From 132dc977fda3309be7773b7659179519df0f33cc Mon Sep 17 00:00:00 2001 From: stgmt Date: Wed, 9 Sep 2026 23:30:59 +0300 Subject: [PATCH 1/6] fix(auth): autoselect the recorded account at the post-migration chooser (#764) When Google hands the session to flow.google.com and redirects to an account chooser, FlowApiClient now auto-selects the profile's recorded account from .gflow_account instead of stalling into an opaque RecaptchaError/exit 1. - Exact, case-insensitive row match on both tiers; anchored so the chooser's "Remove " / "Sign out of " rows can never be clicked. - A chooser is identified positively (chooser path or account rows), so an ordinary expired session still classifies as AuthExpiredError (exit 3). - Unselectable cases raise a typed, non-retryable FlowAccountChooserError (exit 38) naming the URL the session actually landed on. - gflow auth login --account asserts login authenticated as the required account, failing closed on mismatch. - .gflow_account is treated as untrusted input, fixing an untyped failure in the selector and a crash in gflow auth list on a damaged file. Closes #763. Follow-up hardening tracked in #773. --- AGENTS.md | 2 +- CHANGELOG.md | 25 ++ docs/AUTHENTICATION.md | 12 + docs/DEBUGGING.md | 2 +- docs/USAGE.md | 1 + .../memory/ui-selector-drift-error-exit-23.md | 16 + src/gflow_cli/api/client.py | 139 +++++++- src/gflow_cli/cli.py | 30 +- src/gflow_cli/data/redaction.py | 44 ++- src/gflow_cli/diagnostics.py | 9 +- src/gflow_cli/errors.py | 23 ++ src/gflow_cli/profile_store.py | 24 +- tests/api/test_bootstrap_chooser.py | 240 ++++++++++++++ tests/api/test_client_locale_cache.py | 34 ++ tests/auth/test_account_autoselect.py | 192 +++++++++++ tests/data/test_redaction.py | 25 +- tests/e2e/test_account_chooser_e2e.py | 297 ++++++++++++++++++ tests/e2e/test_auth_verification_e2e.py | 24 ++ tests/test_diagnostics_recorder.py | 10 + website/docs/AUTHENTICATION.md | 12 + website/docs/DEBUGGING.md | 2 +- website/docs/USAGE.md | 1 + 22 files changed, 1140 insertions(+), 24 deletions(-) create mode 100644 tests/api/test_bootstrap_chooser.py create mode 100644 tests/auth/test_account_autoselect.py create mode 100644 tests/e2e/test_account_chooser_e2e.py diff --git a/AGENTS.md b/AGENTS.md index a188aa2e..f051faae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,7 +106,7 @@ the five other mirror axes), which no command here can check and no CI gate can - Type hints everywhere; `pyright` strict on `src/gflow_cli`. - Structured logging only (`structlog`) — **never** raw `print()` or `import logging` in `src/`. -- Errors as RFC 9457 Problem Details with stable per-class exit codes (3–37, e.g. 11 is `ConfigurationError` — including `ProfileLockedError` for same-profile lease contention, 16 is the `DataStoreError` family, 19 `SceneConcatError`, 20 `FrameExtractionError`, 21 `ChainPartialError`, 22 `UpscaleUnavailableError`, 25 `FlowAgentUiError`, 28 `UiModeUnavailableError`, 29 `MentionIndexUnavailableError`, 30 `QueueSchemaError`, 37 `InsufficientCreditsError`). See `src/gflow_cli/errors.py::EXIT_CODE_MAP` for the complete mapping. Exit 33 is reserved outside that map: `gflow doctor` findings-present — a successful diagnosis, not an error class. +- Errors as RFC 9457 Problem Details with stable per-class exit codes (3–38, e.g. 11 is `ConfigurationError` — including `ProfileLockedError` for same-profile lease contention, 16 is the `DataStoreError` family, 19 `SceneConcatError`, 20 `FrameExtractionError`, 21 `ChainPartialError`, 22 `UpscaleUnavailableError`, 25 `FlowAgentUiError`, 28 `UiModeUnavailableError`, 29 `MentionIndexUnavailableError`, 30 `QueueSchemaError`, 37 `InsufficientCreditsError`). See `src/gflow_cli/errors.py::EXIT_CODE_MAP` for the complete mapping. Exit 33 is reserved outside that map: `gflow doctor` findings-present — a successful diagnosis, not an error class. - 100-char line length, `ruff` configured. Imports sorted by `ruff` (isort rules). - **YAGNI / least-code**: prefer the smallest change that works. No speculative abstractions (interface/factory with one implementation), no config or flags nobody sets, no dead constants/helpers, no reinventing the stdlib. Review carries this as its own lens — the **D14 over-engineering** dimension of [`pr-council-review`](skills/pr-council-review/SKILL.md) (baseline, always runs). Its rubric is portable; the `ponytail` plugin (see CONTRIBUTING) is an optional accelerant, not a dependency. - **MCP & CLI Schema Symmetry**: Any updates or additions to user-facing CLI command parameters (e.g., `gflow image t2i`, `gflow video`) must be mirrored in the corresponding MCP tool definitions. Never add option/argument fields to Click commands without updating the MCP server implementation. This symmetry is enforced programmatically in CI via `tests/mcp/test_cli_parity.py` (every CLI leaf command needs a mapped MCP tool or an explicit, reasoned exemption) plus the schema checks in `tests/mcp/test_server.py`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dfac6fc..c6aed89e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 exit 23 — which told the user to file a frontend-drift bug about a frontend that was behaving correctly. +- **Account auto-selection at post-migration sign-in chooser** + ([#763](https://github.com/ffroliva/gflow-cli/issues/763)). When Google + Flow hands the session over to `flow.google.com` and redirects to an account + chooser, `FlowApiClient` now auto-selects the profile's recorded account from + `.gflow_account`. If the recorded account is absent or cannot be selected, the + client raises a dedicated, non-retryable `FlowAccountChooserError` (exit code 38), + avoiding generic `UnexpectedError` or selector drift stalls. `gflow auth login` + gains an optional `--account ` option to assert that login authenticates + as the required account. When the click-through does not reach Flow, the error + names the URL the session actually landed on, so a Google challenge that needs a + human is distinguishable from a click that never navigated. Account matching is + case-insensitive on both tiers, matching `--account`'s own comparison, so a + recorded address whose case differs from Google's rendering still selects its + row instead of reporting the account as absent. + +### Fixed + +- **`gflow auth list` no longer fails on a profile whose `.gflow_account` is + damaged.** The reader decoded as UTF-8 and caught only `OSError`, so a + non-UTF-8 or truncated file raised out of `list_profiles()` and broke the + listing for *every* profile, not just the damaged one. The value is also + interpolated into a DOM attribute selector, where a stray quote produced an + untyped failure; unusable content now reads as "no account recorded", which + every caller already handles. + ### Changed - **`gflow auth login` closes the browser for you.** It drives your real Google Chrome diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index dcc4b5bf..34d5d5bc 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -174,6 +174,18 @@ Set ffroliva as default profile. If a profile named after the email local-part already exists, the rename is skipped and the profile keeps the name `default`. +#### `--account ` + +Asserts that the login authenticates as one exact Google account. After the +session verifies, the CLI compares the verified email against `--account` +(case-insensitive) and fails with `FlowAccountChooserError` (exit 38) on a +mismatch — including when no verified email was recorded at all, since an +identity assertion that cannot read the identity must not pass. + +A mismatch means the profile now holds the *other* account's session: re-run +`gflow auth login --profile --account ` while signed in as the +required account. + #### `--browser [auto|chrome|internal]` | Value | Browser used | When to use | diff --git a/docs/DEBUGGING.md b/docs/DEBUGGING.md index 10755e5c..c3f9e5c5 100644 --- a/docs/DEBUGGING.md +++ b/docs/DEBUGGING.md @@ -107,7 +107,7 @@ at command startup). Captured: `FlowAppError` (31), `FlowAgentUiError` (25), `FlowHostMigratedError` (36), `UiModeUnavailableError` (28), -`UiSelectorDriftError` (23), +`UiSelectorDriftError` (23), `FlowAccountChooserError` (38), `TransportTimeoutError` (9), `BrowserSessionClosedError` (15), `WireFormatError` (7), `WafRejectionError` (10), `NetworkError` (6), unexpected exceptions while a page is alive, and `ProfileLockedError` (11) diff --git a/docs/USAGE.md b/docs/USAGE.md index 07892b63..fd022478 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -1787,6 +1787,7 @@ shell scripts can branch on the failure mode without parsing stderr. | `35` | `ExtendUnavailableError` | No Veo extend model is orderable for this account and aspect — the extend family is tier-gated and there is no square variant. **Never auto-retry**: a tier gate does not clear on its own. | | `36` | `FlowHostMigratedError` | Flow served the project from `flow.google.com` and the request could not be represented by the migrated composer, or `GFLOW_CLI_FLOW_HOST=labs.google` disabled it. Supported today: `video t2v`; local-file video i2v/r2v; `image t2i`; and local-file `image i2i`. Image UUID/entity/instruction/Imagen-4 forms, `image batch`, and the `3:4` image aspect remain unsupported. Not selector drift (23) | **Not retryable.** Use one of the supported forms — `--project` is required for images as well as video — or the REST surface (`gflow project list`, `gflow data …`); follow #639 for the remaining matrix | | `37` | `InsufficientCreditsError` | The account's balance is short **for the model it asked for**, so Flow **replaced** the submit control with its `Insufficient credits warning` instead of disabling it. Short, not necessarily empty: measured 2026-09-07, an account holding **50** credits requesting `--model veo-quality` (**100**) rendered the warning. Explicitly **not** selector drift (23): reporting it as drift told users to file a frontend bug over a credit shortfall | Check the balance with `gflow credits user`, then pick a cheaper `--model` (`veo-lite` costs 10), top up, or wait for the allowance to reset. Nothing was submitted, so no credit was spent. `gflow image` draws on a separate daily quota and may still work | +| `38` | `FlowAccountChooserError` | The post-migration hop landed on Google's account chooser and the profile's recorded account (`.gflow_account`) could not be selected automatically (row absent, click-through did not return to the editor, or `--account` mismatch) | **Not retryable**: run `gflow auth login --profile ` and complete the chooser manually, while signed in as the recorded account (re-run `gflow auth login` if the chooser offers a different session) | | `130`| SIGINT | User-interrupted (Ctrl-C) | — | **Exit code 16 — data store / migration error.** Fires when: diff --git a/docs/superpowers/memory/ui-selector-drift-error-exit-23.md b/docs/superpowers/memory/ui-selector-drift-error-exit-23.md index 7d97ae67..efda3e37 100644 --- a/docs/superpowers/memory/ui-selector-drift-error-exit-23.md +++ b/docs/superpowers/memory/ui-selector-drift-error-exit-23.md @@ -16,3 +16,19 @@ description: "Selector-probe failures = typed UiSelectorDriftError exit 23, neve **Remediation contract updated by PR #504 (2026-08-13, #493):** `UiSelectorDriftError._default_remediation` now asks for "the diagnostics JSON and/or debug screenshot referenced in this message, plus the incident bundle's report.md" — the old "debug screenshot from this message" was a false promise on the mode-switch probe, which writes `diag_mode_switch_miss.json` ONLY (no screenshot; the full-page screenshot lives in the incident bundle's `sensitive/`). The exit-23 mode-switch fall-through detail additionally names the unrecognized-new-variant hypothesis. See [[issue-493-third-editor-variant-predict-stop]]. See [[pr-184-e2e-drift-sim-results]], [[flow-library-ui-drift-174]], [[exit-code-map-ordering-invariant-test-pitfall]]. + +**Carve-out recorded by PR #764 (2026-09-08, #763):** a selector-cascade miss on +`accounts.google.com` (the Google account chooser after the post-migration hop) +raises `FlowAccountChooserError` (exit 38), NOT `UiSelectorDriftError` (exit 23). +The chooser is Google-auth UI, not the Flow editor: reporting it as drift would +tell users to file a frontend bug about a working chooser, and the exit-23 +remediation (attach diagnostics, check for a release) cannot fix a missing +account row. The miss is evidence about the *recorded account* (absent row or +a click-through that never reaches the editor). Each raise site interpolates the +observed chooser URL verbatim — there is no URL-kind taxonomy. Explicitly out +of scope: the bot-rejection hop (`.../v3/signin/rejected`) is excluded from the +chooser gate and surfaces as its own error, never as a missing account. +Recovery is `gflow auth login --profile ` while signed in as the recorded +account. Precedent: +exits 36 (`FlowHostMigratedError`) and 37 (`InsufficientCreditsError`) each got +the same carve-out recorded when introduced. diff --git a/src/gflow_cli/api/client.py b/src/gflow_cli/api/client.py index c70c1329..6bcdbc08 100644 --- a/src/gflow_cli/api/client.py +++ b/src/gflow_cli/api/client.py @@ -17,6 +17,7 @@ import base64 import json import os +import re import sys import time import uuid @@ -27,6 +28,7 @@ import structlog from playwright.async_api import BrowserContext, Page, Playwright, async_playwright +from playwright.async_api import TimeoutError as PlaywrightTimeoutError from gflow_cli.api import routes, video_extend from gflow_cli.api._engine import ( @@ -61,7 +63,11 @@ make_transport, resolve_transport_name, ) -from gflow_cli.api.transports._common import await_url_settled, raise_if_migrated +from gflow_cli.api.transports._common import ( + await_url_settled, + flow_host_kind, + raise_if_migrated, +) from gflow_cli.api.transports.base import ( FlowTransportStrategy, SupportsTransportSetup, @@ -75,6 +81,7 @@ parse_video_status, ) from gflow_cli.api.video_extend import ExtendStarted +from gflow_cli.auth.internal_chromium import GOOGLE_REJECTED_BROWSER_ROUTE from gflow_cli.browser_manager import channel_for_profile from gflow_cli.config import BrowserEngine, Settings from gflow_cli.diagnostics import IncidentRecorder, run_retention, validated_incidents_root @@ -86,6 +93,7 @@ BrowserSessionClosedError, ConfigurationError, ContentPolicyError, + FlowAccountChooserError, FlowApiError, # re-exported via gflow_cli.api.__init__ FlowHostMigratedError, NetworkError, @@ -783,6 +791,121 @@ async def _enter_setup(self) -> None: # S1 can share this context rather than opening its own. await self._setup_transport() + async def _handle_account_chooser(self, page: Page) -> bool: + """Select the recorded Google account on accountchooser if encountered (#763). + + Returns True if an account was clicked, False if not on chooser. + Raises FlowAccountChooserError if on chooser but account is missing/not selectable. + + Matching is exact on the account row only: the chooser's loose surfaces + ("Remove ", "Sign out of ", signed-in-as subtitle) would + otherwise win a substring match and click the wrong account, billing it. + """ + from gflow_cli.profile_store import read_account_file + + url = getattr(page, "url", "") or "" + # Exact host match, never a substring test: a Flow URL merely carrying + # accounts.google.com in a ?continue= param must not read as a chooser. + # The rejected-browser hop is not a chooser either and must surface as + # its own error rather than a missing account. + # Total by construction (same discipline as flow_host_kind): a probe + # error must never displace the real bootstrap failure, and suites + # drive this path with mocked pages whose url is not a string. + if not isinstance(url, str): + return False + try: + parts = urlsplit(url) + host = (parts.hostname or "").lower() + except ValueError: + return False + is_accounts_host = parts.scheme == "https" and host == "accounts.google.com" + if not is_accounts_host or GOOGLE_REJECTED_BROWSER_ROUTE in url: + return False + + # Identify a chooser POSITIVELY. The host gate accepts every + # accounts.google.com landing, and most are not choosers — the email form, a + # password challenge, a consent interstitial — where there is nothing to pick. + # Reporting those as a chooser misdirects the operator and changes an exit code + # callers branch on: they otherwise reach the transport's 401 and classify as + # AuthExpiredError (exit 3). Two independent signals, because each covers the + # other's blind spot — Google renaming the path, or a chooser whose rows carry + # no data-email. Neither matching means we return False, which is exactly how + # this path behaved before the feature existed. + on_chooser_path = parts.path.rstrip("/").endswith("accountchooser") + if not on_chooser_path and await page.locator("[data-email]").count() == 0: + return False + + email = read_account_file(self.profile_dir) + if not email: + raise FlowAccountChooserError( + detail=( + f"Google sign-in/chooser displayed at {url} but no account is recorded " + f"in this profile to auto-select." + ) + ) + + # Exact row match only (D3): data-email is the chooser's stable per-account + # anchor. A substring/text-engine fallback would match "Remove " + # or "Sign out of " and click a DOM-order-first wrong account. + # Case-insensitive on BOTH tiers, because `gflow auth login --account` + # already compares with `.lower()` and `read_account_file` normalises + # nothing: an address recorded in one case and rendered by Google in + # another otherwise passes the --account assertion and then misses the + # row, raising "not found among selectable accounts" while the account + # sits on the chooser. CSS attribute matching is case-sensitive unless + # the `i` flag is given; the text fallback stays ANCHORED so relaxing + # case does not start matching "Remove " / "Sign out of " + # — clicking those signs the operator out instead of in. + row = page.locator(f'[data-email="{email}" i]') + count = await row.count() + if count == 0: + row = page.get_by_text(re.compile(rf"^{re.escape(email)}$", re.IGNORECASE)) + count = await row.count() + + if count == 0: + raise FlowAccountChooserError( + detail=( + f"Account chooser displayed at {url} but recorded account '{email}' " + f"was not found among selectable accounts." + ) + ) + + # The row is the account's entry; verify we actually leave the chooser. + await row.first.click() + # `wait_for_url` returns None and signals a miss by RAISING, so its return value + # is falsy on success as well as failure — testing it inverted the check and made + # every successful click raise. Catch the raise instead. + # + # The landing predicate is "on any Flow host", not a `**/project/**` glob: the + # bootstrap URL is `labs.google/fx/tools/flow` with no /project/ segment, and only + # the migrated origin serves /project/. `flow_host_kind` is the codebase's + # exact-host classifier (a substring test matches any URL merely mentioning the + # host in a ?continue= param), and it answers for both cohorts. + try: + await page.wait_for_url(lambda u: flow_host_kind(u) is not None, timeout=30_000) + except PlaywrightTimeoutError as exc: + # Where the click left us IS the diagnosis, so the detail has to carry it + # (its sibling raise above interpolates the chooser URL for the same + # reason). `flow_host_kind` is a host-only match that accepts every Flow + # landing this codebase knows, `/about` included, so a timeout here is + # never the predicate being too narrow — the session is still on a Google + # surface. WHICH surface is the whole question: a challenge needs a human, + # a consent screen needs a click, and a URL still equal to `url` above + # means the click never navigated at all. Shipped without this, the branch + # fired live on 2026-09-09 and said only "did not reach Flow within 30s". + landed = page.url + raise FlowAccountChooserError( + detail=( + f"Clicked recorded account '{email}' on the chooser but the session " + f"did not reach Flow within 30s — it is at {landed}." + ) + ) from exc + logger.info( + "client.account_chooser_autoselected", + account=redact_sensitive_text(email), + ) + return True + async def _bootstrap_and_resolve_locale(self) -> None: """Navigate the bootstrap page and settle the account locale (#580, #587). @@ -819,6 +942,20 @@ async def _bootstrap_and_resolve_locale(self) -> None: self._account_locale, from_url = await self._resolve_account_locale( self._page, settle=settle ) + # #763: the chooser hop lands through the same post-goto redirect chain as + # the locale hop, so it is observable only after the settle above. + # BOTH outputs of the first resolve are the chooser's, and both must be + # replaced. `self._account_locale` would otherwise carry + # accounts.google.com's for the rest of the run. `from_url` + # is subtler and was wrong: a chooser yields None, and + # `next_locale_state(cached="pt", observed=None)` returns PROVISIONAL, so + # the fold below wrote a DEMOTION of a committed locale on every chooser + # hop (#643's bug class). The post-click resolve holds the editor's real + # segment — fold that. + if await self._handle_account_chooser(self._page): + self._account_locale, from_url = await self._resolve_account_locale( + self._page, settle=False + ) if not settle: # Kept (not merged into account_locale_state) because field reports key # on this event to tell "the settle was skipped" from "it timed out". diff --git a/src/gflow_cli/cli.py b/src/gflow_cli/cli.py index 13563a49..8d6c579b 100644 --- a/src/gflow_cli/cli.py +++ b/src/gflow_cli/cli.py @@ -36,6 +36,7 @@ from gflow_cli.observability import DEBUG_LEVEL, configure_logging from gflow_cli.update_check import UpdateNotice, maybe_notify_update +logger = structlog.get_logger(__name__) console = Console() @@ -261,7 +262,12 @@ def _maybe_rename_first_profile( help="Browser strategy for login. 'chrome' bypasses Google secure blocks.", envvar="GFLOW_CLI_AUTH_BROWSER", ) -def auth_login(profile: str | None, browser: str | None) -> None: +@click.option( + "--account", + default=None, + help="Assert that login authenticates as this exact Google account (email address).", +) +def auth_login(profile: str | None, browser: str | None, account: str | None = None) -> None: """One-time interactive sign-in. Opens a browser window.""" from gflow_cli.browser_manager import is_chrome_available from gflow_cli.errors import EXIT_CODE_MAP, GFlowError @@ -284,6 +290,28 @@ def auth_login(profile: str | None, browser: str | None) -> None: try: pdir = asyncio.run(auth_mod.login(name, browser=selected_browser)) + if account: + from gflow_cli.errors import FlowAccountChooserError + + actual_account = profile_store.read_account_file(pdir) + if actual_account is None or actual_account.lower() != account.strip().lower(): + held = actual_account or "nothing recorded" + logger.warning( + "auth.account_assert_failed", + # Not the addresses: redact_sensitive_text maps every address to + # one constant, so those two fields were identical tokens and no + # signal, while the console prints both in the clear below. What + # the event can carry is the distinction it exists to make. + held_recorded=actual_account is not None, + ) + raise FlowAccountChooserError( + detail=( + f"Login completed but the profile now holds '{held}', which does not " + f"match required --account '{account}'. Re-run " + f"`gflow auth login --profile {name} --account {account.strip()}` " + f"while signed in as the required account." + ) + ) except GFlowError as e: console.print(f"[red]{e}[/red]") if e.remediation_hint: diff --git a/src/gflow_cli/data/redaction.py b/src/gflow_cli/data/redaction.py index 6e9e4997..f9e55344 100644 --- a/src/gflow_cli/data/redaction.py +++ b/src/gflow_cli/data/redaction.py @@ -16,17 +16,39 @@ # exception message ("HTTP 403: ... Bearer ya29.xxx") would pass through it # verbatim — these patterns cover the prose case. All case-insensitive: header # dumps are frequently lowercased ("cookie: sapisid=..."). -_SECRET_TEXT_PATTERNS = ( - re.compile(r"Bearer\s+[A-Za-z0-9._~+/=-]+", re.IGNORECASE), - re.compile(r"SAPISIDHASH\s+\S+", re.IGNORECASE), +_SECRET_TEXT_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( + (re.compile(r"Bearer\s+[A-Za-z0-9._~+/=-]+", re.IGNORECASE), ""), + (re.compile(r"SAPISIDHASH\s+\S+", re.IGNORECASE), ""), # Google auth cookie pairs — both header ("SAPISID=x") and equals forms. # SAPISIDHASH before SAPISID before the bare SID family so the longest # name wins; \b keeps bare SID from firing inside unrelated words. - re.compile( - r"\b(?:__Secure-(?:next-auth\.session-token|[13]PSID[A-Z]*)" - r"|SAPISIDHASH|SAPISID|APISID|SSID|HSID|OSID|LSID|SID)" - r"\s*=\s*\S+", - re.IGNORECASE, + ( + re.compile( + r"\b(?:__Secure-(?:next-auth\.session-token|[13]PSID[A-Z]*)" + r"|SAPISIDHASH|SAPISID|APISID|SSID|HSID|OSID|LSID|SID)" + r"\s*=\s*\S+", + re.IGNORECASE, + ), + "", + ), + # Account addresses (chooser/login identity in error details and logs). + # Kept distinct from : an address is correlatable PII, + # not a credential, and operators triage chooser failures by cohort. + ( + # Bounded so a path is not mistaken for an address: `C:/x@y.co/path` and + # `/var/x@y.io/cache` are ordinary paths, and this pattern runs on EVERY + # persisted error detail, transport snippet and worker payload — the one + # artifact left for debugging a failure, where a silent rewrite cannot be + # told apart from the original text. The lookbehind rejects a match starting + # mid-token or right after a path separator; the lookahead rejects one that + # continues into a path segment. + re.compile( + r"(?", ), ) # Any whitespace-delimited token carrying signed-query material — covers full @@ -85,11 +107,11 @@ def redact_error_detail(detail: str) -> str: """Scrub a free-text error detail before it is persisted to the DB (#341). Applied to ``GFlowError.to_problem_details()['detail']`` on the FAILED - operation write path. Scrubs bearer/SAPISIDHASH/cookie-pair secrets, drops + operation write path. Scrubs bearer/SAPISIDHASH/cookie-pair secrets and account addresses, drops URLs carrying signed-query material, and truncates post-redaction as defense-in-depth against a scrub bypass. """ - for pattern in _SECRET_TEXT_PATTERNS: - detail = pattern.sub("", detail) + for pattern, replacement in _SECRET_TEXT_PATTERNS: + detail = pattern.sub(replacement, detail) detail = _SIGNED_QUERY_TOKEN_PATTERN.sub("", detail) return detail[:ERROR_DETAIL_MAX_CHARS] diff --git a/src/gflow_cli/diagnostics.py b/src/gflow_cli/diagnostics.py index 72c23261..42e55718 100644 --- a/src/gflow_cli/diagnostics.py +++ b/src/gflow_cli/diagnostics.py @@ -1019,6 +1019,7 @@ def should_capture(self, exc: BaseException) -> bool: from gflow_cli.errors import ( AuthExpiredError, ContentPolicyError, + FlowAccountChooserError, GFlowError, ProfileLockedError, ) @@ -1027,8 +1028,12 @@ def should_capture(self, exc: BaseException) -> bool: return False if not isinstance(exc, Exception): return False # cancellation/KeyboardInterrupt/SystemExit are not incidents - if isinstance(exc, (ContentPolicyError, AuthExpiredError)): - return False # deterministic operator remediation; DOM adds nothing + if isinstance(exc, (ContentPolicyError, AuthExpiredError, FlowAccountChooserError)): + # Deterministic operator remediation; DOM adds nothing. The chooser error + # additionally fires ONLY while the page is on accounts.google.com, so a + # bundle would carry a DOM dump and a full-page screenshot of a Google auth + # surface into the artifact users are prompted to attach to GitHub issues. + return False if isinstance(exc, ProfileLockedError): return True # metadata-only incident if isinstance(exc, _capture_triggers()): diff --git a/src/gflow_cli/errors.py b/src/gflow_cli/errors.py index 0989a5ae..44180eab 100644 --- a/src/gflow_cli/errors.py +++ b/src/gflow_cli/errors.py @@ -30,6 +30,7 @@ "FlowAgentUiError", "FlowApiError", "FlowAppError", + "FlowAccountChooserError", "FlowHostMigratedError", "FrameExtractionError", "GFlowError", @@ -756,6 +757,23 @@ class FlowHostMigratedError(GFlowError): ) +class FlowAccountChooserError(GFlowError): + """Raised when Google Flow lands on an account chooser or sign-in hop + and the profile's recorded Google account cannot be selected automatically. + + **Not retryable** (exit code 38). Retrying with the same profile and recorded + account into a signed-out or missing chooser row cannot succeed without + manual operator interaction via gflow auth login. + """ + + problem_type = "https://gflow-cli.dev/errors/flow-account-chooser" + title = "Recorded Google account not selectable" + _default_remediation = ( + "Run `gflow auth login --profile ` and complete the account chooser " + "manually while signed in as the recorded account." + ) + + class UiModeUnavailableError(GFlowError): """Raised when the Flow UI arm a command REQUIRES (``--ui-mode`` / ``GFLOW_CLI_UI_MODE``, or inferred — e.g. ``-i`` instructions force agentic) @@ -1245,6 +1263,11 @@ def __init__( # frontend" (per-account, not retryable) from genuine selector drift # (23), which it used to masquerade as. FlowHostMigratedError: 36, + # FlowAccountChooserError: Google Flow landed on account chooser + # and the recorded account row could not be selected automatically. + # Direct GFlowError subclass; exit 38 distinguishes account chooser stall + # from generic errors (1) without parsing stderr. + FlowAccountChooserError: 38, # UiModeUnavailableError (issue #299): a command's required arm (--ui-mode / # inferred) couldn't be reached after a best-effort switch. Direct GFlowError # subclass — retryable policy abort, distinct from FlowAgentUiError (25). diff --git a/src/gflow_cli/profile_store.py b/src/gflow_cli/profile_store.py index 5359ae9c..1ed5dfc6 100644 --- a/src/gflow_cli/profile_store.py +++ b/src/gflow_cli/profile_store.py @@ -107,7 +107,7 @@ def list_profiles() -> list[ProfileMeta]: name = entry.name[len(PROFILE_DIR_PREFIX) :] s = status(name) last_used = _last_modified(entry) - google_account = _read_account_file(entry) + google_account = read_account_file(entry) out.append( ProfileMeta( name=name, @@ -350,13 +350,27 @@ def account_locale_for(profile_name: str) -> str | None: return cached -def _read_account_file(profile_path: Path) -> str | None: - """Read the Google account email from the profile's .gflow_account file.""" +def read_account_file(profile_path: Path) -> str | None: + """Read the Google account email from the profile's .gflow_account file. + + The file is untrusted input — a truncated write, a hand edit, a Google + display string. Its value is interpolated into a CSS attribute selector + (``[data-email="{email}" i]``), where a double quote closes the attribute + early and makes ``locator.count()`` raise a raw Playwright parse error that + escapes every typed handler as a generic exit 1. Guarding here rather than + at the call site fixes every caller at once: the chooser raises its own + "nothing recorded" error, and ``list_profiles`` keeps working on a profile + whose file is damaged (it decodes as UTF-8, so a non-UTF-8 file otherwise + breaks ``gflow auth list`` for every profile, not just the damaged one). + """ account_file = profile_path / ACCOUNT_FILE try: - return account_file.read_text(encoding="utf-8").strip() or None - except OSError: + raw = account_file.read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError): + return None + if not raw or "@" not in raw or '"' in raw or any(c.isspace() for c in raw): return None + return raw def _last_modified(path: Path) -> datetime | None: diff --git a/tests/api/test_bootstrap_chooser.py b/tests/api/test_bootstrap_chooser.py new file mode 100644 index 00000000..73182d38 --- /dev/null +++ b/tests/api/test_bootstrap_chooser.py @@ -0,0 +1,240 @@ +"""Tests for bootstrap account chooser auto-selection in FlowApiClient.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest +from playwright.async_api import TimeoutError as PlaywrightTimeoutError + +from gflow_cli.errors import FlowAccountChooserError + + +def _chooser_page(url: str, row_count: int) -> tuple[MagicMock, AsyncMock]: + """Build a chooser page whose data-email row has the given count. + + The exact-row locator is the first ``page.locator`` call; the exact-text + fallback uses ``page.get_by_text``. ``wait_for_url`` defaults to an + un-awaited MagicMock — tests that reach it must override it. + """ + page = MagicMock() + page.url = url + row = AsyncMock() + row.count = AsyncMock(return_value=row_count) + row.first = AsyncMock() + page.locator.return_value = row + return page, row + + +@pytest.mark.asyncio +async def test_bootstrap_detects_chooser_and_autoselects_account(tmp_path: Path) -> None: + """When bootstrap hits account chooser, it clicks the row matching .gflow_account.""" + from gflow_cli.api.client import FlowApiClient + from gflow_cli.profile_store import ACCOUNT_FILE + + profile = tmp_path / "profile_p1" + profile.mkdir() + (profile / ACCOUNT_FILE).write_text("user@example.com\n", encoding="utf-8") + + client = FlowApiClient(profile_dir=profile) + page, row = _chooser_page( + "https://accounts.google.com/v3/signin/accountchooser?continue=flow.google.com", + row_count=1, + ) + # `Page.wait_for_url` is annotated `-> None`: it returns nothing and signals a miss + # by raising. A mock that returns a URL string encodes a contract Playwright does + # not have, and it hid an inverted check that made every successful click raise. + page.wait_for_url = AsyncMock(return_value=None) + + res = await client._handle_account_chooser(page) + assert res is True + # The exact row selector is used, and it is clicked. The trailing `i` is the CSS + # case-insensitivity flag: `--account` compares with `.lower()`, so the row match + # must too, or a case variant passes the assert and then misses its row. + assert page.locator.call_args[0][0] == '[data-email="user@example.com" i]' + row.first.click.assert_awaited_once() + page.wait_for_url.assert_awaited_once() + # The landing predicate accepts BOTH Flow cohorts and rejects the chooser itself, + # so it cannot be satisfied by simply still being on accounts.google.com. + predicate = page.wait_for_url.call_args[0][0] + assert predicate("https://labs.google/fx/tools/flow?hl=en") is True + assert predicate("https://flow.google.com/project/p1") is True + assert predicate("https://accounts.google.com/v3/signin/accountchooser") is False + assert page.wait_for_url.call_args[1]["timeout"] == 30_000 + + +@pytest.mark.asyncio +async def test_bootstrap_chooser_absent_account_raises_flow_account_chooser_error( + tmp_path: Path, +) -> None: + """When recorded account is not found on chooser, FlowAccountChooserError is raised.""" + from gflow_cli.api.client import FlowApiClient + from gflow_cli.errors import EXIT_CODE_MAP + from gflow_cli.profile_store import ACCOUNT_FILE + + profile = tmp_path / "profile_p1" + profile.mkdir() + (profile / ACCOUNT_FILE).write_text("recorded@example.com\n", encoding="utf-8") + + client = FlowApiClient(profile_dir=profile) + page, row = _chooser_page( + "https://accounts.google.com/v3/signin/accountchooser?continue=flow.google.com", + row_count=0, + ) + # The exact-text fallback also matches nothing. + page.get_by_text = MagicMock(return_value=MagicMock(count=AsyncMock(return_value=0))) + + with pytest.raises(FlowAccountChooserError) as exc_info: + await client._handle_account_chooser(page) + + assert "recorded@example.com" in str(exc_info.value) + assert EXIT_CODE_MAP[FlowAccountChooserError] == 38 + # The fallback is an ANCHORED case-insensitive pattern, not `exact=True`. Assert the + # anchoring behaviourally rather than by repr: an unanchored relaxation would match + # the chooser's "Sign out of " row, and clicking that signs the operator out + # instead of in — the precise hazard `exact=True` was there to prevent. + page.get_by_text.assert_called_once() + pattern = page.get_by_text.call_args[0][0] + assert pattern.search("RECORDED@example.com"), "must match a case variant" + assert not pattern.search("Sign out of recorded@example.com"), "must refuse a superset" + assert not pattern.search("Remove recorded@example.com"), "must refuse a superset" + + +@pytest.mark.asyncio +async def test_bootstrap_chooser_exact_match_never_clicks_superset_account( + tmp_path: Path, +) -> None: + """A superset address on the chooser must not be clicked (billing safety). + + Regression for the substring-match defect: with ``an@corp.com`` recorded and + only ``ryan@corp.com`` present, neither the exact data-email row nor the + exact-text fallback may match — the handler must raise, never click. + """ + from gflow_cli.api.client import FlowApiClient + from gflow_cli.profile_store import ACCOUNT_FILE + + profile = tmp_path / "profile_p1" + profile.mkdir() + (profile / ACCOUNT_FILE).write_text("an@corp.com\n", encoding="utf-8") + + client = FlowApiClient(profile_dir=profile) + page, row = _chooser_page( + "https://accounts.google.com/v3/signin/accountchooser", + row_count=0, + ) + page.get_by_text = MagicMock(return_value=MagicMock(count=AsyncMock(return_value=0))) + + with pytest.raises(FlowAccountChooserError): + await client._handle_account_chooser(page) + row.first.click.assert_not_awaited() + page.wait_for_url.assert_not_called() + + +@pytest.mark.asyncio +async def test_bootstrap_chooser_click_no_editor_raises_flow_account_chooser_error( + tmp_path: Path, +) -> None: + """Click-through that never reaches the editor raises FlowAccountChooserError.""" + from gflow_cli.api.client import FlowApiClient + from gflow_cli.profile_store import ACCOUNT_FILE + + profile = tmp_path / "profile_p1" + profile.mkdir() + (profile / ACCOUNT_FILE).write_text("user@example.com\n", encoding="utf-8") + + client = FlowApiClient(profile_dir=profile) + page, row = _chooser_page( + "https://accounts.google.com/v3/signin/accountchooser", + row_count=1, + ) + # A landing that never happens is a Playwright TimeoutError out of wait_for_url — + # the real failure signal, not a returned URL. + page.wait_for_url = AsyncMock(side_effect=PlaywrightTimeoutError("timed out")) + + with pytest.raises(FlowAccountChooserError) as exc_info: + await client._handle_account_chooser(page) + assert "did not reach Flow" in str(exc_info.value) + # The Playwright timeout is chained, not swallowed, so the bundle keeps the cause. + assert isinstance(exc_info.value.__cause__, PlaywrightTimeoutError) + + +@pytest.mark.asyncio +async def test_bootstrap_rejected_browser_hop_is_not_a_chooser(tmp_path: Path) -> None: + """The bot-rejection hop must surface as its own error, never a missing account.""" + from gflow_cli.api.client import FlowApiClient + from gflow_cli.profile_store import ACCOUNT_FILE + + profile = tmp_path / "profile_p1" + profile.mkdir() + (profile / ACCOUNT_FILE).write_text("user@example.com\n", encoding="utf-8") + + client = FlowApiClient(profile_dir=profile) + page, row = _chooser_page("https://accounts.google.com/v3/signin/rejected", row_count=0) + + res = await client._handle_account_chooser(page) + assert res is False + page.locator.assert_not_called() + + +@pytest.mark.asyncio +async def test_bootstrap_chooser_non_string_url_is_not_a_chooser( + tmp_path: Path, +) -> None: + """A mocked page whose url is not a string must not raise (probe totality).""" + from gflow_cli.api.client import FlowApiClient + from gflow_cli.profile_store import ACCOUNT_FILE + + profile = tmp_path / "profile_p1" + profile.mkdir() + (profile / ACCOUNT_FILE).write_text("user@example.com\n", encoding="utf-8") + + client = FlowApiClient(profile_dir=profile) + page = MagicMock() + page.url = MagicMock(name="mock.url") + + assert await client._handle_account_chooser(page) is False + page.locator.assert_not_called() + + +@pytest.mark.asyncio +async def test_bootstrap_chooser_landing_timeout_names_where_the_page_landed( + tmp_path: Path, +) -> None: + """A landing timeout must report the URL the click actually left us on. + + The sibling raise above interpolates the chooser URL; this branch shipped + without it and fired live on 2026-09-09 saying only "did not reach Flow + within 30s". `flow_host_kind` is a host-only match that accepts every known + Flow landing (including `/about`), so a timeout means the session is still on + a Google surface — and *which* surface is the whole diagnosis: a password + challenge needs a human, a consent screen needs a click, and an unchanged + chooser URL means our click never navigated at all. Without the URL those + are one indistinguishable exit 38. + """ + from gflow_cli.api.client import FlowApiClient + from gflow_cli.profile_store import ACCOUNT_FILE + + profile = tmp_path / "profile_p1" + profile.mkdir() + (profile / ACCOUNT_FILE).write_text("user@example.com\n", encoding="utf-8") + + client = FlowApiClient(profile_dir=profile) + page, row = _chooser_page( + "https://accounts.google.com/v3/signin/accountchooser", + row_count=1, + ) + interstitial = "https://accounts.google.com/signin/v2/challenge/pwd" + + async def _click_moves_to_interstitial(*_args: object, **_kwargs: object) -> None: + page.url = interstitial + + row.first.click = AsyncMock(side_effect=_click_moves_to_interstitial) + page.wait_for_url = AsyncMock(side_effect=PlaywrightTimeoutError("timed out")) + + with pytest.raises(FlowAccountChooserError) as exc_info: + await client._handle_account_chooser(page) + + # The CURRENT url, not the chooser url captured on entry: reporting the entry + # url would claim "still on the chooser" for a click that did navigate. + assert interstitial in str(exc_info.value) diff --git a/tests/api/test_client_locale_cache.py b/tests/api/test_client_locale_cache.py index 3162a84a..4f35b0be 100644 --- a/tests/api/test_client_locale_cache.py +++ b/tests/api/test_client_locale_cache.py @@ -439,3 +439,37 @@ async def test_the_settle_wait_is_skipped_when_the_url_already_answered( assert client._account_locale == "pt" assert page.lang_probed is False, "the URL answered; do not touch " assert page.lang_waited is False, "the URL answered; do not pay the settle-wait" + + +async def test_chooser_hop_does_not_demote_a_learned_locale( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A chooser click-through must fold the EDITOR's locale, not the chooser's. + + The chooser page yields no locale segment, so the first resolve returns + ``from_url=None``. `next_locale_state(cached="pt", observed=None)` returns + PROVISIONAL — a demotion — and it is written to disk. The post-click resolve + holds the real segment and used to discard it (`self._account_locale, _ =`), + so every chooser hop quietly downgraded a committed locale, which is the bug + class of #643. The comment above the call claimed "the on-disk cache is safe"; + it was not. + """ + from unittest.mock import MagicMock + + write_account_locale(tmp_path, "pt") + + page = MagicMock() + page.goto = AsyncMock(return_value=None) + client = FlowApiClient(tmp_path) + client._page = page # type: ignore[assignment] + + # First resolve runs on the chooser: no segment. Second runs on the editor. + resolves = iter([("en", None), ("pt", "pt")]) + monkeypatch.setattr( + client, "_resolve_account_locale", AsyncMock(side_effect=lambda *a, **k: next(resolves)) + ) + monkeypatch.setattr(client, "_handle_account_chooser", AsyncMock(return_value=True)) + + await client._bootstrap_and_resolve_locale() + + assert read_account_locale(tmp_path) == "pt", "a chooser hop must not demote a learned locale" diff --git a/tests/auth/test_account_autoselect.py b/tests/auth/test_account_autoselect.py new file mode 100644 index 00000000..948bfc1b --- /dev/null +++ b/tests/auth/test_account_autoselect.py @@ -0,0 +1,192 @@ +"""Tests for FlowAccountChooserError (exit code 38) and account auto-selection.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import structlog + +from gflow_cli.errors import ( + EXIT_CODE_MAP, + FlowAccountChooserError, + GFlowError, + is_retryable, +) + + +def test_flow_account_chooser_error_class_invariants() -> None: + """FlowAccountChooserError is a non-retryable GFlowError with RFC 9457 attributes.""" + err = FlowAccountChooserError( + detail=( + "Account chooser displayed but recorded account 'user@example.com' was not selectable." + ) + ) + assert isinstance(err, GFlowError) + assert not is_retryable(err) + assert err.problem_type == "https://gflow-cli.dev/errors/flow-account-chooser" + assert err.title == "Recorded Google account not selectable" + assert "gflow auth login" in err.remediation_hint + assert "complete the account chooser" in err.remediation_hint + + +def test_flow_account_chooser_error_exit_code_38() -> None: + """FlowAccountChooserError maps to exit code 38 in EXIT_CODE_MAP.""" + err = FlowAccountChooserError(detail="test") + assert EXIT_CODE_MAP[FlowAccountChooserError] == 38 + # Check isinstance walk correctly resolves to 38 + code = next(c for cls, c in EXIT_CODE_MAP.items() if isinstance(err, cls)) + assert code == 38 + + +def test_read_account_file_returns_email(tmp_path: Path) -> None: + """read_account_file helper returns stripped email or None.""" + from gflow_cli.profile_store import ACCOUNT_FILE, read_account_file + + profile_dir = tmp_path / "profile_test" + profile_dir.mkdir() + assert read_account_file(profile_dir) is None + + (profile_dir / ACCOUNT_FILE).write_text(" User.Test@Gmail.Com \n", encoding="utf-8") + assert read_account_file(profile_dir) == "User.Test@Gmail.Com" + + +def test_auth_login_with_account_mismatch_raises( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """gflow auth login --account asserts against verified session email and fails with exit 38.""" + from click.testing import CliRunner + + from gflow_cli.cli import main as cli + + # Mock login to write one email, but caller asked for a different account + async def _mock_login(name: str, browser: str = "auto", headless: bool = False) -> Path: + pdir = tmp_path / f"profile_{name}" + pdir.mkdir(parents=True, exist_ok=True) + (pdir / ".gflow_account").write_text("actual@example.com", encoding="utf-8") + return pdir + + monkeypatch.setattr("gflow_cli.auth.login", _mock_login) + monkeypatch.setenv("GFLOW_CLI_HOME", str(tmp_path)) + + runner = CliRunner() + result = runner.invoke( + cli, + ["auth", "login", "--profile", "test", "--account", "expected@example.com"], + ) + assert result.exit_code == 38 + assert "Recorded Google account not selectable" in result.output or ( + "does not match" in result.output + ) + + +def test_auth_login_with_account_match_succeeds( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """gflow auth login --account passes when verified email matches (success path).""" + from click.testing import CliRunner + + from gflow_cli.cli import main as cli + + async def _mock_login(name: str, browser: str = "auto", headless: bool = False) -> Path: + pdir = tmp_path / f"profile_{name}" + pdir.mkdir(parents=True, exist_ok=True) + (pdir / ".gflow_account").write_text("actual@example.com", encoding="utf-8") + return pdir + + monkeypatch.setattr("gflow_cli.auth.login", _mock_login) + monkeypatch.setenv("GFLOW_CLI_HOME", str(tmp_path)) + + runner = CliRunner() + result = runner.invoke( + cli, + ["auth", "login", "--profile", "test", "--account", "Actual@Example.com"], + ) + assert result.exit_code == 0, result.output + assert "Session saved" in result.output + + +def test_account_mismatch_event_carries_signal_not_two_identical_tokens( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + install_log_capture: structlog.testing.LogCapture, +) -> None: + """The mismatch event must say something a log-only operator can act on. + + `redact_sensitive_text` maps EVERY address to the one constant + ````, so logging `required=` and `held=` through it emitted + two identical tokens — the event could not answer the only question it + exists for: was a different account held, or none at all? Meanwhile the + console prints both addresses in the clear on the very next line, so the + redaction bought nothing and cost the field its meaning. + """ + from click.testing import CliRunner + + from gflow_cli.cli import main as cli + + async def _mock_login(name: str, browser: str = "auto", headless: bool = False) -> Path: + pdir = tmp_path / f"profile_{name}" + pdir.mkdir(parents=True, exist_ok=True) + (pdir / ".gflow_account").write_text("actual@example.com", encoding="utf-8") + return pdir + + monkeypatch.setattr("gflow_cli.auth.login", _mock_login) + monkeypatch.setenv("GFLOW_CLI_HOME", str(tmp_path)) + # The CLI reconfigures structlog on entry, which would replace the capture + # processor installed by the fixture and swallow every event. + monkeypatch.setattr("gflow_cli.cli.configure_logging", lambda *a, **k: None) + + CliRunner().invoke( + cli, ["auth", "login", "--profile", "test", "--account", "expected@example.com"] + ) + + events = [ + e for e in install_log_capture.entries if e.get("event") == "auth.account_assert_failed" + ] + assert events, "a mismatch must be logged" + event = events[0] + + identical = [v for v in event.values() if v == ""] + assert len(identical) < 2, f"two identical tokens carry no signal: {event}" + assert event["held_recorded"] is True, "must distinguish held-someone-else from held-nothing" + + +class TestAccountFileIsUntrustedInput: + """`.gflow_account` is a file on disk, so its content is untrusted. + + It is interpolated into a CSS attribute selector + (`[data-email="{email}" i]`). A value containing a double quote produces + `[data-email="a"b@x.com" i]`, and `locator.count()` then raises a raw + Playwright parse error that escapes `_handle_account_chooser` past every + FlowAccountChooserError handler — a generic exit 1, which is the symptom + class #763 exists to remove. Guarding the shared reader fixes every caller + at once: the chooser raises its own typed "nothing recorded" error, and + `gflow auth list` keeps working. + """ + + def test_a_quote_bearing_value_reads_as_absent(self, tmp_path: Path) -> None: + from gflow_cli.profile_store import ACCOUNT_FILE, read_account_file + + (tmp_path / ACCOUNT_FILE).write_text('a"b@x.com', encoding="utf-8") + assert read_account_file(tmp_path) is None + + def test_non_utf8_content_reads_as_absent(self, tmp_path: Path) -> None: + from gflow_cli.profile_store import ACCOUNT_FILE, read_account_file + + # read_text catches only OSError today, so this raises UnicodeDecodeError + # out of every caller — including list_profiles(), breaking `gflow auth + # list` for every profile, not just the damaged one. + (tmp_path / ACCOUNT_FILE).write_bytes(b"\xff\xfe not utf 8") + assert read_account_file(tmp_path) is None + + def test_a_value_with_no_at_sign_reads_as_absent(self, tmp_path: Path) -> None: + from gflow_cli.profile_store import ACCOUNT_FILE, read_account_file + + (tmp_path / ACCOUNT_FILE).write_text("truncated-write", encoding="utf-8") + assert read_account_file(tmp_path) is None + + def test_an_ordinary_address_still_reads(self, tmp_path: Path) -> None: + from gflow_cli.profile_store import ACCOUNT_FILE, read_account_file + + (tmp_path / ACCOUNT_FILE).write_text("me@example.com\n", encoding="utf-8") + assert read_account_file(tmp_path) == "me@example.com" diff --git a/tests/data/test_redaction.py b/tests/data/test_redaction.py index fd06e026..763bc85d 100644 --- a/tests/data/test_redaction.py +++ b/tests/data/test_redaction.py @@ -1,4 +1,4 @@ -from gflow_cli.data.redaction import prompt_fields, redact_metadata +from gflow_cli.data.redaction import prompt_fields, redact_error_detail, redact_metadata def test_prompt_fields_store_mode_stores_text_and_hash() -> None: @@ -56,3 +56,26 @@ def test_session_id_is_redacted() -> None: # The project id is a plain resource identifier and stays readable — it is # what makes a bundle diagnosable at all. assert ctx["projectId"] == "7d3d6bd9-a39f-4c2d-b772-146e73e539cf" + + +class TestEmailRedactionScope: + """The `` pattern must scrub addresses without eating paths. + + Added with the account-chooser feature so chooser errors do not persist an + operator's address, and shipped with no test. It runs on EVERY persisted + error detail (`data/recorder.py`), every transport response snippet + (`transports/_common.py`, `transports/batchexecute.py`) and both worker paths + (`worker/codec.py`, `worker/daemon.py`) — so an over-match silently rewrites + the one artifact left for debugging a failure, with nothing to distinguish a + redaction from the original text. + """ + + def test_a_real_address_is_scrubbed(self) -> None: + assert redact_error_detail("contact me@example.com now") == ("contact now") + + def test_a_filesystem_path_is_not_an_address(self) -> None: + # Verified failing before the fix: '' replaced 'x@y.co'. + assert redact_error_detail("file at C:/x@y.co/path") == "file at C:/x@y.co/path" + + def test_a_posix_path_is_not_an_address(self) -> None: + assert redact_error_detail("read /var/x@y.io/cache") == "read /var/x@y.io/cache" diff --git a/tests/e2e/test_account_chooser_e2e.py b/tests/e2e/test_account_chooser_e2e.py new file mode 100644 index 00000000..81f8f988 --- /dev/null +++ b/tests/e2e/test_account_chooser_e2e.py @@ -0,0 +1,297 @@ +"""E2E for the post-migration account-chooser autoselect path (#763). + +**Why this is an e2e and not one more unit test.** The defect this file pins +shipped through a fully green unit suite: the suite mocked ``page.wait_for_url`` +as returning a URL string, so ``(await page.wait_for_url(...) or "")`` looked +like it could be truthy. Playwright's ``wait_for_url`` is annotated ``-> None`` +and signals a miss by *raising*, so the mock encoded a contract the real API does +not have, and the inverted check beneath it — which made every *successful* +chooser click raise — was invisible to every assertion. A mock cannot falsify a +belief about the mocked thing. Only a real ``Page`` can. + +So these tests drive a **real Playwright page** through the real locator engine, +a real click, a real navigation and the real ``wait_for_url``. + +**Cost: zero.** Both the chooser and the Flow landing are served by Playwright +route interception, so no request reaches Google, no Flow credit is spent, and +no authenticated profile is required — see +``docs/superpowers/memory/credit-free-route-abort-verification.md``. That also +makes the test deterministic: the real Google chooser cannot be staged on demand. + +The DOM here is a stand-in for Google's markup, so this proves the *mechanism* +(guard → locate → click → land), not that ``[data-email]`` is still the live +chooser's anchor. Selector drift on the real page is a separate question, and +``/gflow:live-verify`` on a signed-out account is what answers it. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from playwright.async_api import Route, async_playwright + +from gflow_cli.api.client import FlowApiClient +from gflow_cli.errors import FlowAccountChooserError +from gflow_cli.profile_store import ACCOUNT_FILE + +pytestmark = [pytest.mark.e2e, pytest.mark.e2e_auth] + +CHOOSER_URL = "https://accounts.google.com/v3/signin/accountchooser?continue=flow" +LABS_LANDING = "https://labs.google/fx/tools/flow?hl=en" +MIGRATED_LANDING = "https://flow.google.com/project/e2e-project" +ACCOUNT = "e2e-chooser@example.com" +OTHER_ACCOUNT = "someone-else@example.com" + + +def _chooser_html(target_href: str) -> str: + """A chooser carrying two rows, the recorded account second. + + The decoy is first in DOM order on purpose: ``.first`` must resolve within + the *matched* set, so a selector that over-matches would click the wrong + account — and on a real chooser that signs in, and bills, the wrong person. + """ + return f""" + + +""" + + +async def _serve(page: object, chooser_html: str) -> None: + """Route the chooser and both Flow cohorts to local HTML — nothing leaves the box.""" + + async def _chooser(route: Route) -> None: + await route.fulfill(status=200, content_type="text/html", body=chooser_html) + + async def _flow(route: Route) -> None: + await route.fulfill( + status=200, + content_type="text/html", + body="flow", + ) + + await page.route("https://accounts.google.com/**", _chooser) # type: ignore[attr-defined] + await page.route("https://labs.google/**", _flow) # type: ignore[attr-defined] + await page.route("https://flow.google.com/**", _flow) # type: ignore[attr-defined] + + +@pytest.mark.parametrize("landing", [LABS_LANDING, MIGRATED_LANDING], ids=["labs", "migrated"]) +async def test_e2e_chooser_autoselect_lands_on_either_cohort(tmp_path: Path, landing: str) -> None: + """A real click on the recorded row returns True and leaves the chooser. + + Parameterised over both cohorts because the landing predicate is the part + this fix changed: a ``**/project/**`` glob describes only the migrated + origin, while the labs bootstrap URL has no ``/project/`` segment at all. + Whichever host an account resolves to, leaving the chooser must count. + + ``account_email`` is deliberately NOT passed, so this drives the branch + production actually uses — the ``.gflow_account`` read — which every unit + test bypasses by passing the address in. + """ + profile = tmp_path / "profile_e2e" + profile.mkdir() + (profile / ACCOUNT_FILE).write_text(f"{ACCOUNT}\n", encoding="utf-8") + + async with async_playwright() as pw: + browser = await pw.chromium.launch(headless=True) + try: + page = await (await browser.new_context()).new_page() + await _serve(page, _chooser_html(landing)) + await page.goto(CHOOSER_URL, wait_until="domcontentloaded") + + client = FlowApiClient(profile_dir=profile) + assert await client._handle_account_chooser(page) is True + + # Landed on a Flow host, and specifically the one the row pointed at. + assert page.url.startswith(landing.split("?")[0]), ( + f"expected to land on {landing}, still at {page.url}" + ) + assert "accounts.google.com" not in page.url + finally: + await browser.close() + + +async def test_e2e_chooser_click_that_never_lands_raises_exit_38(tmp_path: Path) -> None: + """A click that does not leave the chooser raises FlowAccountChooserError. + + The row's href is a same-page anchor, so the click is real and lands + nowhere. That makes ``wait_for_url`` raise a genuine Playwright + ``TimeoutError`` — the failure signal the code must catch and translate. + Before the fix this branch was unreachable for the opposite reason: the + check was inverted, so it fired on success and the real timeout escaped + uncaught as a generic exit 1, which is the #763 symptom itself. + + Costs the handler's full 30 s wait; that is the price of proving the real + timeout rather than a mocked one. + """ + profile = tmp_path / "profile_e2e" + profile.mkdir() + (profile / ACCOUNT_FILE).write_text(f"{ACCOUNT}\n", encoding="utf-8") + + async with async_playwright() as pw: + browser = await pw.chromium.launch(headless=True) + try: + page = await (await browser.new_context()).new_page() + await _serve(page, _chooser_html("#stay-put")) + await page.goto(CHOOSER_URL, wait_until="domcontentloaded") + + client = FlowApiClient(profile_dir=profile) + with pytest.raises(FlowAccountChooserError) as exc_info: + await client._handle_account_chooser(page) + + assert ACCOUNT in str(exc_info.value) + assert "did not reach Flow" in str(exc_info.value) + finally: + await browser.close() + + +async def test_e2e_chooser_absent_row_raises_before_any_click(tmp_path: Path) -> None: + """A recorded account with no row raises without clicking anything. + + The wrong-account hazard is the reason the selector is an exact + ``[data-email=]`` match: this chooser offers a different address, and a + substring or text-engine fallback that matched it would sign in — and bill — + the wrong person. Nothing here may be clickable. + """ + profile = tmp_path / "profile_e2e" + profile.mkdir() + (profile / ACCOUNT_FILE).write_text("not-on-this-chooser@example.com\n", encoding="utf-8") + + async with async_playwright() as pw: + browser = await pw.chromium.launch(headless=True) + try: + page = await (await browser.new_context()).new_page() + await _serve(page, _chooser_html(LABS_LANDING)) + await page.goto(CHOOSER_URL, wait_until="domcontentloaded") + + client = FlowApiClient(profile_dir=profile) + with pytest.raises(FlowAccountChooserError) as exc_info: + await client._handle_account_chooser(page) + + assert "not-on-this-chooser@example.com" in str(exc_info.value) + # Still on the chooser: no row was clicked, so no wrong account was picked. + assert "accounts.google.com" in page.url + finally: + await browser.close() + + +async def test_e2e_chooser_matches_recorded_account_case_insensitively(tmp_path: Path) -> None: + """A case variant of the recorded address still selects the row. + + `gflow auth login --account` compares case-insensitively (`cli.py`: + ``actual_account.lower() != account.strip().lower()``), but the row match is + a CSS attribute selector and an exact-text fallback, both case-SENSITIVE, and + ``read_account_file`` normalises nothing. So an address recorded in one case + and rendered by Google in another passes the `--account` assertion and then + misses the row — surfacing as "recorded account was not found among + selectable accounts", exit 38, telling the operator to re-login while the + account sits right there on the chooser. That is the exact false negative + this feature exists to remove. + + Only a real locator engine can settle this: CSS attribute matching is + case-sensitive by default and case-insensitive only with the `i` flag, which + no mock can model. Zero credits — route interception, as above. + """ + profile = tmp_path / "profile_e2e" + profile.mkdir() + # Chooser renders ACCOUNT lowercase; the profile records a case variant. + recorded = "E2E-Chooser@Example.com" + assert recorded.lower() == ACCOUNT, "variant must differ only by case" + (profile / ACCOUNT_FILE).write_text(f"{recorded}\n", encoding="utf-8") + + async with async_playwright() as pw: + browser = await pw.chromium.launch(headless=True) + try: + page = await (await browser.new_context()).new_page() + await _serve(page, _chooser_html(MIGRATED_LANDING)) + await page.goto(CHOOSER_URL, wait_until="domcontentloaded") + + client = FlowApiClient(profile_dir=profile) + assert await client._handle_account_chooser(page) is True + assert "accounts.google.com" not in page.url + finally: + await browser.close() + + +async def test_e2e_chooser_case_insensitive_match_still_refuses_a_superset_row( + tmp_path: Path, +) -> None: + """Relaxing case must not relax the anti-substring discipline. + + The chooser's loose surfaces ("Remove ", "Sign out of ") are + why the match is exact. A case-insensitive match implemented with an + unanchored regex would start selecting those, and clicking "Sign out of" on + a real chooser signs the operator out instead of in. Here the ONLY row + carrying the address is a superset string, so a correct implementation finds + no exact row and raises rather than clicking it. + """ + profile = tmp_path / "profile_e2e" + profile.mkdir() + (profile / ACCOUNT_FILE).write_text(f"{ACCOUNT}\n", encoding="utf-8") + + superset_only = f""" + + +""" + + async with async_playwright() as pw: + browser = await pw.chromium.launch(headless=True) + try: + page = await (await browser.new_context()).new_page() + await _serve(page, superset_only) + await page.goto(CHOOSER_URL, wait_until="domcontentloaded") + + client = FlowApiClient(profile_dir=profile) + with pytest.raises(FlowAccountChooserError) as exc_info: + await client._handle_account_chooser(page) + assert "not found among selectable accounts" in str(exc_info.value) + finally: + await browser.close() + + +async def test_e2e_signin_page_is_not_reported_as_a_chooser(tmp_path: Path) -> None: + """An ordinary expired session must not be misreported as a missing account row. + + The host gate accepts ANY https accounts.google.com landing, so an expired + session redirected to the email form — no remembered accounts, nothing to + pick — reaches the row lookup, finds nothing, and raises + FlowAccountChooserError: exit 38, "recorded account was not found among + selectable accounts". Two things are wrong with that. It asserts a chooser + listing other accounts when there is no chooser at all, pointing the operator + at the wrong remediation; and it *changes an exit code callers branch on* — + this path previously continued to the transport's HTTP 401 and surfaced as + AuthExpiredError (exit 3), which is also deliberately excluded from incident + capture. Scripts keyed on exit 3 for re-auth would silently stop matching. + + A chooser is identified structurally, by having account rows at all — not by + its URL, which is Google's to change. Same discipline as the host gate: + parse, never pattern-match a label. + """ + profile = tmp_path / "profile_e2e" + profile.mkdir() + (profile / ACCOUNT_FILE).write_text(f"{ACCOUNT}\n", encoding="utf-8") + + signin_form = """ + +
+""" + + async with async_playwright() as pw: + browser = await pw.chromium.launch(headless=True) + try: + page = await (await browser.new_context()).new_page() + await _serve(page, signin_form) + await page.goto( + "https://accounts.google.com/v3/signin/identifier?continue=flow", + wait_until="domcontentloaded", + ) + + client = FlowApiClient(profile_dir=profile) + # False = "not a chooser", so the caller carries on and the real + # auth failure classifies itself downstream. + assert await client._handle_account_chooser(page) is False + finally: + await browser.close() diff --git a/tests/e2e/test_auth_verification_e2e.py b/tests/e2e/test_auth_verification_e2e.py index b9027c0d..c927f123 100644 --- a/tests/e2e/test_auth_verification_e2e.py +++ b/tests/e2e/test_auth_verification_e2e.py @@ -88,3 +88,27 @@ class BrowserCookieError(Exception): assert status.outcome is FlowSessionOutcome.AUTHENTICATED assert isinstance(status.user_email, str) and status.user_email + + +async def test_e2e_bootstrap_completes_with_chooser_callsite_present( + e2e_profile_dir: Path, +) -> None: + """Bootstrap still completes with the chooser-autoselect callsite wired in (#763). + + Zero credits: enters the client (bootstrap + locale settle + the new + ``_handle_account_chooser`` call) and asserts the page lands on the Flow + editor, not on an account chooser. On a signed-in profile no chooser + renders, so this pins the no-op path; a maintainer can run it against a + profile signed out of Flow to exercise the click-through branch. + """ + from gflow_cli.profile_store import ACCOUNT_FILE + + account_file = e2e_profile_dir / ACCOUNT_FILE + recorded = account_file.read_text(encoding="utf-8").strip() if account_file.exists() else "" + + async with FlowApiClient(profile_dir=e2e_profile_dir, transport="evaluate_fetch") as client: + assert await client.health_check() is True + url = getattr(client._page, "url", "") or "" + assert "accounts.google.com" not in url, ( + f"bootstrap stalled on the account chooser for recorded account {recorded!r}" + ) diff --git a/tests/test_diagnostics_recorder.py b/tests/test_diagnostics_recorder.py index fb159745..4cc7863c 100644 --- a/tests/test_diagnostics_recorder.py +++ b/tests/test_diagnostics_recorder.py @@ -16,6 +16,7 @@ AuthExpiredError, ConfigurationError, ContentPolicyError, + FlowAccountChooserError, FlowAgentUiError, FlowAppError, NetworkError, @@ -91,6 +92,15 @@ def test_trigger_classification_matches_design(self, tmp_path: Path) -> None: ContentPolicyError("expected"), AuthExpiredError("expected"), ConfigurationError("usage"), + # Same policy class as AuthExpiredError: deterministic operator + # remediation, and it fires ONLY while the page is on + # accounts.google.com — so a bundle would carry a DOM dump and a + # full-page screenshot of a Google auth surface (every signed-in + # identity's address, name and avatar; on a sign-in form, the input + # and hidden-field DOM) into the artifact users are told to attach + # to GitHub issues. docs/DEBUGGING.md: "Never captured: ... ordinary + # AuthExpiredError". This has the same remediation, so same rule. + FlowAccountChooserError(detail="expected"), ): assert not rec.should_capture(exc), type(exc).__name__ diff --git a/website/docs/AUTHENTICATION.md b/website/docs/AUTHENTICATION.md index 92f1f0c8..cefd45e8 100644 --- a/website/docs/AUTHENTICATION.md +++ b/website/docs/AUTHENTICATION.md @@ -174,6 +174,18 @@ Set ffroliva as default profile. If a profile named after the email local-part already exists, the rename is skipped and the profile keeps the name `default`. +#### `--account ` + +Asserts that the login authenticates as one exact Google account. After the +session verifies, the CLI compares the verified email against `--account` +(case-insensitive) and fails with `FlowAccountChooserError` (exit 38) on a +mismatch — including when no verified email was recorded at all, since an +identity assertion that cannot read the identity must not pass. + +A mismatch means the profile now holds the *other* account's session: re-run +`gflow auth login --profile --account ` while signed in as the +required account. + #### `--browser [auto|chrome|internal]` | Value | Browser used | When to use | diff --git a/website/docs/DEBUGGING.md b/website/docs/DEBUGGING.md index 10755e5c..c3f9e5c5 100644 --- a/website/docs/DEBUGGING.md +++ b/website/docs/DEBUGGING.md @@ -107,7 +107,7 @@ at command startup). Captured: `FlowAppError` (31), `FlowAgentUiError` (25), `FlowHostMigratedError` (36), `UiModeUnavailableError` (28), -`UiSelectorDriftError` (23), +`UiSelectorDriftError` (23), `FlowAccountChooserError` (38), `TransportTimeoutError` (9), `BrowserSessionClosedError` (15), `WireFormatError` (7), `WafRejectionError` (10), `NetworkError` (6), unexpected exceptions while a page is alive, and `ProfileLockedError` (11) diff --git a/website/docs/USAGE.md b/website/docs/USAGE.md index 19fa9c27..143fe3de 100644 --- a/website/docs/USAGE.md +++ b/website/docs/USAGE.md @@ -1787,6 +1787,7 @@ shell scripts can branch on the failure mode without parsing stderr. | `35` | `ExtendUnavailableError` | No Veo extend model is orderable for this account and aspect — the extend family is tier-gated and there is no square variant. **Never auto-retry**: a tier gate does not clear on its own. | | `36` | `FlowHostMigratedError` | Flow served the project from `flow.google.com` and the request could not be represented by the migrated composer, or `GFLOW_CLI_FLOW_HOST=labs.google` disabled it. Supported today: `video t2v`; local-file video i2v/r2v; `image t2i`; and local-file `image i2i`. Image UUID/entity/instruction/Imagen-4 forms, `image batch`, and the `3:4` image aspect remain unsupported. Not selector drift (23) | **Not retryable.** Use one of the supported forms — `--project` is required for images as well as video — or the REST surface (`gflow project list`, `gflow data …`); follow #639 for the remaining matrix | | `37` | `InsufficientCreditsError` | The account's balance is short **for the model it asked for**, so Flow **replaced** the submit control with its `Insufficient credits warning` instead of disabling it. Short, not necessarily empty: measured 2026-09-07, an account holding **50** credits requesting `--model veo-quality` (**100**) rendered the warning. Explicitly **not** selector drift (23): reporting it as drift told users to file a frontend bug over a credit shortfall | Check the balance with `gflow credits user`, then pick a cheaper `--model` (`veo-lite` costs 10), top up, or wait for the allowance to reset. Nothing was submitted, so no credit was spent. `gflow image` draws on a separate daily quota and may still work | +| `38` | `FlowAccountChooserError` | The post-migration hop landed on Google's account chooser and the profile's recorded account (`.gflow_account`) could not be selected automatically (row absent, click-through did not return to the editor, or `--account` mismatch) | **Not retryable**: run `gflow auth login --profile ` and complete the chooser manually, while signed in as the recorded account (re-run `gflow auth login` if the chooser offers a different session) | | `130`| SIGINT | User-interrupted (Ctrl-C) | — | **Exit code 16 — data store / migration error.** Fires when: From f65767f654eae57d6088943ce7bbe86cc624e184 Mon Sep 17 00:00:00 2001 From: Flavio Oliva Date: Thu, 10 Sep 2026 14:38:12 +0100 Subject: [PATCH 2/6] chore(workflow): make the Bug Lane the documented route from symptom to fix (#774) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Formalises the chain a bug travels here — spike -> systematic-debugging -> BDD -> TDD -> fix -> e2e — canonically in skills/issue-resolve/SKILL.md, cited from AGENTS.md, skills/spike, skills/scenario, docs/E2E_TESTING.md and docs/INDEX.md. Written once; nothing restates it, because a duplicated checklist drifts. Gated by surface, not severity: steps 0-2 are skippable when the cause is already proven and the blast radius is one line, but a skip is a claim and has to be said out loud. Steps 3-5 have no gate. Removes a contradiction that no gate could see. issue-resolve step 3 read "the test is the closest browser-free proxy" while AGENTS.md's Iron Law read "if no e2e test covers the change, write one" and listed "covered by unit tests" among the excuses that are NOT blockers. Disjoint files, clean merge, and an agent following either one was compliant with the other's opposite. "Browser-free" is no longer a blocker; only a named external blocker is. Wires BDD to e2e with no new machinery: pytest-bdd converts Gherkin tags into pytest markers (verified on 8.1 — plugin.py:137), so a Feature tagged @e2e @e2e_auth is filtered by the existing addopts and selected by the existing -m . Feature files stay in tests/features/; the step module lives in tests/e2e/ so it inherits that suite's profile-gating fixtures. tests/features/test_e2e_binding_guard.py enforces it offline, in hosted CI, without a browser — orphan @e2e Gherkin, a missing cost tier, and the inverse hazard of a feature bound from tests/e2e/ but left untagged, which escapes addopts and makes hosted CI try to drive Chrome. It carries its own fire-test, so green means "no orphans" rather than "never looked". Proving these tests PASS stays the nightly canary's job, on a machine with a warm profile. --- AGENTS.md | 13 ++- CHANGELOG.md | 31 ++++++ docs/E2E_TESTING.md | 49 +++++++++ docs/INDEX.md | 2 + skills/issue-resolve/SKILL.md | 120 +++++++++++++++++--- skills/scenario/SKILL.md | 22 +++- skills/spike/SKILL.md | 17 ++- tests/features/test_e2e_binding_guard.py | 134 +++++++++++++++++++++++ website/docs/E2E_TESTING.md | 49 +++++++++ 9 files changed, 418 insertions(+), 19 deletions(-) create mode 100644 tests/features/test_e2e_binding_guard.py diff --git a/AGENTS.md b/AGENTS.md index f051faae..9669dba3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,8 @@ that did not run. |---|---|---| | Claim a Flow surface is broken, missing, or impossible | `/gflow:spike ` | A selector that misses is evidence about the SELECTOR; "labs-only, ever" came from one 20 s timeout and cost a day | | Touch a GitHub issue | `/gflow:issue-assessment ` | Read-only triage precedes any fix; classification changes what "fixing" means | +| Explain a symptom, or fix a bug whose cause is not yet proven | The **Bug Lane** — [`skills/issue-resolve/SKILL.md`](skills/issue-resolve/SKILL.md) | The issue names a symptom; the cause is usually a caller above it. Skipping to the fix patches one path and leaves its siblings broken | +| Write the test for a scenario that can only happen in a browser | [`docs/E2E_TESTING.md`](docs/E2E_TESTING.md) § BDD-bound e2e | A mocked page asserts *our* code; the bug was that Flow did something else. The Gherkin tag is what makes it an e2e test | | Propose a transport, auth, selector, or schema change | `/gflow:predict ` | Five adversarial personas return GO / CAUTION / STOP before code exists | | Start a feature | `/gflow:scenario` → `/gflow:plan` | Edge cases before tasks; tasks before code | | Resume work / ask "where are we?" | `/gflow:status` | The current plan's next unchecked task is the answer, not your guess | @@ -159,7 +161,10 @@ captured, and it cannot prove the surface an agent or a user actually calls is w the code under test. Everything not exercised is unknown, and unknown ships as a bug. If no e2e test covers the change, **write one** — that is part of the change, not -follow-up work. +follow-up work. For a bug, the lane that gets you there — spike → debug → BDD → TDD +→ e2e, and which steps a given bug may skip — is +[`skills/issue-resolve/SKILL.md`](skills/issue-resolve/SKILL.md) § The Bug Lane. It is +written once, there; this section states the law, that one states the route. **The only permitted exception is a named external blocker** you cannot remove: an exhausted API quota, hardware you do not have, an account you do not control, a cohort @@ -235,8 +240,9 @@ All AI agents and harnesses working on `gflow-cli` follow this standard 10-phase |---|---|---|---| | 0. Spike | `/gflow:spike ` | Evidence from the live surface (DOM / network / HAR) whenever a claim of absence is in play | `scripts/dev/spike_*.py` + `docs/superpowers/spikes/-.md` | | 1. Triage | `/gflow:issue-assessment ` | Read-only issue analysis & root cause hypothesis | `issue_assessment_.md` | +| 1b. Root cause | `superpowers:systematic-debugging` (the **Bug Lane**, [`skills/issue-resolve`](skills/issue-resolve/SKILL.md)) | Turn the triage *hypothesis* into a proven cause at `:`, then grep every caller so the fix lands at the root | Root cause + the callers it covers | | 2. Pre-Implementation | `/gflow:predict ` | Adversarial audit (D14 YAGNI, security, risks) | GO / CAUTION / STOP verdict | -| 3. BDD Scaffolding | `/gflow:scenario ` | Edge-case explorer & BDD Gherkin spec | `Scenario:` blocks & test scaffold | +| 3. BDD Scaffolding | `/gflow:scenario ` | Edge-case explorer & BDD Gherkin spec | `Scenario:` blocks + the binding its **surface** dictates: a browser-only scenario is tagged `@e2e @e2e_` and bound from `tests/e2e/`, everything else stays offline in `tests/features/` | | 4. Implementation Plan | `/gflow:plan ` | Task-by-task atomic implementation plan | `docs/superpowers/plans/-/PLAN.md` | | 5. Council Review | `/gflow:pr-council-review` / `llm-council` | Multi-dimensional audit across 6 core dimensions | Consensus verdict report | | 6. Task Execution | `/gflow:status` | Track unchecked tasks during TDD execution | Next unchecked task | @@ -251,7 +257,8 @@ Every AI agent executing any phase of this pipeline MUST proactively state the c | Current Phase | Completed Artifact / Gate | Next Sequential Phase & Command | |---|---|---| -| Phase 1: Triage | `issue_assessment_.md` | ➔ Phase 2: Pre-Implementation (`/gflow:predict `) | +| Phase 1: Triage | `issue_assessment_.md` | ➔ Phase 1b: Root cause (`superpowers:systematic-debugging`) — skip only when the cause is already proven, and say so | +| Phase 1b: Root cause | Proven cause at `:` + its callers | ➔ Phase 3: BDD Scaffolding (`/gflow:scenario`), written at the **root**, not the symptom | | Phase 2: Pre-Implementation | Verdict `GO` or `CAUTION` | ➔ Phase 3: BDD Scaffolding (`/gflow:scenario `) | | Phase 3: BDD Scaffolding | `Scenario:` blocks & test scaffold | ➔ Phase 4: Implementation Plan (`/gflow:plan `) | | Phase 4: Implementation Plan | `PLAN.md` created & approved | ➔ Phase 6: Task Execution (`/gflow:status`) | diff --git a/CHANGELOG.md b/CHANGELOG.md index c6aed89e..0d4b9de3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Workflow: the Bug Lane is now the documented route from symptom to fix.** + `skills/issue-resolve/SKILL.md` gains a canonical `spike → systematic-debugging → + BDD → TDD → fix → e2e` chain, gated by *surface* (steps 0–2 are skippable for a + one-line fix whose cause is proven — but a skip is a claim and must be stated; + steps 3–5 never are). AGENTS.md, `skills/spike`, `skills/scenario`, + `docs/E2E_TESTING.md` and `docs/INDEX.md` cite it; none restate it. + - Removes a real contradiction: `issue-resolve` step 3 previously permitted "the + closest browser-free proxy" while AGENTS.md's Iron Law said a change with no e2e + coverage must get one and listed "covered by unit tests" among the excuses that + are *not* blockers. Two disjoint files, no merge conflict, no gate that could + see it. + - "Browser-free" is no longer accepted as a verification blocker: only a **named** + external blocker is (an account you do not control, a Mac, an exhausted quota). + +### Added + +- **BDD scenarios can now be bound as e2e tests**, with no new machinery: pytest-bdd + converts Gherkin tags into pytest markers, so a Feature tagged `@e2e @e2e_auth` + is filtered by the existing `addopts` and selected by the existing `-m `. + Feature files stay in `tests/features/`; their step module lives in `tests/e2e/` + so it inherits that suite's profile-gating fixtures. See + [docs/E2E_TESTING.md § BDD-bound e2e](docs/E2E_TESTING.md#bdd-bound-e2e). +- `tests/features/test_e2e_binding_guard.py` — offline guard (no browser, runs in + hosted CI) for three ways that binding breaks silently: an `@e2e` scenario nobody + wrote a test for, an `@e2e` Feature with no cost sub-marker (invisible to the + nightly canary), and the inverse hazard — a Feature bound from `tests/e2e/` but + left untagged, which escapes `addopts` and makes hosted CI try to drive Chrome. + It carries its own fire-test, so a green run means "no orphans", not "never looked". + ## [0.72.0] — 2026-09-09 ### Added diff --git a/docs/E2E_TESTING.md b/docs/E2E_TESTING.md index dc21b9b4..4a8bcea5 100644 --- a/docs/E2E_TESTING.md +++ b/docs/E2E_TESTING.md @@ -77,6 +77,55 @@ e2e ─┬─ e2e_auth (auth/session, health check — zero credits) --- +## BDD-bound e2e + +A live test can be written as Gherkin. This is the required form for a bug whose +scenario can only happen in a browser — see +[`skills/issue-resolve/SKILL.md`](../skills/issue-resolve/SKILL.md) § The Bug Lane, +step 5. It needs **no new machinery**: pytest-bdd (already a dependency) converts every +Gherkin tag into a pytest marker, so a tagged scenario is filtered by the same +`addopts` and selected by the same `-m ` as a hand-written e2e test. + +**The three moving parts:** + +```gherkin +# tests/features/account_chooser_landing.feature +@e2e @e2e_auth # ← tags become pytest markers +Feature: A known landing state is named, not reported as selector drift + Scenario: the OAuth callback error page + Given a profile whose Flow session is authenticated + When the UI transport lands on /fx/api/auth/signin?error=Callback + Then it names the sign-in state, not a missing 'New project' CTA +``` + +```python +# tests/e2e/test_account_chooser_landing_bdd.py +from pytest_bdd import given, scenarios, then, when + +scenarios("../features/account_chooser_landing.feature") +# step defs here; tests/e2e/conftest.py fixtures (e2e_profile_dir, …) apply +``` + +| Rule | Why | +|---|---| +| Feature file stays in `tests/features/` | one home for Gherkin; the guard scans one directory | +| Step module lives in `tests/e2e/` | inherits `tests/e2e/conftest.py` — profile gating, `e2e_env`, `skip_on_migrated_host` | +| `@e2e` **plus** a cost tier | a bare `@e2e` cannot be selected by `-m e2e_auth`, so the nightly canary never runs it | +| One feature file, one binding module | bound from two modules, every scenario runs twice | + +**Enforced offline** by `tests/features/test_e2e_binding_guard.py` (no browser, normal +CI): an `@e2e` feature with no binder under `tests/e2e/` fails, so does one with no cost +tier, and so does the dangerous inverse — a feature bound from `tests/e2e/` but left +untagged, which carries no `e2e` marker, escapes `addopts`, and makes hosted CI try to +drive Chrome. + +> **What this does and does not prove.** The guard proves the test **exists and is +> wired**, and runs anywhere. Proving it **passes** needs a warm profile and a real +> browser — that is the nightly canary's job (`scripts/canary/`), on a machine that has +> one. Hosted CI cannot run the live tiers and never could. + +--- + ## Environment variables | Variable | Default | Purpose | diff --git a/docs/INDEX.md b/docs/INDEX.md index 106e3aca..a380d081 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -137,6 +137,8 @@ Slash commands for Claude Code, stored in `.claude/commands/gflow/`. All prefixe **"How do we use Copilot review on PRs?"** → [GITHUB § GitHub Copilot Code Review](GITHUB.md#github-copilot-code-review) **"Why did SonarCloud skip or fail on a forked PR?"** → [GITHUB § Forked PRs And SonarCloud](GITHUB.md#forked-prs-and-sonarcloud) **"How do I run e2e tests before a release?"** → [DEVELOPMENT § E2e gate](DEVELOPMENT.md#e2e-gate-before-merging-develop--main) +**"A bug came in — what is the route from symptom to shipped fix?"** → [skills/issue-resolve § The Bug Lane](../skills/issue-resolve/SKILL.md) (spike → debug → BDD → TDD → e2e, and which steps a given bug may skip) +**"The scenario only reproduces in a browser — where does its test go?"** → [E2E_TESTING § BDD-bound e2e](E2E_TESTING.md#bdd-bound-e2e) **"What does each e2e marker cost? How do I run only the cheap tests?"** → [E2E_TESTING § Run commands](E2E_TESTING.md#run-commands) **"Has Flow drifted since the last release? What is the nightly canary telling me?"** → [E2E_TESTING § Nightly canary](E2E_TESTING.md#nightly-canary-502) **"Which Flow selector moved? What is the CI selector probe telling me (exit 0/1/2)?"** → [E2E_TESTING § Selector drift probe](E2E_TESTING.md#selector-drift-probe-563) diff --git a/skills/issue-resolve/SKILL.md b/skills/issue-resolve/SKILL.md index 92761bd6..b3c6afb5 100644 --- a/skills/issue-resolve/SKILL.md +++ b/skills/issue-resolve/SKILL.md @@ -22,12 +22,73 @@ browser, macOS-only, credits), say so in the PR and stop. (Memory: --- +## The Bug Lane — canonical here, cited everywhere + +**This is the chain a bug travels in gflow-cli.** It is written out once, in this +file. `AGENTS.md`, `skills/spike`, `skills/scenario` and `docs/E2E_TESTING.md` all +point here — none of them restate it, because a duplicated checklist drifts. + +``` +0 SPIKE measure the live surface ── only when a claim about Flow is in play +1 DEBUG systematic-debugging → ROOT CAUSE ── the symptom is never the finding +2 SCENARIO BDD Gherkin written at the ROOT ── the reproduction, in Given/When/Then +3 TDD that Gherkin RED before any fix ── red for the right reason +4 FIX minimal change, at the root ── grep every caller before editing one +5 FORMALIZE UI/Flow surface ⇒ it is an E2E test ── a browser-free proxy does not discharge it +``` + +### The surface gate — steps 0–2 are conditional, 3–5 never are + +Run **0 SPIKE** when the bug's explanation involves what Flow does — a selector, +a wire response, a host behaviour, any claim of absence. Skip it when the cause is +already proven or lives entirely in our own code. + +Run **1 DEBUG** and **2 SCENARIO** whenever the bug touches a Flow surface, a +transport, auth, selectors, or the cause is not yet *proven*. Skip both for a +fix whose cause is self-evident and whose blast radius is one line — a typo, an +exit-code string, a doc correction. **A skip is a claim; say it out loud** ("cause +proven at `:`, skipping the debug step") so the skip is reviewable. + +Steps **3–5 have no gate.** There is no bug small enough to fix without a test +that failed first, and no Flow-surface change that a unit test discharges. + +### Step 5 is the one that gets rationalised away + +``` +IF THE SCENARIO CAN ONLY HAPPEN IN A BROWSER, +THE TEST THAT PROVES IT IS AN E2E TEST. +``` + +Not a unit test with a mocked page. Not "the CLI path is covered and it shares the +service." Those assert that *our* code does what we think; the bug was that Flow +did something else. Write `tests/e2e/test__bdd.py`, binding the Gherkin from +step 2 — see [`docs/E2E_TESTING.md`](../../docs/E2E_TESTING.md) § BDD-bound e2e for +the tag/marker mechanics. + +The only exit is a **named external blocker** (AGENTS.md Iron Law): an account you +do not control, hardware you do not have, an exhausted quota. Write it down, use +`Refs #N` not `Closes #N`, and leave the issue open. "I could not reach it here" is +a blocker only after you have said *what* stopped you. + +> **Written from the contradiction it removes.** Until 2026-09-10 step 3 of this +> file read "the test is the closest browser-free proxy" while AGENTS.md's Iron Law +> read "if no e2e test covers the change, write one — that is part of the change," +> and listed "it's covered by unit tests" among the excuses that are *not* blockers. +> Two files, disjoint, no merge conflict, no gate that could see it — memory +> `prose-conflicts-hide-in-disjoint-files`. An agent following this skill could +> ship a mocked proxy and be, by the letter, compliant. + +--- + ## Preconditions (all required before any code change) 1. An `issue-assessment` verdict of `CONFIRMED-BUG` or `LIKELY-BUG`. 2. Scope is single-surface / localized (not a cross-cutting redesign). -3. The fix is **verifiable in this environment** (browser-free), OR the - verification gap is explicitly carried into the PR as "needs human e2e." +3. The fix is **verifiable in this environment**, OR the gap is a **named external + blocker** carried into the PR. "Browser-free" is not itself a blocker — this host + has a warm profile and runs the `e2e_auth` tier at zero credits nightly. Name what + actually stops the run (an account you do not control, a Mac, an exhausted quota) + or run it. If any fails → do not resolve; return to `issue-assessment` (reply-only). @@ -65,16 +126,38 @@ Urgency does not make an unverified fix verified. Worktree off `origin/develop` on a `bugfix/` branch (use the `superpowers:using-git-worktrees` skill). Never work on `develop`/`main`. -### 2. Gate high-stakes changes -If the fix touches auth, a transport, selectors, or a schema → run -`/gflow:predict` first; for edge-case coverage run `/gflow:scenario`. Otherwise -proceed. +### 2. Find the root cause — lane steps 0–2 +Apply **the surface gate** above, then: + +- **0 SPIKE** — `/gflow:spike` when the explanation involves what Flow does. A + selector that missed is evidence about the selector, never about the feature. +- **1 DEBUG** — `superpowers:systematic-debugging`. Backtrack from the symptom to + the line that causes it. The issue reports a symptom; the fix goes at the root, + so **grep every caller** of the function you are about to touch. One guard in the + shared path is a smaller diff than a guard per caller, and patching only the path + the ticket names leaves every sibling still broken. State the root cause as + `:` plus the evidence that pins it. +- **2 SCENARIO** — `/gflow:scenario`. Write the reproduction as Gherkin **at the + root cause**, not at the symptom. Two bugs with one root cause are one scenario. + +If the fix touches auth, a transport, selectors, or a schema, `/gflow:predict` +runs here too. + +### 3. Fix test-first — lane steps 3–5 +Use `superpowers:test-driven-development`. The step-2 Gherkin goes **red first**, +and red for the right reason — read the failure, don't just see a red dot. Then +the minimal fix at the root, then green. + +**Where the test lives is decided by the surface, not by convenience:** + +| The scenario can only happen… | The test is | Marked | +|---|---|---| +| in a real browser / against real Flow | `tests/e2e/test__bdd.py` binding the feature | `@e2e` + a cost tier | +| in our own code (parsing, routing, exit codes) | `tests/features/test__steps.py` | untagged (offline) | -### 3. Fix test-first (TDD) -Use `superpowers:test-driven-development`. Write/confirm a **failing** test that -reproduces the bug on the affected surface, then the minimal fix, then green. -If the bug's surface can't be reached here, the test is the closest browser-free -proxy and the PR flags the residual gap. +`tests/features/test_e2e_binding_guard.py` enforces the binding both ways and +runs offline in normal CI. A browser-free proxy **does not** discharge a +UI-surface scenario; only a named external blocker does (see step 5 above). ### 4. Orchestrate (scales with complexity) For non-trivial fixes: Opus plans → delegates coding to a Sonnet subagent → @@ -102,8 +185,12 @@ findings (or record why you declined each), then **STOP** — a human promotes a | Step | Tool | |---|---| | Worktree | `superpowers:using-git-worktrees` | -| High-stakes gate | `/gflow:predict`, `/gflow:scenario` | -| TDD | `superpowers:test-driven-development` | +| 0 Spike (live-surface claims) | `/gflow:spike` | +| 1 Debug → root cause | `superpowers:systematic-debugging` | +| 2 Scenario (BDD at the root) | `/gflow:scenario` | +| High-stakes gate | `/gflow:predict` | +| 3 TDD | `superpowers:test-driven-development` | +| 5 Formalize (UI ⇒ e2e) | `docs/E2E_TESTING.md` § BDD-bound e2e | | Pre-commit | `/gflow:check` | | PR review | `/gflow:pr-council-review`, `/gflow:branch-review` | | Verify discipline | `superpowers:verification-before-completion` | @@ -114,6 +201,13 @@ findings (or record why you declined each), then **STOP** — a human promotes a ## Common mistakes +- **Fixing the symptom the issue names.** The reporter saw a symptom; lane step 1 + exists because the cause is usually a caller or two above it. Two issues that + reproduce differently can share one root — fix it once, where they meet. +- **Letting a mocked test stand in for a browser scenario.** It is the most + comfortable wrong answer in this repo, which is why step 5 is written as a rule + and enforced by `tests/features/test_e2e_binding_guard.py` rather than left to + judgement. - Working on `develop` instead of a `bugfix/` branch off it (memory: `develop-divergence-recovery`). - Treating step 6's council as optional, or asking permission to run it. It is neither (memory: `council-review-is-standing-authorized`). Ask before merging, marking a PR diff --git a/skills/scenario/SKILL.md b/skills/scenario/SKILL.md index 2e3e4b95..5ea72816 100644 --- a/skills/scenario/SKILL.md +++ b/skills/scenario/SKILL.md @@ -187,8 +187,9 @@ Test category: **Unit** (no I/O) · **Integration** (mocked HTTP/Playwright) · ## Deferred (Medium + Low — log as issues, not blockers) 1. … -## Suggested BDD scenarios (for `tests/features/`) +## Suggested BDD scenarios ```gherkin +@e2e @e2e_auth Feature: Scenario: Given … @@ -196,6 +197,20 @@ Feature: Then … ``` +**Tag by surface — the tag is the binding, not decoration.** pytest-bdd turns each +Gherkin tag into a pytest marker, and `addopts`' `-m 'not e2e …'` filters on exactly +that. So the tag decides where the scenario runs and who must run it: + +| The scenario can only happen… | Feature-level tags | Bound from | +|---|---|---| +| in a real browser / against real Flow | `@e2e` + one cost tier (`@e2e_auth`, `@e2e_image`, `@e2e_video`, …) | `tests/e2e/test__bdd.py` | +| in our own code (parsing, routing, exit codes, redaction) | none | `tests/features/test__steps.py` | + +One feature file is bound by exactly **one** module — bound twice, its scenarios run +twice. `tests/features/test_e2e_binding_guard.py` enforces both directions offline, so +an `@e2e` scenario nobody wrote a test for fails normal CI. Mechanics: +[`docs/E2E_TESTING.md`](../../docs/E2E_TESTING.md) § BDD-bound e2e. + ## Known-issues cross-reference ``` @@ -207,7 +222,10 @@ Feature: 1. Run `/gflow:predict` first to validate the approach (GO/CAUTION/STOP). 2. Run `/gflow:scenario` to enumerate edge cases and build the test matrix. 3. Use the "Must-cover before merge" list as the acceptance criteria for the PLAN.md task. -4. Add BDD scenarios to `tests/features/` **before** coding (TDD is non-negotiable per AGENTS.md). +4. Add BDD scenarios **before** coding (TDD is non-negotiable per AGENTS.md), each + tagged by its surface per the table above — a browser-only scenario is bound from + `tests/e2e/`, and a mocked stand-in does not discharge it + ([`skills/issue-resolve`](../issue-resolve/SKILL.md) § The Bug Lane, step 5). 5. **Next step:** Proactively announce: **"BDD Scenarios generated. Next step: Phase 4 Implementation Plan (`/gflow:plan `)."** --- diff --git a/skills/spike/SKILL.md b/skills/spike/SKILL.md index af211a5b..59965eec 100644 --- a/skills/spike/SKILL.md +++ b/skills/spike/SKILL.md @@ -138,4 +138,19 @@ gap named is a lead; an unmeasured gap implied is the next day lost. Feeds: [`issue-assessment`](../issue-assessment/SKILL.md) (triage needs evidence, not a hypothesis), [`predict`](../predict/SKILL.md) (persona claims about a live surface must cite a capture), [`live-verify`](../live-verify/SKILL.md) (proves the fix; this proves -the diagnosis). +the diagnosis), and — for a bug — [`scenario`](../scenario/SKILL.md), where what you +observed becomes the `Given`/`When`/`Then` of a test. + +## A spike is step 0, never the deliverable + +A spike answers a question. It does not close an issue, and its script is not the +regression test — nothing re-runs it, so nothing notices when Flow changes again. + +**The observation you just made is the body of a scenario.** What you drove is the +`Given`, what you triggered is the `When`, what the DOM or the wire actually returned +is the `Then`. Carry it into [`scenario`](../scenario/SKILL.md) and, if it can only +happen in a browser, into `tests/e2e/test__bdd.py` — the route is +[`issue-resolve`](../issue-resolve/SKILL.md) § The Bug Lane. + +A spike whose finding never became a test has bought you one answer, once, at full +price. diff --git a/tests/features/test_e2e_binding_guard.py b/tests/features/test_e2e_binding_guard.py new file mode 100644 index 00000000..00fa78f3 --- /dev/null +++ b/tests/features/test_e2e_binding_guard.py @@ -0,0 +1,134 @@ +"""Guard: `@e2e`-tagged Gherkin and the e2e suite must agree about each other. + +The Bug Lane (`skills/issue-resolve/SKILL.md`) says a scenario on a Flow/UI +surface is formalised as an **e2e** test, and the binding is carried by a +Gherkin tag: pytest-bdd converts `@e2e @e2e_auth` into `pytest.mark.e2e` / +`pytest.mark.e2e_auth`, which is what `addopts`' `-m 'not e2e ...'` filters on. + +Three ways that binding silently breaks, all caught here without a browser: + +1. **Orphan scenario** — Gherkin written, e2e test never was. The lane's whole + failure mode ("recorded, not omitted"), invisible to every other gate. +2. **No cost sub-marker** — `-m e2e_auth` cannot select it, so it only ever runs + in a full `-m e2e` sweep and the nightly canary never sees it. +3. **Untagged live binding** — a feature bound from ``tests/e2e/`` but *not* + tagged carries no `e2e` marker, so `addopts` does not exclude it and hosted + CI runs it: Chrome launch, no profile, red for a reason nobody can read. + +Static text checks only. Proving these tests **pass** is a different job, done +on a machine with a warm profile (`scripts/canary/`), never in hosted CI. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_FEATURES_DIR = _REPO_ROOT / "tests" / "features" +_E2E_DIR = _REPO_ROOT / "tests" / "e2e" + +# Mirrors the cost sub-markers registered in pyproject.toml. A bare `@e2e` is +# not selectable by tier, and the canary runs tiers. +_COST_MARKERS = frozenset( + { + "e2e_auth", + "e2e_image", + "e2e_video", + "e2e_batch", + "e2e_data", + "e2e_scene", + "e2e_character", + } +) + +_TAG_LINE = re.compile(r"^\s*@[\w@\s]+$") + + +def _tags(feature: Path) -> set[str]: + """Every Gherkin tag in a feature file, Feature- and Scenario-level alike.""" + found: set[str] = set() + for line in feature.read_text(encoding="utf-8").splitlines(): + if _TAG_LINE.match(line): + found.update(token.lstrip("@") for token in line.split() if token.startswith("@")) + return found + + +def _binders(feature_name: str, search_dir: Path) -> list[Path]: + """Modules under `search_dir` whose `scenarios(...)` call names this feature.""" + if not search_dir.is_dir(): + return [] + call = re.compile(rf"scenarios?\(\s*[\"'][^\"']*{re.escape(feature_name)}[\"']") + return sorted( + path for path in search_dir.rglob("*.py") if call.search(path.read_text(encoding="utf-8")) + ) + + +def _feature_files() -> list[Path]: + return sorted(_FEATURES_DIR.glob("*.feature")) + + +def test_every_e2e_tagged_feature_is_bound_under_tests_e2e() -> None: + orphans = [ + feature.name + for feature in _feature_files() + if "e2e" in _tags(feature) and not _binders(feature.name, _E2E_DIR) + ] + assert not orphans, ( + f"@e2e-tagged Gherkin with no binding module under tests/e2e/: {orphans}. " + "The scenario was written and the e2e test never was — write " + f"tests/e2e/test__bdd.py calling scenarios('../features/')." + ) + + +def test_every_e2e_tagged_feature_declares_a_cost_tier() -> None: + untiered = [ + feature.name + for feature in _feature_files() + if "e2e" in (tags := _tags(feature)) and not (tags & _COST_MARKERS) + ] + assert not untiered, ( + f"@e2e Gherkin with no cost sub-marker: {untiered}. " + f"Add one of {sorted(_COST_MARKERS)} so `-m ` and the canary can select it." + ) + + +def test_every_feature_bound_from_tests_e2e_is_tagged_e2e() -> None: + """The dangerous direction: an untagged live binding runs in hosted CI.""" + untagged = [ + feature.name + for feature in _feature_files() + if _binders(feature.name, _E2E_DIR) and "e2e" not in _tags(feature) + ] + assert not untagged, ( + f"Bound from tests/e2e/ but not tagged @e2e: {untagged}. " + "Without the tag these scenarios carry no e2e marker, so addopts does not " + "exclude them and hosted CI will try to drive a browser." + ) + + +def test_the_guard_actually_fires(tmp_path: Path) -> None: + """A guard that has only ever passed vacuously has not been tested. + + Feeds the detectors a synthetic orphan and a synthetic bare `@e2e`, and + proves each one is seen — so a green suite above means "no orphans", not + "the check never looked". + """ + orphan = tmp_path / "orphan.feature" + orphan.write_text("@e2e\nFeature: nobody binds me\n", encoding="utf-8") + assert _tags(orphan) == {"e2e"} + assert not _binders(orphan.name, _E2E_DIR) + assert not _tags(orphan) & _COST_MARKERS + + tiered = tmp_path / "tiered.feature" + tiered.write_text(" @e2e @e2e_auth\nFeature: tagged at scenario level\n", encoding="utf-8") + assert _tags(tiered) & _COST_MARKERS == {"e2e_auth"} + + binder_dir = tmp_path / "e2e" + binder_dir.mkdir() + (binder_dir / "test_x_bdd.py").write_text( + 'from pytest_bdd import scenarios\n\nscenarios("../features/orphan.feature")\n', + encoding="utf-8", + ) + assert _binders("orphan.feature", binder_dir) + assert not _binders("unrelated.feature", binder_dir) diff --git a/website/docs/E2E_TESTING.md b/website/docs/E2E_TESTING.md index dc21b9b4..4a8bcea5 100644 --- a/website/docs/E2E_TESTING.md +++ b/website/docs/E2E_TESTING.md @@ -77,6 +77,55 @@ e2e ─┬─ e2e_auth (auth/session, health check — zero credits) --- +## BDD-bound e2e + +A live test can be written as Gherkin. This is the required form for a bug whose +scenario can only happen in a browser — see +[`skills/issue-resolve/SKILL.md`](../skills/issue-resolve/SKILL.md) § The Bug Lane, +step 5. It needs **no new machinery**: pytest-bdd (already a dependency) converts every +Gherkin tag into a pytest marker, so a tagged scenario is filtered by the same +`addopts` and selected by the same `-m ` as a hand-written e2e test. + +**The three moving parts:** + +```gherkin +# tests/features/account_chooser_landing.feature +@e2e @e2e_auth # ← tags become pytest markers +Feature: A known landing state is named, not reported as selector drift + Scenario: the OAuth callback error page + Given a profile whose Flow session is authenticated + When the UI transport lands on /fx/api/auth/signin?error=Callback + Then it names the sign-in state, not a missing 'New project' CTA +``` + +```python +# tests/e2e/test_account_chooser_landing_bdd.py +from pytest_bdd import given, scenarios, then, when + +scenarios("../features/account_chooser_landing.feature") +# step defs here; tests/e2e/conftest.py fixtures (e2e_profile_dir, …) apply +``` + +| Rule | Why | +|---|---| +| Feature file stays in `tests/features/` | one home for Gherkin; the guard scans one directory | +| Step module lives in `tests/e2e/` | inherits `tests/e2e/conftest.py` — profile gating, `e2e_env`, `skip_on_migrated_host` | +| `@e2e` **plus** a cost tier | a bare `@e2e` cannot be selected by `-m e2e_auth`, so the nightly canary never runs it | +| One feature file, one binding module | bound from two modules, every scenario runs twice | + +**Enforced offline** by `tests/features/test_e2e_binding_guard.py` (no browser, normal +CI): an `@e2e` feature with no binder under `tests/e2e/` fails, so does one with no cost +tier, and so does the dangerous inverse — a feature bound from `tests/e2e/` but left +untagged, which carries no `e2e` marker, escapes `addopts`, and makes hosted CI try to +drive Chrome. + +> **What this does and does not prove.** The guard proves the test **exists and is +> wired**, and runs anywhere. Proving it **passes** needs a warm profile and a real +> browser — that is the nightly canary's job (`scripts/canary/`), on a machine that has +> one. Hosted CI cannot run the live tiers and never could. + +--- + ## Environment variables | Variable | Default | Purpose | From bbc7f000e95779ae6941330dca4a1a08e0cde63d Mon Sep 17 00:00:00 2001 From: Flavio Oliva Date: Thu, 10 Sep 2026 14:49:07 +0100 Subject: [PATCH 3/6] fix(transports): name a known Flow landing instead of blaming the selector (#775) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(transports): name a known Flow landing instead of blaming the selector Closes #756. Also fixes the 2026-09-10 RED nightly canary (#559), which is the same defect one URL apart. flow_host_kind() classifies the ORIGIN. /about, /project/ and /fx/api/auth/signin?error=Callback all share one, so when a readiness wait timed out it had nothing left to blame but its own anchor — sending the operator to "check for a newer release, then file a bug" over a session state no release changes. Reported at three sites and special-cased three times before (#721 credits, #749 agent mode, FlowAppError's own crash page), so it is fixed once, in the shared place, rather than a fourth time. - flow_landing_kind() joins flow_host_kind() in api/transports/_common.py and names a known non-app landing ("signin" / "public" / None). raise_for_known_landing() converts the diagnosis at the raise site. - Consulted ONLY inside an already-failed branch. Not ahead of the probe, which would delete the DOM evidence that corrects a wrong absence claim (#739 shipped one that way), and not as a bounded wait after goto, which reads page.url before Flow's client-side redirect lands (#639). - flow.google.com/about instead of the project -> FlowAppError (exit 31), naming the landing and the project it did not open, and deliberately not why: #756 measured the redirect and not its cause, and auth status reports the session verified while it happens. - labs .../auth/signin?error=Callback instead of the gallery -> AuthExpiredError (exit 3). This is the exact page behind the RED canary. - auth/internal_chromium.py drops its private _NEXTAUTH_ROUTE_PREFIX and reuses the shared classifier. The knowledge existed there since #767; no transport could reach it. Retryability was measured, not assumed. Routing /about to exit 31 would have flipped it from non-retryable to retryable purely as a side effect. The spike (scripts/dev/spike_about_redirect_stability.py, $0, ci-probe) came back 0/5 — the redirect stopped reproducing between 2026-09-08 and today — which is equally consistent with "transient" and with "a session state changed", and so settles nothing. The reading was pre-registered in the script before the run. Therefore GFlowError gains a per-instance `retryable` override (same class-default / instance-override shape as remediation_hint) and the /about raise site passes retryable=False to PRESERVE its previous exit-23 answer, not to claim a retry fails. is_retryable() pins the override with isinstance(..., bool): a MagicMock answers every attribute with a truthy child, so a truthiness test would report every mocked error as retryable with nothing noticing. Tests: tests/features/landing_state_diagnosis.feature bound from tests/e2e/test_landing_state_diagnosis_bdd.py — the first BDD-bound e2e. Three scenarios against a real browser over route interception, zero credits: both landings, plus an A/B control proving genuine drift still reports as drift. RED first (the canary's exact RuntimeError, reproduced deterministically), then green. Plus classifier unit tests and five retryability tests. tests/test_marker_registry.py now resolves a BDD module's cost tier through its Gherkin tags. It read Python source only, so it saw no pytest.mark and failed a correctly-tiered file; copying the tier into a pytestmark would have satisfied it and then drifted from the Feature. * fix(review): apply the branch-council findings 12-dimension council on 63fb5bbd: 3 GREEN (correctness, auth, BDD), 9 YELLOW, 0 RED, ~19 must-fix. Two dimensions found defects in the workflow hardening itself rather than in the fix. Security (D3): the landing URL is stripped to scheme+host+path before it reaches either the message or the log. The NextAuth family the "signin" arm matches includes /fx/api/auth/callback/google?state=...&code=..., and because AuthExpiredError is capture-exempt that message is the ONLY artifact — so it is exactly what a user pastes into an issue. redact_error_detail was already imported in this module and used 300 lines below; the new raise skipped it. Truthfulness (D2, D15, D9): the message said "its sign-in page" for a family that also contains the callback and /session. docs/MCP.md still told agents FlowAppError is unconditionally retryable and that RETRYABLE_ERRORS "is the whole" list; docs/USAGE.md's exit-3 row still described only the 401/403 trigger; and a docstring claimed the helper "never adds a failure", which is false at migrated_composer.ensure_editor, whose except branch has a recovery path. Reach (D6): a THIRD site. _enter_editor(project_id=...) has no readiness gate, so a landing page reaches _locate_prompt_box and raises a bare RuntimeError, which observability.py hashes because it is not a GFlowError — the operator saw "Unexpected error" with even the URL destroyed. Worse than the report #756 is about, and the guard was not consulted there. Scope (D14, D1): retryable moves off GFlowError onto FlowAppError, the one class that needs it — is_retryable reads it by getattr, so a base field bought no typing and no test-double visibility, only a field on every error in the project. The FlowApiError pass-through it required is deleted. flow_landing_kind's "public" arm is gated to the migrated host, the only one where /about was measured. Instrument (D13): the spike re-implemented the predicate it was measuring (endswith("/about") vs the production classifier), which would score /about?hl=en as a non-reproduction — the #743 shape its own docstring cites. It now calls flow_landing_kind. except BaseException became except Exception: the former swallowed Ctrl-C, recorded it as a non-/about sample and advanced the loop, so an interrupted run could print "DOES NOT REPRODUCE" assembled from interrupts. Guards (D4, D12): the binding guard could pass on an empty scan — every check is assert-not-, so a mis-resolved _FEATURES_DIR made all of them vacuous while the fire-test still passed. It now asserts it scanned something. The "one feature, one binding module" rule stated in docs was the one rule nothing enforced; it is enforced now. Feature-name matching is anchored on the path separator. The "signin" branch had zero offline coverage (only the e2e reached it, and addopts excludes that), so the 4194-pass run never executed it. Memory (D5): docs/superpowers/memory/ui-selector-drift-error-exit-23.md keeps a carve-out ledger for exceptions to exit 23 — exits 36, 37 and 38 each recorded when introduced. Carve-out 4 is now recorded, with the transferable lesson the branch is actually about: flow_host_kind classifies the ORIGIN, not the page. Hardening defects the council found in the first commit (D9): the lane said "steps 3-5 have no gate" beside "step 3 = that Gherkin RED", which is unsatisfiable when step 2 is skipped — the gate is red-before-green, never Gherkin-before-green. And the continuation table routed 1 -> 1b -> 3, orphaning Phase 2 (predict) while the routing table still mandates it for exactly this kind of change. Declined, with reasons: excluding /about from incident capture (FlowAppError also covers the React crash page, where the DOM helps, and a class cannot exclude one shape), and moving the guard after _capture_debug_screenshot (D3 showed that placement removes an existing screenshot of a signed-in page; D9's answer, taken, is to document the capture change in DEBUGGING.md rather than revert it). Verified: 4197 passed, 92% coverage; e2e 3/3 in a real browser at $0; ruff, format, doc-links, website mirror, council-memory and hygiene gates all green. * fix(transports): recognise Google's auth host as a known landing Found by live-verifying the previous commit instead of trusting it. Same profile, same test, real Google, $0: BEFORE RuntimeError: Could not find 'New project' CTA on Flow gallery. URL: https://accounts.google.com/v3/signin/accountchooser ?client_id=...&code_challenge=...&state=PKOA6qjxDh... AFTER FlowAccountChooserError: Google's account chooser is displayed (https://accounts.google.com/v3/signin/accountchooser) instead of the Flow gallery - the session needs a person to pick an account. Not selector drift. `flow_landing_kind` returned None for accounts.google.com by design, and a unit test asserted it, on the reasoning "the chooser has its own handler". That is true at BOOTSTRAP - `client._handle_account_chooser` - and false for a hop that happens after it, which is what profile `denon82` did on 2026-09-10: it left the `?error=Callback` state the 02:00 canary reported and moved to Google's chooser, where `_enter_editor` swept eighteen selectors for a "+ New project" CTA on Google's sign-in page and blamed the anchor. So the previous commit's claim to fix the RED canary was false for the state the profile is actually in. The classifier now answers for Google's auth host too: "chooser" for a `/v3/signin/accountchooser` path -> FlowAccountChooserError (38, the class #763 and #764 already established for this), "signin" for its other sign-in surfaces -> AuthExpiredError (3). The bot-rejection hop (`/v3/signin/rejected`) keeps returning None; it has its own error and must never read as a missing account. Path-tested rather than importing GOOGLE_REJECTED_BROWSER_ROUTE, because `_common` -> `auth` is a real import cycle - which is why `internal_chromium` imports this module deferred. `_is_safe_to_probe_session` is unaffected, verified: its `flow_host_kind(url) is None` short-circuit returns False for accounts.google.com before this function is consulted, so the cookie-rotation gate the council proved equivalent over 393 cases still behaves identically. D3's redaction finding is no longer hypothetical - that live message carried the OAuth `state`, `code_challenge` and `client_id`, and this is the artifact users paste into issues. The stripping added in 7e29be49 removes them. Not in scope, and recorded rather than smuggled in: the mid-run chooser now DIAGNOSES but does not RECOVER. #764 taught the bootstrap path to autoselect the recorded account; that lives on FlowApiClient and is not reachable from a transport helper. Teaching this hop to autoselect would rescue the run instead of naming the failure. Verified: 4202 passed, 92% coverage; the live A/B above; ruff, format, doc-links, website mirror and hygiene gates green. * docs(changelog): fold the duplicate Added heading the rebase created Rebasing onto develop after #774 squash-merged stacked this branch's Added section above the one that arrived with the hardening, leaving two under [Unreleased]. Keep a Changelog wants one per type; content is unchanged. --- AGENTS.md | 6 +- CHANGELOG.md | 99 +++++++-- docs/DEBUGGING.md | 10 +- docs/E2E_TESTING.md | 32 ++- docs/INDEX.md | 2 +- docs/MCP.md | 7 +- docs/USAGE.md | 4 +- .../memory/ui-selector-drift-error-exit-23.md | 23 ++ .../2026-09-10-about-redirect-stability.md | 68 ++++++ scripts/dev/spike_about_redirect_stability.py | 144 ++++++++++++ skills/issue-resolve/SKILL.md | 39 +++- skills/scenario/SKILL.md | 5 +- skills/spike/SKILL.md | 24 ++ src/gflow_cli/api/transports/_common.py | 162 ++++++++++++++ .../api/transports/migrated_composer.py | 9 +- src/gflow_cli/api/transports/ui_automation.py | 19 ++ src/gflow_cli/auth/internal_chromium.py | 15 +- src/gflow_cli/errors.py | 56 ++++- tests/api/transports/test_common.py | 47 ++++ tests/e2e/test_landing_state_diagnosis_bdd.py | 208 ++++++++++++++++++ .../features/landing_state_diagnosis.feature | 37 ++++ tests/features/test_e2e_binding_guard.py | 31 ++- tests/test_errors_classification.py | 121 ++++++++++ tests/test_marker_registry.py | 53 ++++- website/docs/DEBUGGING.md | 10 +- website/docs/E2E_TESTING.md | 32 ++- website/docs/MCP.md | 7 +- website/docs/USAGE.md | 4 +- 28 files changed, 1194 insertions(+), 80 deletions(-) create mode 100644 docs/superpowers/spikes/2026-09-10-about-redirect-stability.md create mode 100644 scripts/dev/spike_about_redirect_stability.py create mode 100644 tests/e2e/test_landing_state_diagnosis_bdd.py create mode 100644 tests/features/landing_state_diagnosis.feature diff --git a/AGENTS.md b/AGENTS.md index 9669dba3..e050dc41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,8 +161,8 @@ captured, and it cannot prove the surface an agent or a user actually calls is w the code under test. Everything not exercised is unknown, and unknown ships as a bug. If no e2e test covers the change, **write one** — that is part of the change, not -follow-up work. For a bug, the lane that gets you there — spike → debug → BDD → TDD -→ e2e, and which steps a given bug may skip — is +follow-up work. For a bug, the lane that gets you there — spike → debug → BDD → TDD → +fix → e2e, and which steps a given bug may skip — is [`skills/issue-resolve/SKILL.md`](skills/issue-resolve/SKILL.md) § The Bug Lane. It is written once, there; this section states the law, that one states the route. @@ -258,7 +258,7 @@ Every AI agent executing any phase of this pipeline MUST proactively state the c | Current Phase | Completed Artifact / Gate | Next Sequential Phase & Command | |---|---|---| | Phase 1: Triage | `issue_assessment_.md` | ➔ Phase 1b: Root cause (`superpowers:systematic-debugging`) — skip only when the cause is already proven, and say so | -| Phase 1b: Root cause | Proven cause at `:` + its callers | ➔ Phase 3: BDD Scaffolding (`/gflow:scenario`), written at the **root**, not the symptom | +| Phase 1b: Root cause | Proven cause at `:` + its callers | ➔ Phase 2 (`/gflow:predict`) **if the fix touches a transport, auth, selectors or a schema** — a proven cause does not make the remedy safe; otherwise straight to Phase 3: BDD Scaffolding (`/gflow:scenario`), written at the **root**, not the symptom | | Phase 2: Pre-Implementation | Verdict `GO` or `CAUTION` | ➔ Phase 3: BDD Scaffolding (`/gflow:scenario `) | | Phase 3: BDD Scaffolding | `Scenario:` blocks & test scaffold | ➔ Phase 4: Implementation Plan (`/gflow:plan `) | | Phase 4: Implementation Plan | `PLAN.md` created & approved | ➔ Phase 6: Task Execution (`/gflow:status`) | diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d4b9de3..93afce00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,24 +7,76 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Changed - -- **Workflow: the Bug Lane is now the documented route from symptom to fix.** - `skills/issue-resolve/SKILL.md` gains a canonical `spike → systematic-debugging → - BDD → TDD → fix → e2e` chain, gated by *surface* (steps 0–2 are skippable for a - one-line fix whose cause is proven — but a skip is a claim and must be stated; - steps 3–5 never are). AGENTS.md, `skills/spike`, `skills/scenario`, - `docs/E2E_TESTING.md` and `docs/INDEX.md` cite it; none restate it. - - Removes a real contradiction: `issue-resolve` step 3 previously permitted "the - closest browser-free proxy" while AGENTS.md's Iron Law said a change with no e2e - coverage must get one and listed "covered by unit tests" among the excuses that - are *not* blockers. Two disjoint files, no merge conflict, no gate that could - see it. - - "Browser-free" is no longer accepted as a verification blocker: only a **named** - external blocker is (an account you do not control, a Mac, an exhausted quota). +### Fixed +- **A known Flow landing page is no longer reported as selector drift** + ([#756](https://github.com/ffroliva/gflow-cli/issues/756), and the 2026-09-10 RED + nightly canary). `flow_host_kind()` classifies the *origin*; `/about`, + `/project/` and `/fx/api/auth/signin?error=Callback` all share one, so when a + readiness wait timed out it had nothing left to blame but its own anchor — sending + the operator to "check for a newer release, then file a bug" over a session state + no release changes. The fourth instance of one pattern (after #721 credits, #749 + agent mode, and `FlowAppError`'s own crash page), so it is fixed once, shared: + - New `flow_landing_kind()` beside `flow_host_kind()` in `api/transports/_common.py` + names a known non-app landing (`"signin"` / `"public"` / `None`), and + `raise_if_known_landing()` converts the diagnosis at **three** raise sites — the + migrated readiness wait, the labs gallery sweep, and the labs prompt-box sweep, + where a bare `RuntimeError` was being SHA-256 hashed into "Unexpected error" with + the URL destroyed. Consulted **only inside an already-failed branch** — never ahead of a probe, which would delete the evidence + that corrects a wrong absence claim, and never as a new bounded wait after `goto`, + which reads the URL before Flow's client-side redirect lands + ([#639](https://github.com/ffroliva/gflow-cli/issues/639)). + - `flow.google.com/about` instead of the project → `FlowAppError` (exit 31), naming + the landing and the project it did not open. It deliberately does **not** say why: + #756 measured the redirect and not its cause, and `gflow auth status` reports the + session verified while it happens. + - `labs.google/fx/api/auth/signin?error=Callback` instead of the gallery → + `AuthExpiredError` (exit 3), remediation `gflow auth login`. This is the exact + page behind the 2026-09-10 RED canary, which reported + `Could not find 'New project' CTA`. + - `auth/internal_chromium.py` drops its private `_NEXTAUTH_ROUTE_PREFIX` and reuses + the shared classifier — the knowledge existed there since #767 and no transport + could reach it. + - `FlowAppError`'s docstring and `docs/USAGE.md`'s exit-code table now describe both + shapes; previously both stated the crash page as the only one. `docs/USAGE.md`'s + exit-3 row and `docs/MCP.md`'s retryable list are corrected to match, and + `docs/DEBUGGING.md` records that the sign-in landing is now capture-exempt — + deliberate (a bundle there would screenshot a Google auth surface into the artifact + users attach to issues), but a class swap switches capture off silently. + - The landing URL is stripped to scheme+host+path before it reaches the message or + the log: the NextAuth family includes `/fx/api/auth/callback/google?state=…&code=…`, + and this message is precisely what users paste into issues. + - `"public"` is scoped to the migrated host, the only one where `/about` was measured. + - **`accounts.google.com` is recognised too — found by live-verifying, not by + reasoning.** The first version returned `None` there on the grounds that "the + chooser has its own handler", which is true at bootstrap + (`client._handle_account_chooser`) and false for a hop that happens *after* it. A + live A/B on profile `denon82` (2026-09-10, $0) landed exactly there mid-run and + still produced `RuntimeError: Could not find 'New project' CTA` — with the OAuth + `state`, `code_challenge` and `client_id` interpolated into the message. It now + raises `FlowAccountChooserError` (38) for a chooser and `AuthExpiredError` (3) for + other Google sign-in surfaces, URL stripped. The bot-rejection hop + (`/v3/signin/rejected`) keeps returning `None` — it has its own error. ### Added - +- **`FlowAppError.retryable`** — a per-instance override of that class's + `RETRYABLE_ERRORS` membership. `None` (the default) keeps the class answer, so no + existing raise changes. Scoped to the one class that needs it: `is_retryable()` reads + it by `getattr`, so a base-class field would have sat on every error in the project + to serve a single raise site. + - It exists because routing `/about` to exit 31 would otherwise have silently flipped + that shape from non-retryable (its exit-23 past) to retryable, asserting on every + occurrence that a retry is worth making. **That was measured, and could not be + settled:** the redirect stopped reproducing on `ci-probe` between 2026-09-08 and + 2026-09-10 (5/5 attempts reached the editor — + [spike](docs/superpowers/spikes/2026-09-10-about-redirect-stability.md)), which is + equally consistent with "transient" and with "a session state changed". So the + `/about` raise site passes `retryable=False` to **preserve** the previous answer, + not to claim a retry fails. Flip it when someone catches the redirect live and + measures whether a second attempt wins. + - `is_retryable()` pins the override with `isinstance(..., bool)` rather than a + truthiness test: a `MagicMock` answers every attribute with a truthy child mock, so + a truthiness test would report **every** mocked error as retryable with nothing in + the suite noticing. Covered by a test that asserts that precondition explicitly. - **BDD scenarios can now be bound as e2e tests**, with no new machinery: pytest-bdd converts Gherkin tags into pytest markers, so a Feature tagged `@e2e @e2e_auth` is filtered by the existing `addopts` and selected by the existing `-m `. @@ -38,6 +90,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 left untagged, which escapes `addopts` and makes hosted CI try to drive Chrome. It carries its own fire-test, so a green run means "no orphans", not "never looked". +### Changed +- **Workflow: the Bug Lane is now the documented route from symptom to fix.** + `skills/issue-resolve/SKILL.md` gains a canonical `spike → systematic-debugging → + BDD → TDD → fix → e2e` chain, gated by *surface* (steps 0–2 are skippable for a + one-line fix whose cause is proven — but a skip is a claim and must be stated; + steps 3–5 never are). AGENTS.md, `skills/spike`, `skills/scenario`, + `docs/E2E_TESTING.md` and `docs/INDEX.md` cite it; none restate it. + - Removes a real contradiction: `issue-resolve` step 3 previously permitted "the + closest browser-free proxy" while AGENTS.md's Iron Law said a change with no e2e + coverage must get one and listed "covered by unit tests" among the excuses that + are *not* blockers. Two disjoint files, no merge conflict, no gate that could + see it. + - "Browser-free" is no longer accepted as a verification blocker: only a **named** + external blocker is (an account you do not control, a Mac, an exhausted quota). + ## [0.72.0] — 2026-09-09 ### Added diff --git a/docs/DEBUGGING.md b/docs/DEBUGGING.md index c3f9e5c5..27eac83b 100644 --- a/docs/DEBUGGING.md +++ b/docs/DEBUGGING.md @@ -116,7 +116,15 @@ also shows the recorded lock owner's PID/start-time evidence — advisory only, the kernel lock stays authoritative and nothing is ever reclaimed). Never captured: expected `ContentPolicyError`, ordinary `AuthExpiredError`, -usage/config validation, cancellation (Ctrl-C). Successful commands write +usage/config validation, cancellation (Ctrl-C). **That `AuthExpiredError` exclusion +now covers one more path than it used to:** since +[#756](https://github.com/ffroliva/gflow-cli/issues/756), landing on one of Flow's +OAuth/sign-in routes raises `AuthExpiredError` where it previously raised +`UiSelectorDriftError`, which *is* captured. The change is deliberate — the remediation +is `gflow auth login` either way, and a bundle there would put a DOM dump and a +full-page screenshot of a Google auth surface into the artifact users are prompted to +attach to issues. It is recorded here because swapping a class silently switches +capture off, which is exactly the trap `docs/PROJECT_STATUS.md` records. Successful commands write nothing. At most 3 bundles per command; repeats of the same failure fingerprint increment `suppressed_count` in the manifest instead. diff --git a/docs/E2E_TESTING.md b/docs/E2E_TESTING.md index 4a8bcea5..5f952ca1 100644 --- a/docs/E2E_TESTING.md +++ b/docs/E2E_TESTING.md @@ -89,23 +89,30 @@ Gherkin tag into a pytest marker, so a tagged scenario is filtered by the same **The three moving parts:** ```gherkin -# tests/features/account_chooser_landing.feature +# tests/features/landing_state_diagnosis.feature @e2e @e2e_auth # ← tags become pytest markers -Feature: A known landing state is named, not reported as selector drift - Scenario: the OAuth callback error page - Given a profile whose Flow session is authenticated - When the UI transport lands on /fx/api/auth/signin?error=Callback - Then it names the sign-in state, not a missing 'New project' CTA +Feature: A known landing state is named, never reported as selector drift + Scenario: the labs gallery is answered with a NextAuth sign-in error + Given the labs Flow gallery URL + When Flow answers it with a NextAuth sign-in error page + Then the failure says the session is signed out + And the failure does not blame the New project anchor ``` ```python -# tests/e2e/test_account_chooser_landing_bdd.py +# tests/e2e/test_landing_state_diagnosis_bdd.py from pytest_bdd import given, scenarios, then, when -scenarios("../features/account_chooser_landing.feature") +scenarios("../features/landing_state_diagnosis.feature") # step defs here; tests/e2e/conftest.py fixtures (e2e_profile_dir, …) apply ``` +**Feature-level tags propagate to every scenario** — measured on pytest-bdd 8.1, not +assumed: a scenario carrying no tags of its own was still selected by `-m e2e_auth` from +its Feature's tags. So tag the Feature once; per-scenario tags are for narrowing a single +scenario to a different tier (an `@e2e_video` case inside an otherwise `@e2e_auth` +feature), not for repeating the feature's own. + | Rule | Why | |---|---| | Feature file stays in `tests/features/` | one home for Gherkin; the guard scans one directory | @@ -114,10 +121,11 @@ scenarios("../features/account_chooser_landing.feature") | One feature file, one binding module | bound from two modules, every scenario runs twice | **Enforced offline** by `tests/features/test_e2e_binding_guard.py` (no browser, normal -CI): an `@e2e` feature with no binder under `tests/e2e/` fails, so does one with no cost -tier, and so does the dangerous inverse — a feature bound from `tests/e2e/` but left -untagged, which carries no `e2e` marker, escapes `addopts`, and makes hosted CI try to -drive Chrome. +CI), in four directions: an `@e2e` feature with no binder under `tests/e2e/` fails; so +does one with no cost tier; so does the dangerous inverse — a feature bound from +`tests/e2e/` but left untagged, which carries no `e2e` marker, escapes `addopts`, and +makes hosted CI try to drive Chrome; and so does a feature bound from **both** +directories, whose scenarios would run twice. > **What this does and does not prove.** The guard proves the test **exists and is > wired**, and runs anywhere. Proving it **passes** needs a warm profile and a real diff --git a/docs/INDEX.md b/docs/INDEX.md index a380d081..32023578 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -137,7 +137,7 @@ Slash commands for Claude Code, stored in `.claude/commands/gflow/`. All prefixe **"How do we use Copilot review on PRs?"** → [GITHUB § GitHub Copilot Code Review](GITHUB.md#github-copilot-code-review) **"Why did SonarCloud skip or fail on a forked PR?"** → [GITHUB § Forked PRs And SonarCloud](GITHUB.md#forked-prs-and-sonarcloud) **"How do I run e2e tests before a release?"** → [DEVELOPMENT § E2e gate](DEVELOPMENT.md#e2e-gate-before-merging-develop--main) -**"A bug came in — what is the route from symptom to shipped fix?"** → [skills/issue-resolve § The Bug Lane](../skills/issue-resolve/SKILL.md) (spike → debug → BDD → TDD → e2e, and which steps a given bug may skip) +**"A bug came in — what is the route from symptom to shipped fix?"** → [skills/issue-resolve § The Bug Lane](../skills/issue-resolve/SKILL.md) (spike → debug → BDD → TDD → fix → e2e, and which steps a given bug may skip) **"The scenario only reproduces in a browser — where does its test go?"** → [E2E_TESTING § BDD-bound e2e](E2E_TESTING.md#bdd-bound-e2e) **"What does each e2e marker cost? How do I run only the cheap tests?"** → [E2E_TESTING § Run commands](E2E_TESTING.md#run-commands) **"Has Flow drifted since the last release? What is the nightly canary telling me?"** → [E2E_TESTING § Nightly canary](E2E_TESTING.md#nightly-canary-502) diff --git a/docs/MCP.md b/docs/MCP.md index 9c388c00..c7c3e5b2 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -143,7 +143,12 @@ transport timeout (`TransportTimeoutError`), network blip (`NetworkError`), a dropped browser session (`BrowserSessionClosedError`), a Flow web-app crash (`FlowAppError`), an agentic-cohort flap (`FlowAgentUiError`), an unreachable UI arm (`UiModeUnavailableError`), and a partially-completed sync -(`SyncPartialError`). That list is the whole of `errors.RETRYABLE_ERRORS`. +(`SyncPartialError`). That list is `errors.RETRYABLE_ERRORS`, but it is no longer +the whole answer: `errors.is_retryable` consults the **instance** first, so a raise +site can override its class. One does today — Flow's `/about` redirect raises +`FlowAppError` with `retryable: false`, because whether a retry helps there was +measured and could not be settled ([#756](https://github.com/ffroliva/gflow-cli/issues/756)). +Read the flag off the envelope; never re-derive it from the class list. Everything else (auth, content-policy, configuration, security) is terminal (`retryable: false`): retrying the identical request fails the same way. This diff --git a/docs/USAGE.md b/docs/USAGE.md index fd022478..78f7f8b4 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -1752,7 +1752,7 @@ shell scripts can branch on the failure mode without parsing stderr. | `0` | — | Success | — | | `1` | unhandled exception | Anything not derived from `GFlowError` — **or a deliberate CLI verdict**: `gflow auth status` exits 1 for a dead/unverifiable session | Re-run with `--verbose`; for `auth status` follow the printed hint; file a bug if it persists | | `2` | usage error (Click) | Bad usage / missing arg / profile missing | Standard CLI usage error | -| `3` | `AuthExpiredError` | Session cookies rejected by Flow (401/403) | `gflow auth login --profile ` | +| `3` | `AuthExpiredError` | Session cookies rejected by Flow (401/403), or Flow served one of its OAuth/sign-in routes instead of the page gflow asked for ([#756](https://github.com/ffroliva/gflow-cli/issues/756)) | `gflow auth login --profile ` | | `4` | `RateLimitError` | Quota / rate limit hit, exhausted retries | Wait + reduce `GFLOW_CLI_CONCURRENCY` | | `5` | `ContentPolicyError` | Flow rejected the prompt (200 + empty `media[]`) | Soften prompt wording | | `6` | `NetworkError` | Network failure persisted across 3 attempts | Check connectivity | @@ -1780,7 +1780,7 @@ shell scripts can branch on the failure mode without parsing stderr. | `28` | `UiModeUnavailableError` | The Flow UI arm this command required (`--ui-mode`/`GFLOW_CLI_UI_MODE`; `-i` forces agentic for images; **video always requires classic** — no agentic video driver exists) couldn't be reached after a switch attempt; aborted before submitting — no credits spent (issue #299) | Retry (the cohort flaps per load); try another `--profile`; for images you can also relax `GFLOW_CLI_UI_MODE` — for video there is nothing to relax | | `29` | `MentionIndexUnavailableError` | An `@mention` was present but the catalog source needed to resolve it (character entities or media assets) failed to load — distinct from an empty index, which is not an error | Check network connectivity (character source) or `GFLOW_CLI_DB_PATH` / filesystem permissions (media source), then retry | | `30` | `QueueSchemaError` | A `gflow serve`/MCP worker-queue task payload has an unrecognized `schema_version` or fails validation against the typed request DTOs | Usually means gflow-cli was downgraded after a newer version enqueued the task, or the payload was hand-edited; re-enqueue with a compatible version | -| `31` | `FlowAppError` | Flow's web app hit a client-side exception (its error-boundary page rendered instead of the editor) — a transient Flow crash, not a gflow bug | Retry shortly; if it persists, Flow itself is degraded — wait and retry later | +| `31` | `FlowAppError` | Flow did not serve the page gflow asked for. Two shapes: its error-boundary page rendered instead of the editor (a transient client-side crash), or it redirected to `flow.google.com/about` instead of the project ([#756](https://github.com/ffroliva/gflow-cli/issues/756)) | Crash: retry shortly; if it persists, Flow itself is degraded. `/about`: open the project in a browser on that host and confirm this account can reach it — whether a retry helps is [not measured](superpowers/spikes/2026-09-10-about-redirect-stability.md), so gflow does not flag it retryable | | `32` | `ReferenceNotFoundError` | A referenced media NAME is not in this project's picker. Flow indexes a short auto-caption, not the generation prompt, so a prompt used as a reference name never matches | Reference the asset by its media UUID, pass a local file with `--ref`, or check what exists with `gflow data list images` | | `33` | — (`gflow doctor` verdict) | Doctor found warn/fail findings — a successful diagnosis, not an error class | Review the report; see [`gflow doctor`](#gflow-doctor) | | `34` | `SyncPartialError` | `gflow data sync` failed on some projects but succeeded on others — completed writes stay committed | Retryable: re-run the same command; it resumes with what is still nameless (see [`gflow data sync`](#gflow-data-sync)) | diff --git a/docs/superpowers/memory/ui-selector-drift-error-exit-23.md b/docs/superpowers/memory/ui-selector-drift-error-exit-23.md index efda3e37..76fa24f9 100644 --- a/docs/superpowers/memory/ui-selector-drift-error-exit-23.md +++ b/docs/superpowers/memory/ui-selector-drift-error-exit-23.md @@ -32,3 +32,26 @@ Recovery is `gflow auth login --profile ` while signed in as the recorded account. Precedent: exits 36 (`FlowHostMigratedError`) and 37 (`InsufficientCreditsError`) each got the same carve-out recorded when introduced. + +## Carve-out 4 — a known landing page is not drift (#756, 2026-09-10) + +The broadest one, and the one that names the shared cause under the other three. +`flow_host_kind` classifies the **ORIGIN, not the page**: `/about`, `/project/` +and `/fx/api/auth/signin?error=Callback` all satisfy the same host check. So every +readiness wait that timed out had nothing left to blame but its own anchor — which +is #756 (`/about` -> exit 23), #773 item 2 (a sign-in page reported as an account +chooser), and the 2026-09-10 RED canary (`Could not find 'New project' CTA` on a +NextAuth error page), all one defect at three sites. + +`api/transports/_common.py::flow_landing_kind` answers the missing question +(`"signin"` / `"public"` / `None`) and `raise_if_known_landing` converts the +diagnosis: sign-in routes -> `AuthExpiredError` (3), the migrated host's `/about` +-> `FlowAppError` (31, with `retryable=False`). Consulted **only inside an +already-failed branch** — never ahead of a probe, which would delete the DOM +evidence that corrects a wrong absence claim, and never as a bounded wait after +`goto`, which reads the URL before Flow's client-side redirect lands (#639). + +**The transferable lesson:** before reporting an anchor as drifted, ask whether the +page is the page you asked for. Three prior special cases (#721 credits, #749 agent +mode, `FlowAppError`'s crash page) were the same question answered one surface at a +time. diff --git a/docs/superpowers/spikes/2026-09-10-about-redirect-stability.md b/docs/superpowers/spikes/2026-09-10-about-redirect-stability.md new file mode 100644 index 00000000..af7acf41 --- /dev/null +++ b/docs/superpowers/spikes/2026-09-10-about-redirect-stability.md @@ -0,0 +1,68 @@ +# Is `flow.google.com/about` transient? — unanswered, and that is the finding + +**Date:** 2026-09-10 · **Profile:** `ci-probe` · **Cost:** $0 (no generation) +**Script:** [`scripts/dev/spike_about_redirect_stability.py`](../../../scripts/dev/spike_about_redirect_stability.py) +**Raw:** `scripts/dev/_spike_out/about_redirect_stability_20260910_102147.json` (gitignored) +**Refs:** [#756](https://github.com/ffroliva/gflow-cli/issues/756) + +## Why it was asked + +Routing the `/about` landing to `FlowAppError` (exit 31) hands it that class's retry +semantics, because `is_retryable` was class-level. So the code would assert, on every +occurrence, that a retry is worth making — an assertion nobody had measured. #756 +measured the *redirect* and explicitly declined to measure its *cause*; "does it +repeat?" is a narrower question and looked answerable, because `ci-probe` is the +profile that produced the original report and it is on this machine. + +## The reading was fixed before the run + +Written into the script's docstring **before** it was executed, so the outcome could +not be reinterpreted to suit the change it was gating: + +| Outcome | Reading | +|---|---| +| N/N `/about` | stable for this account — a retry is doomed; must not be retryable | +| mixed | it flaps — a retry can win; retryable is defensible | +| 0/N `/about` | does not reproduce; settles **nothing** — absence of a reproduction is not evidence of transience | + +## What was observed + +Five sequential `MigratedComposer.ensure_editor` calls against project +`1e4efe0d-…` on `ci-probe`: + +| # | Result | Landed | Elapsed | +|---|---|---|---| +| 1 | `editor_ready` | `/project/1e4efe0d-…` | 1.86 s | +| 2–5 | `editor_ready` | `/project/1e4efe0d-…` | 0.02–0.03 s | + +`/about` landings: **0 of 5.** The session authenticated normally +(`flow_session_cookie_present=True`, `expired=False`, `google_sapisid_present=True`, +51 context cookies). + +## Verdict + +**The redirect no longer reproduces on `ci-probe`, and retryability is therefore +UNMEASURED.** It disappeared sometime between 2026-09-08 and 2026-09-10. + +That is consistent with "it was transient" *and* with "an account or session state +changed underneath it" — a re-auth, a project-access grant, a cohort move. Nothing +here distinguishes those, so nothing here licenses a retry claim in either direction. + +## What was done with it + +`GFlowError` gained a per-instance `retryable` override (the same class-default / +instance-override shape `remediation_hint` already had), and the `/about` raise site +passes `retryable=False`. **That is not a finding that retrying fails.** This shape +raised exit 23 before, which was already non-retryable, so `False` preserves the +existing answer instead of inventing a new one under cover of an exit-code change. + +## Not measured + +- **Why the redirect happened at all**, in either direction. Out of scope by design; + #756 warns against a fix that asserts an unmeasured cause. +- **Whether a *second* attempt wins during a live occurrence.** This is the question + that actually settles the flag, and it needs someone to catch the redirect while it + is happening. Re-run this script with `--attempts 5` at that moment and the answer + falls out. +- **Any other account.** One profile is one account; per + `flow-capabilities-are-cohort-dependent`, an account is not a cohort. diff --git a/scripts/dev/spike_about_redirect_stability.py b/scripts/dev/spike_about_redirect_stability.py new file mode 100644 index 00000000..ede3eab4 --- /dev/null +++ b/scripts/dev/spike_about_redirect_stability.py @@ -0,0 +1,144 @@ +"""Spike: is `flow.google.com/about` a TRANSIENT landing or a STABLE one? (#756) + +**The question, and why it needs measuring.** Routing the `/about` landing to +``FlowAppError`` gives it exit 31's retry semantics, because ``is_retryable`` is +class-level (``errors.py``). Whether a retry helps is therefore an assertion the +code makes on every occurrence — and nobody has measured it. #756 measured the +redirect and explicitly declined to measure its cause; this measures only whether +it *repeats*, which is a different and answerable question. + +**Method.** N sequential ``MigratedComposer.ensure_editor`` calls against one +project on one profile, recording where each landed via the production +``flow_landing_kind`` — never a local copy of the predicate. No generation, no +upload, no prompt submitted: **zero credits.** Not read-only, though: a +non-``/about`` failure reaches ``_exit_agent_mode``, which clicks a chip Flow +remembers per account. Same shape as the 2026-09-04 migrated-host +mechanism spike that settled ``FlowHostMigratedError``'s retryability (5/5, 7/7). + +**How to read the result.** + +| Outcome | Reading | +|---|---| +| N/N `/about` | stable for this account — a retry is doomed; must NOT be retryable | +| mixed | it flaps — a retry can win; retryable is defensible | +| 0/N `/about` | **does not reproduce today.** Settles NOTHING about + retryability — absence of a reproduction is not evidence of transience | + +**Scope, stated up front.** One profile is one account, and per +``flow-capabilities-are-cohort-dependent`` an account is not a cohort. This +answers "does it repeat for THIS account, now" — nothing wider. + + python scripts/dev/spike_about_redirect_stability.py --profile ci-probe --attempts 5 +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +import time +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _spike_common import ( # noqa: E402, isort: skip + build_client, + default_out_path, + resolve_profile_dir, + step, +) + + +async def _one_attempt(client: Any, project_id: str, timeout_s: float) -> dict[str, Any]: + """One ensure_editor call. Records where it landed and what it raised.""" + from gflow_cli.api.transports._common import flow_landing_kind + from gflow_cli.api.transports.migrated_composer import MigratedComposer + + page = client._page # noqa: SLF001 — dev instrument + started = time.monotonic() + outcome: dict[str, Any] = {} + try: + await MigratedComposer().ensure_editor(page, project_id, timeout_s=timeout_s) + outcome["result"] = "editor_ready" + outcome["error"] = None + except Exception as exc: # noqa: BLE001 — the failure IS the measurement + # NOT BaseException: that swallowed Ctrl-C, recorded it as a non-/about + # sample, and advanced the loop — so an interrupted run could print + # "DOES NOT REPRODUCE" built from interrupts (council D13). + outcome["result"] = "raised" + outcome["error"] = f"{type(exc).__name__}: {exc}" + outcome["landed_url"] = str(getattr(page, "url", "")) + # The PRODUCTION classifier, never a local re-implementation: `endswith("/about")` + # scores `/about?hl=en` as a non-reproduction, and would drift from + # `_PUBLIC_LANDING_PATHS` the moment either changes. That is the #743 shape — a + # verdict computed from an incomplete set (council D13). + outcome["is_about"] = flow_landing_kind(outcome["landed_url"]) == "public" + outcome["elapsed_s"] = round(time.monotonic() - started, 2) + return outcome + + +async def _run(profile: str, project_id: str, attempts: int, timeout_s: float) -> int: + profile_dir = resolve_profile_dir(profile) + step("profile", f"{profile} -> {profile_dir}") + + async with build_client(profile_dir) as client: + step("project", str(project_id)) + + results: list[dict[str, Any]] = [] + for i in range(1, attempts + 1): + outcome = await _one_attempt(client, str(project_id), timeout_s) + results.append(outcome) + step( + f"attempt {i}/{attempts}", + f"{outcome['result']} is_about={outcome['is_about']} " + f"{outcome['elapsed_s']}s url={outcome['landed_url']}", + ) + + about = sum(1 for r in results if r["is_about"]) + ready = sum(1 for r in results if r["result"] == "editor_ready") + if about == attempts: + verdict = "STABLE — every attempt landed on /about; a retry is doomed here" + elif about == 0: + verdict = ( + "DOES NOT REPRODUCE on this profile today — settles NOTHING about " + "retryability. Do not read this as 'transient'." + ) + else: + verdict = f"FLAPS — {about}/{attempts} landed on /about; a retry can win" + + step("verdict", verdict) + out = default_out_path("about_redirect_stability") + out.write_text( + json.dumps( + { + "profile": profile, + "project_id": project_id, + "attempts": attempts, + "timeout_s": timeout_s, + "about_landings": about, + "editor_ready": ready, + "verdict": verdict, + "results": results, + }, + indent=2, + ), + encoding="utf-8", + ) + step("out", str(out)) + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--profile", required=True, help="profile name, e.g. ci-probe") + ap.add_argument("--project-id", required=True, help="project to open on this account") + ap.add_argument("--attempts", type=int, default=5, help="sequential attempts (default 5)") + ap.add_argument("--timeout-s", type=float, default=30.0, help="ensure_editor readiness wait") + args = ap.parse_args() + return asyncio.run(_run(args.profile, args.project_id, args.attempts, args.timeout_s)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/issue-resolve/SKILL.md b/skills/issue-resolve/SKILL.md index b3c6afb5..ab5ac67a 100644 --- a/skills/issue-resolve/SKILL.md +++ b/skills/issue-resolve/SKILL.md @@ -49,8 +49,43 @@ fix whose cause is self-evident and whose blast radius is one line — a typo, a exit-code string, a doc correction. **A skip is a claim; say it out loud** ("cause proven at `:`, skipping the debug step") so the skip is reviewable. -Steps **3–5 have no gate.** There is no bug small enough to fix without a test -that failed first, and no Flow-surface change that a unit test discharges. +Steps **3–5 have no gate** — but read step 3 correctly when step 2 was skipped. +There is no bug small enough to fix without a test that failed first, and no +Flow-surface change that a unit test discharges. What step 3 requires is **the +reproduction, red first**; Gherkin is its form only when step 2 produced Gherkin. +Skip step 2 and step 3 still owes you a failing test — an ordinary `test_*` that +reproduces the bug — it just is not a scenario. The gate is *red before green*, +never *Gherkin before green*. + +### A flag is a claim — decide it with evidence or don't change it + +`retryable`, an exit code, a capability-table entry, a `skip_if_*` predicate: these are +read by code that **acts** on them. `retryable=True` is not a hint, it is an instruction +to try again. So metadata obeys the same rule as prose — *a claim you have not run is a +guess with formatting.* The trap is that a flag changes for free when you re-route a +raise to a different class, so a refactor smuggles in an assertion nobody reviewed. + +**How to decide, in order:** + +| Can you reproduce the condition? | Then | +|---|---| +| **Yes** | Measure it. N sequential attempts, cheapest surface that reaches it. N/N = stable · mixed = it flaps · and **write down which reading means what before you run** | +| **No, but the surface already gave an answer** | **Preserve that answer** and record that it is preserved, not measured. The status quo is not a claim; changing it is | +| **No, and the condition is new** | Leave the class default and say so at the raise site. An unset flag is honest; a guessed one is not | + +Never let a class default speak for a raise site it was not written for. When one class +covers shapes with genuinely different semantics, give the raise site an override rather +than picking one answer for both — `FlowAppError.retryable` exists for exactly this, +and lives on that class alone until a second one needs it. + +> **Written from a near-miss in the same session that wrote this file.** Routing #756's +> `/about` landing to `FlowAppError` (exit 31) would have flipped it from non-retryable +> to retryable purely as a side effect of the exit-code change, and the first draft +> shipped that with a confident remediation string saying a retry was "unlikely to help" +> — also unmeasured. The maintainer caught it: *"I need evidence and test. otherwise +> everything will be a guess."* The measurement came back **inconclusive** (the redirect +> had stopped reproducing), which is why the rule's middle row exists: inconclusive is a +> real result, and it means preserve, not pick. ### Step 5 is the one that gets rationalised away diff --git a/skills/scenario/SKILL.md b/skills/scenario/SKILL.md index 5ea72816..bd420305 100644 --- a/skills/scenario/SKILL.md +++ b/skills/scenario/SKILL.md @@ -207,8 +207,9 @@ that. So the tag decides where the scenario runs and who must run it: | in our own code (parsing, routing, exit codes, redaction) | none | `tests/features/test__steps.py` | One feature file is bound by exactly **one** module — bound twice, its scenarios run -twice. `tests/features/test_e2e_binding_guard.py` enforces both directions offline, so -an `@e2e` scenario nobody wrote a test for fails normal CI. Mechanics: +twice. `tests/features/test_e2e_binding_guard.py` enforces this offline in four directions +(orphan, missing tier, untagged live binding, double binding), so an `@e2e` scenario +nobody wrote a test for fails normal CI. Mechanics: [`docs/E2E_TESTING.md`](../../docs/E2E_TESTING.md) § BDD-bound e2e. ## Known-issues cross-reference diff --git a/skills/spike/SKILL.md b/skills/spike/SKILL.md index 59965eec..2fe91cf8 100644 --- a/skills/spike/SKILL.md +++ b/skills/spike/SKILL.md @@ -119,6 +119,30 @@ If you write a spike that launches Chrome itself rather than through `FlowApiCli wrap it: `async with ProfileLease(profile_dir), async_playwright() as pw:`. Chrome must never start on a profile this process does not own. +## Pre-register the reading before you run + +Write down what each possible outcome will mean **before** the spike executes — in the +script's own docstring, where it is timestamped by the commit. Then the result cannot be +reinterpreted to suit whatever change the spike was gating. + +| Outcome | Reading | +|---|---| +| N/N | stable | +| mixed | it flaps | +| 0/N | **does not reproduce; settles nothing** | + +That last row is the one worth pre-writing, because it is the one you will be tempted to +spin. **A condition that has stopped reproducing has not been shown to be transient.** It +is equally consistent with some state having changed underneath it, and a spike that +cannot distinguish those has produced one honest result: *unmeasured*. + +Unmeasured is a real finding. Report it as the answer, not as a failed run — and say what +would settle it, so the next person who sees the condition live knows what to capture. + +> **Worked example:** [`2026-09-10-about-redirect-stability.md`](../../docs/superpowers/spikes/2026-09-10-about-redirect-stability.md) +> — asked whether Flow's `/about` redirect is transient, got 0/5, and shipped +> "unmeasured" rather than letting a disappearance argue for a retry flag. + ## Output - Evidence → `scripts/dev/_spike_out/` (**gitignored**; captures carry Bearer tokens, diff --git a/src/gflow_cli/api/transports/_common.py b/src/gflow_cli/api/transports/_common.py index cd06fc8f..2dcec336 100644 --- a/src/gflow_cli/api/transports/_common.py +++ b/src/gflow_cli/api/transports/_common.py @@ -24,7 +24,9 @@ from gflow_cli.errors import ( AuthExpiredError, ContentPolicyError, + FlowAccountChooserError, FlowApiError, + FlowAppError, FlowHostMigratedError, NetworkError, RateLimitError, @@ -89,6 +91,166 @@ def flow_host_kind(url: object) -> str | None: return _FLOW_HOSTS.get(host) +#: NextAuth mounts Flow's OAuth routes on the *app's own origin*, so a host check +#: passes straight through them: `/fx/api/auth/callback/google?...` and +#: `/fx/api/auth/signin?error=Callback` are both `labs.google`. Verified in +#: `auth/internal_chromium.py`, which is where this constant used to live — +#: privately, and used only to gate a session poll, so no transport could see it. +#: +#: It matches the whole `/fx/api/auth/` family, NOT just `/signin` — callback and +#: `/session` too — so anything derived from it must not claim "the sign-in page". +#: NOTE: `auth/internal_chromium.py::_is_safe_to_probe_session` gates the login +#: session poll on `flow_landing_kind(...) != "signin"`. Reclassifying a NextAuth +#: path here re-opens the cookie-rotation hole that poll exists to avoid (#769). +_NEXTAUTH_ROUTE_PREFIX = "/fx/api/auth/" + + +def flow_landing_kind(url: object) -> str | None: + """Name a known **non-app** landing on a Flow origin: ``"signin"``, ``"public"``, or ``None``. + + :func:`flow_host_kind` answers *which Flow origin*; this answers *whether the + origin served the app at all*. They are different questions, and conflating them + is how a sign-in error page and a project editor became indistinguishable — + `/about`, `/project/` and `/fx/api/auth/signin?error=Callback` all pass a + host check, so a readiness wait that missed had nothing left to blame but its own + anchor (#756, #773, the 2026-09-10 RED canary). + + ``None`` means **"nothing recognised"**, never "this is the app". A caller may + only use a positive answer to REPLACE a diagnosis it was already about to make. + Never call this ahead of a probe to decide whether to look: a fail-fast that runs + before the evidence is collected deletes the evidence that would correct it + (`skills/spike/SKILL.md`), and `page.url` read too early misses Flow's redirect + entirely, because it is client-side and lands after ``goto`` returns (#639). + + ``"chooser"`` and ``"signin"`` also cover ``accounts.google.com`` — Google's auth + host, which is not a Flow origin but IS a known place to land. Reaching it mid-run + is measured, not theoretical (2026-09-10, profile ``denon82``): the session hopped + there after bootstrap and the labs gallery sweep reported a missing CTA on Google's + sign-in page. The rejected-browser route keeps returning ``None`` — it has its own + error and must never read as a missing account or an expired session. + + ``"public"`` is deliberately scoped to the **migrated** host: `/about` was measured + there (#756) and nowhere else, and the remediation text names `flow.google.com`. + A `labs.google/about` landing would be a different, unmeasured thing, so it stays + ``None`` and the caller's own diagnosis stands rather than a message about the + wrong host. + + Not measured, and so not encoded: whether a NextAuth route can carry a locale + segment (`/fx/pt/api/auth/...`). `routes.py` shows Flow does that for the app's + own paths. A non-EN profile would settle it; until then the prefix stays exact, + exactly as ``internal_chromium`` had it. + + Total by construction, like its sibling: anything unparseable — or not even a + string — is ``None``, so a probe error can never displace the real failure. + """ + if not isinstance(url, str): + return None + try: + parts = urlsplit(url) + except ValueError: + return None + if parts.scheme != "https": + return None + host = (parts.hostname or "").lower() + path = parts.path + + # Google's own auth host. Measured live on 2026-09-10: a session can hop here + # MID-RUN, after bootstrap has already passed, and `_enter_editor` then sweeps + # for a "+ New project" CTA on Google's sign-in page and reports the anchor. + # `client._handle_account_chooser` covers the bootstrap hop and only that, so + # this file's first version returned None here on the reasoning "the chooser has + # its own handler" — true at bootstrap, false everywhere else. + if host == "accounts.google.com": + # The bot-rejection hop. Path-tested rather than importing + # `auth.internal_chromium.GOOGLE_REJECTED_BROWSER_ROUTE`: `_common` -> `auth` + # is a real import cycle (`_common` reaches `profile_store`, which imports + # `gflow_cli.auth`), which is why that module imports THIS one deferred. + # It has its own error and must never read as a missing account or an + # expired session — same exclusion `client._handle_account_chooser` makes. + if path.rstrip("/").endswith("/v3/signin/rejected"): + return None + return "chooser" if path.rstrip("/").endswith("accountchooser") else "signin" + + host_kind = _FLOW_HOSTS.get(host) + if host_kind is None: + return None + if path.startswith(_NEXTAUTH_ROUTE_PREFIX): + return "signin" + if host_kind == "migrated" and path.rstrip("/") == "/about": + return "public" + return None + + +def raise_if_known_landing(page: object, *, requested: str, at: str) -> None: + """Replace an about-to-be-raised drift report when the page is a **known landing**. + + Call this from **inside a failure branch**, at a point where the caller is already + committed to raising — after a readiness wait has timed out, never before it. Two + reasons, both learned the hard way: a guard placed ahead of the probe deletes the + evidence that would correct it (`skills/spike/SKILL.md`), and Flow's hop to a + landing page is client-side, so ``page.url`` read right after ``goto`` is read too + early and sees nothing (#639). By the time the wait has failed, the URL has settled + and is simply true. + + Returns silently when nothing is recognised, which is the common case and means the + caller's own diagnosis stands. **It does not follow that the call is safe anywhere:** + put it in a branch that can still recover and it converts a recoverable state into a + raise. `migrated_composer.ensure_editor`'s ``except`` has such a recovery path — the + call sits before it because agent-mode recovery cannot succeed on a landing page, + which is a property of THAT branch, not of this function. + + ``requested`` is what the caller asked Flow for; the whole complaint in #756 is that + the operator could not tell what was asked for and what arrived. + + The URL is stripped to scheme+host+path before it goes anywhere. The NextAuth family + includes `/fx/api/auth/callback/google?state=...&code=...`, and this message is the + artifact users paste into issues — an auth code is single-use, but it has no business + being in it. + """ + url = str(getattr(page, "url", "") or "") + kind = flow_landing_kind(url) + if kind is None: + return + parts = urlsplit(url) + safe_url = f"{parts.scheme}://{parts.netloc}{parts.path}" if parts.scheme else url + log.info("ui_driver.known_landing", at=at, kind=kind, url=safe_url, requested=requested) + if kind == "chooser": + # The existing class for "we are at the chooser and cannot proceed" (#763/#764, + # exit 38). Path-only, so no DOM probe is needed here — the bootstrap handler + # does the `[data-email]` work and this is the raise for a hop that never + # reaches it. + raise FlowAccountChooserError( + detail=( + f"Google's account chooser is displayed ({safe_url}) instead of " + f"{requested} — the session needs a person to pick an account. " + f"Not selector drift." + ) + ) + if kind == "signin": + raise AuthExpiredError( + detail=( + f"Flow served one of its OAuth/sign-in routes ({safe_url}) instead of " + f"{requested} — this session is not signed in to Flow on that host, so " + f"none of the controls gflow drives are on the page. Not selector drift." + ) + ) + # Deliberately says WHAT arrived and stops. #756 measured the redirect and did not + # measure its cause — `gflow auth status` reports the session verified while this + # happens — so naming one here would just be a second confident wrong diagnosis. + raise FlowAppError( + detail=( + f"Flow redirected to its public landing page ({safe_url}) instead of " + f"{requested}. gflow cannot tell from here why it declined — this account " + f"may not have access to that project on this host. It is not selector " + f"drift, and no gflow-cli release changes it." + ), + # NOT a claim that a retry fails — the ABSENCE of one. See FlowAppError's + # docstring for the measurement that could not be made and why False preserves + # the answer this shape already gave as exit 23. + retryable=False, + ) + + def migrated_route(url: object, flow_host: str, *, prefer_migrated: bool = False) -> str: """Which driver a page gets: ``"labs"``, ``"migrated"`` or ``"blocked"``. diff --git a/src/gflow_cli/api/transports/migrated_composer.py b/src/gflow_cli/api/transports/migrated_composer.py index 600a40d0..fced7168 100644 --- a/src/gflow_cli/api/transports/migrated_composer.py +++ b/src/gflow_cli/api/transports/migrated_composer.py @@ -43,7 +43,7 @@ from gflow_cli.api.dto import GeneratedImage from gflow_cli.api.image import Aspect as ImageAspect from gflow_cli.api.image import Model as ImageModel -from gflow_cli.api.transports._common import extract_project_id +from gflow_cli.api.transports._common import extract_project_id, raise_if_known_landing from gflow_cli.api.transports.batchexecute import ( GenerationRecord, generation_record, @@ -597,6 +597,13 @@ async def ensure_editor(self, page: Page, project_id: str, *, timeout_s: float = try: await trigger.wait_for(state="visible", timeout=int(timeout_s * 1000)) except Exception as e: + # Before blaming the anchor, ask the prior question: is this even the page + # we asked for? Flow answers a project navigation with its public /about + # landing when it will not open that project for this session (#756), and + # `flow_host_kind` cannot see it — /about and /project/ share an origin. + # Reaching this line on a landing page means the trigger was never going to + # be here, so probing for the agent chip below is meaningless too. + raise_if_known_landing(page, requested=target, at="migrated.ensure_editor") # Only now look for agent mode. Probing for the chip BEFORE this wait raced # the SPA: `goto` returns on `domcontentloaded` and Angular mounts the # composer seconds later, so the chip was reliably absent at that point, the diff --git a/src/gflow_cli/api/transports/ui_automation.py b/src/gflow_cli/api/transports/ui_automation.py index 8fb4ba5a..575cd0d0 100644 --- a/src/gflow_cli/api/transports/ui_automation.py +++ b/src/gflow_cli/api/transports/ui_automation.py @@ -38,6 +38,7 @@ generation_error, migrated_route, offered_menu_labels, + raise_if_known_landing, raise_if_migrated, ) from gflow_cli.api.transports.migrated_composer import MENU_ITEM, ModelMenuMatcher @@ -1599,6 +1600,16 @@ async def _enter_editor( except Exception: continue + # Before blaming the anchor, ask the prior question: is this the gallery at all? + # NextAuth mounts Flow's OAuth routes on labs.google itself, so a signed-out + # session sits at `/fx/api/auth/signin?error=Callback` and passes every host + # check this file makes — `auth/internal_chromium.py` has known that since #767; + # no transport could see it. The 2026-09-10 RED canary is this exact page, + # reported as a missing CTA. Consulted only HERE, after the sweep has already + # run: an early bail would delete the DOM evidence that corrects a wrong + # absence claim, which is how #739 shipped one (see the note below). + raise_if_known_landing(page, requested="the Flow gallery", at="labs.enter_editor") + shot_path = await _capture_debug_screenshot(page, out_dir, "debug_new_project.png") # NO migrated-host branch here. #739 added one asserting that # flow.google.com "renders no '+ New project' control gflow can drive". That was an @@ -1684,6 +1695,14 @@ async def _locate_prompt_box( except Exception: continue + # The third site, and the worst of the three. `_enter_editor(project_id=...)` + # has no readiness gate of its own — it navigates, settles, checks overlays and + # returns — so a landing page passes all of it and the FIRST thing to fail is + # this sweep. It raises a bare RuntimeError, which `observability.py` SHA-256 + # hashes because it is not a GFlowError, so the operator is shown "Unexpected + # error" with even the URL destroyed. Worse than the drift report #756 is about. + raise_if_known_landing(page, requested="the Flow editor", at="labs.locate_prompt_box") + shot_path = await _capture_debug_screenshot(page, out_dir, "debug_prompt_not_found.png") msg = f"Prompt input not found in Flow UI. URL: {page.url}.{screenshot_clause(shot_path)}" raise RuntimeError(msg) diff --git a/src/gflow_cli/auth/internal_chromium.py b/src/gflow_cli/auth/internal_chromium.py index 4813fb8d..b5a959d2 100644 --- a/src/gflow_cli/auth/internal_chromium.py +++ b/src/gflow_cli/auth/internal_chromium.py @@ -2,7 +2,6 @@ import asyncio from typing import TYPE_CHECKING, Any -from urllib.parse import urlsplit import structlog from playwright.async_api import Error as PlaywrightError @@ -24,12 +23,6 @@ GEMINI_URL = "https://labs.google/fx/tools/flow?hl=en" GOOGLE_REJECTED_BROWSER_ROUTE = "accounts.google.com/v3/signin/rejected" POLL_INTERVAL_SECONDS = 3 -# NextAuth mounts its OAuth routes here — callback, signin, and the session endpoint -# itself. The poll stays off the page while it is on one of them; see -# `_is_safe_to_probe_session`. Host classification reuses `flow_host_kind`, which already -# knows both cohorts (labs.google and the migrated flow.google.com), so there is no -# host set to keep in sync here. -_NEXTAUTH_ROUTE_PREFIX = "/fx/api/auth/" def login_launch_kwargs( @@ -243,12 +236,16 @@ def _is_safe_to_probe_session(page: object) -> bool: # Deferred: a module-level import cycles. `_common` reaches `profile_store`, which # imports `gflow_cli.auth` — verified as # "cannot import name 'default_profile_root' from partially initialized module". - from gflow_cli.api.transports._common import flow_host_kind + from gflow_cli.api.transports._common import flow_host_kind, flow_landing_kind url = getattr(page, "url", "") if flow_host_kind(url) is None: return False - return not urlsplit(str(url)).path.startswith(_NEXTAUTH_ROUTE_PREFIX) + # The NextAuth prefix used to be a private constant here. It is now + # `flow_landing_kind`, next to `flow_host_kind`, because the transports need the + # same knowledge and could not reach it: a signed-out session sits on one of these + # routes while passing every host check, which is #756 / the 2026-09-10 RED canary. + return flow_landing_kind(url) != "signin" class InternalChromiumStrategy(AuthStrategy): diff --git a/src/gflow_cli/errors.py b/src/gflow_cli/errors.py index 44180eab..1512e7e2 100644 --- a/src/gflow_cli/errors.py +++ b/src/gflow_cli/errors.py @@ -700,21 +700,46 @@ class FlowAgentUiError(GFlowError): class FlowAppError(GFlowError): - """Raised when Google Flow's web app itself crashed — a client-side exception - (its React error boundary), not a gflow-cli issue. The editor never rendered, - so no generation control exists to drive. **Transient and retryable** (exit - code 31). Detected at the mode-switch raise site via the Flow error-page title, - which otherwise surfaces as a misleading ``UiSelectorDriftError`` "file a bug". + """Raised when Google Flow's own app did not give us the page we asked for — + not a gflow-cli issue. Either way the editor never rendered, so no generation + control exists to drive (exit code 31). **Two measured shapes:** + + 1. **Its React error boundary** — the app crashed client-side. Transient; + retry works. Detected at the mode-switch raise site via the error-page title. + 2. **A redirect to Flow's public landing page** (``/about``, #756) — the app + declined to open the project for this session. *Why* is not measured: + ``gflow auth status`` reports the session verified while it happens, so the + message names the redirect and stops rather than inventing a cause. Nor is it + known whether a retry helps — the redirect stopped reproducing before it could + be measured (spike 2026-09-10), so this raise site passes ``retryable=False`` + to PRESERVE the answer it gave as exit 23, not to claim a retry fails. + + Both otherwise surface as a misleading ``UiSelectorDriftError`` "file a bug" — + which is the whole reason this class exists. See + ``api/transports/_common.py::raise_if_known_landing``. """ problem_type = "https://gflow-cli.dev/errors/flow-app" title = "Google Flow web app error" + + #: Per-instance override of this class's ``RETRYABLE_ERRORS`` membership; ``None`` + #: keeps it. It lives HERE and not on ``GFlowError`` because there is exactly one + #: producer (``_common.py::raise_if_known_landing``) and one class with two shapes + #: that disagree about retrying. ``is_retryable`` reads it by ``getattr``, so a base + #: declaration would buy no typing and no test-double visibility — only a field on + #: every error in the project. Move it up if, and only if, a second class needs it. + retryable: bool | None = None _default_remediation = ( - "Google Flow's web app failed to load (a client-side exception on " - "labs.google) — a transient Flow-side error, not a gflow-cli bug. Retry in a " - "moment; if it persists, check https://labs.google/fx and try a fresh session." + "Google Flow did not serve the page gflow asked for — a Flow-side condition, " + "not a gflow-cli bug. If it crashed (client-side exception), retry in a moment. " + "If it redirected to flow.google.com/about, open the project in a browser on " + "that host and confirm this account can reach it." ) + def __init__(self, *args: Any, retryable: bool | None = None, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.retryable = retryable + class FlowHostMigratedError(GFlowError): """Raised when Flow served the project from ``flow.google.com`` — the origin @@ -1339,5 +1364,18 @@ def __init__( def is_retryable(exc: GFlowError) -> bool: - """Shared retry classification consumed by every machine-readable error surface.""" + """Shared retry classification consumed by every machine-readable error surface. + + The class answer (``RETRYABLE_ERRORS``) unless the raise site overrode it — see + ``FlowAppError.retryable``. + + ``isinstance(..., bool)`` rather than a truthiness test, deliberately: a + ``MagicMock`` answers every ``getattr`` with a truthy child mock, so + ``if override is not None`` would silently report **every** mocked error as + retryable and no assertion in the suite would notice + (memory ``magicmock-truthy-getattr-silences-guards``). + """ + override = getattr(exc, "retryable", None) + if isinstance(override, bool): + return override return isinstance(exc, RETRYABLE_ERRORS) diff --git a/tests/api/transports/test_common.py b/tests/api/transports/test_common.py index 07837ec4..e9876cfd 100644 --- a/tests/api/transports/test_common.py +++ b/tests/api/transports/test_common.py @@ -17,6 +17,7 @@ REFRESH_SAFETY_MARGIN_S, await_url_settled, flow_host_kind, + flow_landing_kind, interpret_response, mint_batch_id, ) @@ -336,3 +337,49 @@ async def test_already_localised_url_still_short_circuits(self) -> None: page.url = "https://labs.google/fx/pt/tools/flow" page.wait_for_url = AsyncMock(side_effect=AssertionError("must not wait")) assert await await_url_settled(page) == "https://labs.google/fx/pt/tools/flow" + + +class TestFlowLandingKind: + """`flow_landing_kind` answers *did the origin serve the app*, which + `flow_host_kind` cannot — /about, /project/ and a NextAuth sign-in error + page all share one origin (#756, #773, the 2026-09-10 RED canary).""" + + @pytest.mark.parametrize( + ("url", "expected"), + [ + # The two shapes that were reported as selector drift. + ("https://flow.google.com/about", "public"), + ("https://labs.google/fx/api/auth/signin?error=Callback", "signin"), + # The one `auth/internal_chromium.py` already knew about, from #767. + ("https://labs.google/fx/api/auth/callback/google?state=x&code=y", "signin"), + ("https://flow.google.com/about/", "public"), + # Real app pages: nothing recognised, so the caller's own diagnosis stands. + ("https://flow.google.com/project/abc-123", None), + ("https://labs.google/fx/tools/flow", None), + ("https://labs.google/fx/pt/tools/flow", None), + # Google's auth host. The first version of this returned None here, + # reasoning "the chooser has its own handler" — true at BOOTSTRAP + # (`client._handle_account_chooser`) and false for a mid-run hop, which + # is what a live run on `denon82` produced on 2026-09-10: the labs + # gallery sweep reported a missing CTA on Google's sign-in page. + ("https://accounts.google.com/v3/signin/accountchooser?client_id=x", "chooser"), + ("https://accounts.google.com/v3/signin/identifier", "signin"), + # The bot-rejection hop keeps its own error — never a missing account + # and never an expired session. + ("https://accounts.google.com/v3/signin/rejected", None), + # Substring impostors — the host must match exactly, never by mention. + ("https://evil.example/?next=https://flow.google.com/about", None), + ("https://evil.example/?n=https://accounts.google.com/v3/signin/accountchooser", None), + ("http://accounts.google.com/v3/signin/accountchooser", None), + ("http://flow.google.com/about", None), + ], + ) + def test_classifies_known_landings(self, url: str, expected: str | None) -> None: + assert flow_landing_kind(url) == expected + + @pytest.mark.parametrize("url", [None, 123, object(), "", "not a url", "https://[bad"]) + def test_total_by_construction(self, url: object) -> None: + """Callers read this straight off `page.url` inside a failure branch, where a + probe error must never displace the real failure. Anything unparseable — or + not even a string — is None, exactly like its sibling `flow_host_kind`.""" + assert flow_landing_kind(url) is None diff --git a/tests/e2e/test_landing_state_diagnosis_bdd.py b/tests/e2e/test_landing_state_diagnosis_bdd.py new file mode 100644 index 00000000..e651a1ac --- /dev/null +++ b/tests/e2e/test_landing_state_diagnosis_bdd.py @@ -0,0 +1,208 @@ +"""E2E for landing-state diagnosis (#756, #773, the 2026-09-10 RED canary). + +Binds ``tests/features/landing_state_diagnosis.feature``. The Gherkin's ``@e2e`` +tags become pytest markers via pytest-bdd, so this file is selected by ``-m e2e`` +and ``-m e2e_auth`` exactly like a hand-written e2e — see +``docs/E2E_TESTING.md`` § BDD-bound e2e. + +**Why an e2e and not a unit test.** The thing under test is what a real page's +URL *is* after a real client-side redirect. `goto` returns before that redirect +runs (issue #639), which is the trap this fix has to survive; a mocked page whose +`url` is whatever the test assigned cannot express it, and would pass against a +fix that reads the URL at the wrong moment. Only a real ``Page`` navigating for +real can falsify that. + +**Cost: zero.** Every Flow origin is served by Playwright route interception, so +nothing reaches Google, no credit is spent, and no authenticated profile is +needed — the same harness as ``test_account_chooser_e2e.py``. The HTML stands in +for Flow's markup, so this proves the *mechanism* (land somewhere unexpected → +say where, and do not blame the anchor), not that `/about` is still Flow's +redirect target. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest +from playwright.async_api import Route, async_playwright +from pytest_bdd import given, scenarios, then, when + +from gflow_cli.api.transports.migrated_composer import MigratedComposer +from gflow_cli.api.transports.ui_automation import UiAutomationTransport +from gflow_cli.errors import AuthExpiredError, FlowAppError, UiSelectorDriftError, is_retryable + +scenarios("../features/landing_state_diagnosis.feature") + +PROJECT_ID = "e2e-landing-project" +PROJECT_URL = f"https://flow.google.com/project/{PROJECT_ID}" +ABOUT_URL = "https://flow.google.com/about" +LABS_GALLERY = "https://labs.google/fx/tools/flow?hl=en" +LABS_SIGNIN = "https://labs.google/fx/api/auth/signin?error=Callback" + +# Flow's hop is client-side (spike 2026-09-04): `goto` returns on +# domcontentloaded and the redirect runs after. Reproducing it as a script — +# not as an HTTP 302 — is what makes this test able to fail the #639 way. +_REDIRECT_HTML = "" +_BARE_HTML = "

{}

" + + +@pytest.fixture +def world() -> dict[str, Any]: + return {} + + +async def _serve(page: Any, pages: dict[str, str]) -> None: + """Serve each URL prefix from local HTML; anything else gets a bare page.""" + + async def _handler(route: Route) -> None: + url = route.request.url + body = next((html for prefix, html in pages.items() if url.startswith(prefix)), None) + await route.fulfill( + status=200, + content_type="text/html", + body=body if body is not None else _BARE_HTML.format("unrouted"), + ) + + for host in ("https://flow.google.com/**", "https://labs.google/**"): + await page.route(host, _handler) + + +async def _drive(pages: dict[str, str], start: str, run: Any) -> BaseException | None: + """Launch a real browser, serve `pages`, navigate to `start`, run `run(page)`.""" + async with async_playwright() as pw: + browser = await pw.chromium.launch(headless=True) + try: + page = await (await browser.new_context()).new_page() + await _serve(page, pages) + await page.goto(start, wait_until="domcontentloaded") + try: + await run(page) + except BaseException as exc: # noqa: BLE001 - the failure IS the assertion + return exc + return None + finally: + await browser.close() + + +# --------------------------------------------------------------------------- given + + +@given("a real browser whose Flow requests are served locally, spending nothing") +def _harness(world: dict[str, Any]) -> None: + world["pages"] = {} + + +@given("a project URL on the migrated host") +def _project_url(world: dict[str, Any]) -> None: + world["start"] = PROJECT_URL + + +@given("the labs Flow gallery URL") +def _gallery_url(world: dict[str, Any]) -> None: + world["start"] = LABS_GALLERY + + +# ---------------------------------------------------------------------------- when + + +@when("Flow redirects it to its public /about landing page") +def _redirect_to_about(world: dict[str, Any]) -> None: + world["pages"] = { + ABOUT_URL: _BARE_HTML.format("Flow"), + PROJECT_URL: _REDIRECT_HTML.format(ABOUT_URL), + } + world["error"] = asyncio.run( + _drive( + world["pages"], + world["start"], + lambda page: MigratedComposer().ensure_editor(page, PROJECT_ID, timeout_s=2.0), + ) + ) + + +@when("Flow serves the project page but the settings trigger never appears") +def _project_without_trigger(world: dict[str, Any]) -> None: + world["pages"] = {PROJECT_URL: _BARE_HTML.format("editor, minus the trigger")} + world["error"] = asyncio.run( + _drive( + world["pages"], + world["start"], + lambda page: MigratedComposer().ensure_editor(page, PROJECT_ID, timeout_s=2.0), + ) + ) + + +@when("Flow answers it with a NextAuth sign-in error page") +def _signin_error(world: dict[str, Any]) -> None: + world["pages"] = { + LABS_SIGNIN: _BARE_HTML.format("Sign in"), + LABS_GALLERY: _REDIRECT_HTML.format(LABS_SIGNIN), + } + world["error"] = asyncio.run( + _drive( + world["pages"], + world["start"], + lambda page: UiAutomationTransport()._enter_editor(page), # noqa: SLF001 + ) + ) + + +# ---------------------------------------------------------------------------- then + + +@then("the failure names the landing page and the project it did not open") +def _names_landing(world: dict[str, Any]) -> None: + error = world["error"] + assert isinstance(error, FlowAppError), f"expected FlowAppError, got {error!r}" + assert "/about" in str(error), str(error) + assert PROJECT_ID in str(error), str(error) + + +@then("the failure is not reported as selector drift") +def _not_drift(world: dict[str, Any]) -> None: + error = world["error"] + assert not isinstance(error, UiSelectorDriftError), str(error) + assert "settings-trigger-button" not in str(error), str(error) + + +@then("the failure does not assert why the redirect happened") +def _no_unmeasured_cause(world: dict[str, Any]) -> None: + # #756: `gflow auth status` reports the session verified while this happens, + # so naming a cause we have not measured would be a second wrong diagnosis. + text = str(world["error"]).lower() + for claim in ("expired", "signed out", "sign in again", "not authenticated"): + assert claim not in text, f"asserts an unmeasured cause ({claim!r}): {text}" + + +@then("the failure says the session is signed out") +def _says_signed_out(world: dict[str, Any]) -> None: + error = world["error"] + assert isinstance(error, AuthExpiredError), f"expected AuthExpiredError, got {error!r}" + assert "signin" in str(error) or "sign-in" in str(error).lower(), str(error) + + +@then("the failure does not blame the New project anchor") +def _not_the_cta(world: dict[str, Any]) -> None: + assert "New project" not in str(world["error"]), str(world["error"]) + + +@then("the failure is reported as selector drift") +def _is_drift(world: dict[str, Any]) -> None: + error = world["error"] + assert isinstance(error, UiSelectorDriftError), f"expected UiSelectorDriftError, got {error!r}" + + +@then("the failure is not flagged retryable") +def _not_retryable(world: dict[str, Any]) -> None: + """Exit 31's class default IS retryable — correct for the crash page it was built + for. Whether it is correct for /about could not be measured: the redirect stopped + reproducing on `ci-probe` before the flag could be tested + (docs/superpowers/spikes/2026-09-10-about-redirect-stability.md). This shape + raised exit 23 before, which was already non-retryable, so the raise site must + preserve that rather than let an exit-code change smuggle in a retry claim. + """ + error = world["error"] + assert isinstance(error, FlowAppError), f"expected FlowAppError, got {error!r}" + assert is_retryable(error) is False diff --git a/tests/features/landing_state_diagnosis.feature b/tests/features/landing_state_diagnosis.feature new file mode 100644 index 00000000..803eaf8c --- /dev/null +++ b/tests/features/landing_state_diagnosis.feature @@ -0,0 +1,37 @@ +@e2e @e2e_auth +Feature: A known landing state is named, never reported as selector drift + Flow can answer a navigation with a page that is not the one we asked for — its + public /about landing, or a NextAuth sign-in error on the app's own origin. The + readiness wait then times out on an anchor that was never going to be there, and + blames the anchor. That sends the operator to "check for a newer release, then + file a bug" over a session state no release changes. + + flow_host_kind() cannot see this: it classifies the ORIGIN, and /about, the + project page and the sign-in error page all share one. Reported at three sites + (#756, #773, the 2026-09-10 RED canary) and special-cased three times before + (#721 credits, #749 agent mode, FlowAppError's crash page). + + Background: + Given a real browser whose Flow requests are served locally, spending nothing + + @e2e @e2e_auth + Scenario: the migrated editor is answered with Flow's public landing page + Given a project URL on the migrated host + When Flow redirects it to its public /about landing page + Then the failure names the landing page and the project it did not open + And the failure is not reported as selector drift + And the failure does not assert why the redirect happened + And the failure is not flagged retryable + + @e2e @e2e_auth + Scenario: the labs gallery is answered with a NextAuth sign-in error + Given the labs Flow gallery URL + When Flow answers it with a NextAuth sign-in error page + Then the failure says the session is signed out + And the failure does not blame the New project anchor + + @e2e @e2e_auth + Scenario: an ordinary missing anchor is still reported as selector drift + Given a project URL on the migrated host + When Flow serves the project page but the settings trigger never appears + Then the failure is reported as selector drift diff --git a/tests/features/test_e2e_binding_guard.py b/tests/features/test_e2e_binding_guard.py index 00fa78f3..dbe95f15 100644 --- a/tests/features/test_e2e_binding_guard.py +++ b/tests/features/test_e2e_binding_guard.py @@ -14,6 +14,9 @@ 3. **Untagged live binding** — a feature bound from ``tests/e2e/`` but *not* tagged carries no `e2e` marker, so `addopts` does not exclude it and hosted CI runs it: Chrome launch, no profile, red for a reason nobody can read. +4. **Double binding** — one feature bound from BOTH directories runs every + scenario twice. This is the rule `docs/E2E_TESTING.md` states and, until the + council pointed it out, the only one nothing enforced (D12). Static text checks only. Proving these tests **pass** is a different job, done on a machine with a warm profile (`scripts/canary/`), never in hosted CI. @@ -55,10 +58,15 @@ def _tags(feature: Path) -> set[str]: def _binders(feature_name: str, search_dir: Path) -> list[Path]: - """Modules under `search_dir` whose `scenarios(...)` call names this feature.""" + """Modules under `search_dir` whose `scenarios(...)` call names this feature. + + Anchored on the path separator so `driver.feature` is not reported as bound by + `scenarios("../features/migrated_driver.feature")` — no such pair exists today, + and this keeps it that way (council D12). + """ if not search_dir.is_dir(): return [] - call = re.compile(rf"scenarios?\(\s*[\"'][^\"']*{re.escape(feature_name)}[\"']") + call = re.compile(rf"scenarios?\(\s*[\"'][^\"']*[/\"']{re.escape(feature_name)}[\"']") return sorted( path for path in search_dir.rglob("*.py") if call.search(path.read_text(encoding="utf-8")) ) @@ -93,6 +101,20 @@ def test_every_e2e_tagged_feature_declares_a_cost_tier() -> None: ) +def test_no_feature_is_bound_from_both_directories() -> None: + """`docs/E2E_TESTING.md` states "one feature file, one binding module". Bound + from both, pytest-bdd generates the scenarios twice — a live tier would run + twice and an offline one would double-count. This branch is what made + cross-directory binding possible, so it is also what has to guard it.""" + doubled = { + feature.name: [str(p.relative_to(_REPO_ROOT)) for p in binders] + for feature in _feature_files() + if len(binders := _binders(feature.name, _E2E_DIR) + _binders(feature.name, _FEATURES_DIR)) + > 1 + } + assert not doubled, f"feature files bound more than once (scenarios run twice): {doubled}" + + def test_every_feature_bound_from_tests_e2e_is_tagged_e2e() -> None: """The dangerous direction: an untagged live binding runs in hosted CI.""" untagged = [ @@ -114,6 +136,11 @@ def test_the_guard_actually_fires(tmp_path: Path) -> None: proves each one is seen — so a green suite above means "no orphans", not "the check never looked". """ + assert _feature_files(), ( + "the detector scanned zero feature files — every assert-not-empty check above " + "would pass vacuously. Check _FEATURES_DIR resolution." + ) + orphan = tmp_path / "orphan.feature" orphan.write_text("@e2e\nFeature: nobody binds me\n", encoding="utf-8") assert _tags(orphan) == {"e2e"} diff --git a/tests/test_errors_classification.py b/tests/test_errors_classification.py index 8c0ecc91..8eebe668 100644 --- a/tests/test_errors_classification.py +++ b/tests/test_errors_classification.py @@ -156,3 +156,124 @@ def test_classify_content_safety_handles_multiple_details() -> None: } ) assert classify_content_safety(body) == "PUBLIC_ERROR_UNSAFE_GENERATION" + + +class TestPerInstanceRetryability: + """`FlowAppError.retryable` overrides the class answer for one raise site. + + It exists because `FlowAppError` (exit 31) now covers two shapes with different + retry semantics: Flow's client-side crash page, where a retry genuinely works, + and its `/about` redirect (#756), where retryability is UNMEASURED — the redirect + stopped reproducing on `ci-probe` between 2026-09-08 and 2026-09-10 + (docs/superpowers/spikes/2026-09-10-about-redirect-stability.md). One flag for + both would have made the class answer an assertion nobody checked. + """ + + def test_class_answer_is_unchanged_when_no_override(self) -> None: + from gflow_cli.errors import FlowAppError, is_retryable + + assert is_retryable(FlowAppError(detail="the React error boundary rendered")) is True + + def test_instance_override_wins(self) -> None: + from gflow_cli.errors import FlowAppError, is_retryable + + assert is_retryable(FlowAppError(detail="/about", retryable=False)) is False + + def test_only_flow_app_error_carries_the_override(self) -> None: + """Scoped to the one class that needs it (council D14). + + A base-class field would sit on every error in the project to serve one + raise site. `is_retryable` reads it by `getattr`, so narrowing costs nothing + — and this test is what makes the narrowing visible if someone widens it back + without a second producer to justify it. + """ + from gflow_cli.errors import UiSelectorDriftError, is_retryable + + assert is_retryable(UiSelectorDriftError(detail="drift")) is False + with pytest.raises(TypeError): + UiSelectorDriftError(detail="drift", retryable=True) # type: ignore[call-arg] + + def test_the_signin_landing_raises_auth_expired(self) -> None: + """The `"signin"` arm, which only the e2e reached before — and `addopts` + excludes that, so the offline suite never executed this branch (council D4). + + Also pins the redaction: the NextAuth family includes the OAuth callback, + whose query carries `code=` and `state=`. This message is what users paste + into issues (council D3). + """ + from gflow_cli.api.transports._common import raise_if_known_landing + from gflow_cli.errors import AuthExpiredError + + url = "https://labs.google/fx/api/auth/callback/google?state=s3cr3t&code=4/0Aabc" + page = type("P", (), {"url": url})() + with pytest.raises(AuthExpiredError) as exc_info: + raise_if_known_landing(page, requested="the Flow gallery", at="test") + + detail = str(exc_info.value) + assert "https://labs.google/fx/api/auth/callback/google" in detail + assert "code=" not in detail and "state=" not in detail and "s3cr3t" not in detail + assert "sign-in page" not in detail, "the family includes callback and /session" + + def test_a_midrun_chooser_hop_raises_the_chooser_error_not_drift(self) -> None: + """Measured live, not imagined (2026-09-10, `denon82`): a session can land on + `accounts.google.com` AFTER bootstrap, where `client._handle_account_chooser` + no longer runs — and the labs gallery sweep then reported a missing + "+ New project" CTA on Google's sign-in page, with the OAuth `state` and + `code_challenge` interpolated into the message. + """ + from gflow_cli.api.transports._common import raise_if_known_landing + from gflow_cli.errors import EXIT_CODE_MAP, FlowAccountChooserError + + url = ( + "https://accounts.google.com/v3/signin/accountchooser" + "?client_id=365941595420-x.apps.googleusercontent.com&state=PKOA6qjxDh" + "&code_challenge=rNzAdlPk4Ed" + ) + page = type("P", (), {"url": url})() + with pytest.raises(FlowAccountChooserError) as exc_info: + raise_if_known_landing(page, requested="the Flow gallery", at="test") + + detail = str(exc_info.value) + assert "accounts.google.com/v3/signin/accountchooser" in detail + assert "state=" not in detail and "code_challenge" not in detail + assert "New project" not in detail + assert EXIT_CODE_MAP[FlowAccountChooserError] == 38 + + def test_the_about_landing_is_not_flagged_retryable(self) -> None: + """The raise site itself, not just the constructor. + + Pins the non-claim: this shape raised exit 23 before (already non-retryable), + so routing it to exit 31 must not quietly flip consumers into retrying it. + """ + from gflow_cli.api.transports._common import raise_if_known_landing + from gflow_cli.errors import EXIT_CODE_MAP, FlowAppError, is_retryable + + page = type("P", (), {"url": "https://flow.google.com/about"})() + with pytest.raises(FlowAppError) as exc_info: + raise_if_known_landing(page, requested="project abc", at="test") + + assert is_retryable(exc_info.value) is False + assert EXIT_CODE_MAP[FlowAppError] == 31 + + def test_a_truthy_non_bool_override_does_not_flip_the_class_answer(self) -> None: + """`is_retryable` pins the override with `isinstance(..., bool)`, not truthiness. + + A `MagicMock` answers every `getattr` with a truthy child mock. Under a + truthiness test that child would read as "retryable: yes" for any object + carrying it, and no assertion in the suite would notice (memory + `magicmock-truthy-getattr-silences-guards`). + + Deliberately a BARE mock, not `spec=FlowAppError`: a spec'd mock satisfies + `isinstance(exc, RETRYABLE_ERRORS)`, so the class answer is `True` anyway and + the guard becomes unobservable through it. Bare, the class answer is `False`, + so the truthy child is the only thing that could flip it — which makes this a + real test of the guard rather than a test that agrees with itself by accident. + """ + from unittest.mock import MagicMock + + from gflow_cli.errors import is_retryable + + mock_exc = MagicMock() + assert not isinstance(mock_exc.retryable, bool), "precondition: not a bool" + assert bool(mock_exc.retryable) is True, "precondition: but it IS truthy" + assert is_retryable(mock_exc) is False diff --git a/tests/test_marker_registry.py b/tests/test_marker_registry.py index 696d159e..ada0a58c 100644 --- a/tests/test_marker_registry.py +++ b/tests/test_marker_registry.py @@ -19,6 +19,7 @@ from __future__ import annotations import pathlib +import re import sys import types from typing import Any @@ -42,10 +43,33 @@ # --------------------------------------------------------------------------- +#: A BDD-bound e2e module declares no `pytest.mark.*` at all — pytest-bdd derives its +#: markers from the Gherkin tags at collection time. Reading Python source alone +#: therefore sees nothing and reports a correctly-tiered file as untiered. Resolving +#: the tags keeps the Gherkin the SINGLE source of truth: duplicating the tier into a +#: `pytestmark` would satisfy this check and then drift the moment a Feature is +#: retagged. See docs/E2E_TESTING.md § BDD-bound e2e. +_SCENARIOS_CALL = re.compile(r"scenarios?\(\s*[\"']([^\"']+)[\"']") +_TAG_LINE = re.compile(r"^\s*@[\w@\s]+$") + + def _collect_e2e_test_files() -> list[pathlib.Path]: return sorted(_E2E_TEST_DIR.glob("test_*.py")) +def _bound_feature_tags(test_file: pathlib.Path) -> list[str]: + """Gherkin tags of every feature file this module binds via ``scenarios(...)``.""" + tags: list[str] = [] + for ref in _SCENARIOS_CALL.findall(test_file.read_text(encoding="utf-8")): + feature = (test_file.parent / ref).resolve() + if not feature.is_file(): # pragma: no cover - a broken bind fails at collection + continue + for line in feature.read_text(encoding="utf-8").splitlines(): + if _TAG_LINE.match(line): + tags.extend(token.lstrip("@") for token in line.split() if token.startswith("@")) + return tags + + def _extract_pytestmarks(source: str) -> list[str]: """Return all marker *names* referenced in ``pytestmark`` declarations.""" markers: list[str] = [] @@ -78,14 +102,37 @@ def test_e2e_file_has_cost_sub_marker(test_file: pathlib.Path) -> None: importing test modules. A file-level ``pytestmark`` that covers all tests is the canonical approach; individual ``@pytest.mark.*`` decorators on every function are also accepted. + + A **BDD-bound** e2e module is the third accepted shape: it carries no + ``pytest.mark.*`` at all, because pytest-bdd derives the markers from its + feature file's Gherkin tags. Source text alone cannot see those, so the tags + are resolved through the ``scenarios(...)`` call — which keeps the Gherkin the + one place a tier is written, instead of a ``pytestmark`` copy free to drift. """ source = test_file.read_text(encoding="utf-8") - found = set(_extract_pytestmarks(source)) & _COST_SUB_MARKERS + declared = set(_extract_pytestmarks(source)) | set(_bound_feature_tags(test_file)) + found = declared & _COST_SUB_MARKERS assert found, ( f"{test_file.name} has no cost sub-marker. " - f"Add at least one of {sorted(_COST_SUB_MARKERS)} to pytestmark or " - "individual test functions so callers can filter by cost tier." + f"Add at least one of {sorted(_COST_SUB_MARKERS)} to pytestmark, to " + "individual test functions, or as a Gherkin tag on the feature it binds, " + "so callers can filter by cost tier." + ) + + +def test_bdd_bound_tier_resolution_actually_reads_the_gherkin() -> None: + """Prove the resolution above works, and is not passing for another reason. + + Without this, a BDD module that happened to mention ``pytest.mark.e2e_auth`` in + a docstring would satisfy the check and nobody would learn the tag path is dead. + """ + bdd = _E2E_TEST_DIR / "test_landing_state_diagnosis_bdd.py" + assert bdd.is_file(), "the reference BDD-bound e2e module is missing" + assert not set(_extract_pytestmarks(bdd.read_text(encoding="utf-8"))) & _COST_SUB_MARKERS, ( + "this module is supposed to declare NO Python-level cost marker — if it now " + "does, it is no longer exercising the Gherkin-tag path" ) + assert set(_bound_feature_tags(bdd)) & _COST_SUB_MARKERS == {"e2e_auth"} # --------------------------------------------------------------------------- diff --git a/website/docs/DEBUGGING.md b/website/docs/DEBUGGING.md index c3f9e5c5..27eac83b 100644 --- a/website/docs/DEBUGGING.md +++ b/website/docs/DEBUGGING.md @@ -116,7 +116,15 @@ also shows the recorded lock owner's PID/start-time evidence — advisory only, the kernel lock stays authoritative and nothing is ever reclaimed). Never captured: expected `ContentPolicyError`, ordinary `AuthExpiredError`, -usage/config validation, cancellation (Ctrl-C). Successful commands write +usage/config validation, cancellation (Ctrl-C). **That `AuthExpiredError` exclusion +now covers one more path than it used to:** since +[#756](https://github.com/ffroliva/gflow-cli/issues/756), landing on one of Flow's +OAuth/sign-in routes raises `AuthExpiredError` where it previously raised +`UiSelectorDriftError`, which *is* captured. The change is deliberate — the remediation +is `gflow auth login` either way, and a bundle there would put a DOM dump and a +full-page screenshot of a Google auth surface into the artifact users are prompted to +attach to issues. It is recorded here because swapping a class silently switches +capture off, which is exactly the trap `docs/PROJECT_STATUS.md` records. Successful commands write nothing. At most 3 bundles per command; repeats of the same failure fingerprint increment `suppressed_count` in the manifest instead. diff --git a/website/docs/E2E_TESTING.md b/website/docs/E2E_TESTING.md index 4a8bcea5..5f952ca1 100644 --- a/website/docs/E2E_TESTING.md +++ b/website/docs/E2E_TESTING.md @@ -89,23 +89,30 @@ Gherkin tag into a pytest marker, so a tagged scenario is filtered by the same **The three moving parts:** ```gherkin -# tests/features/account_chooser_landing.feature +# tests/features/landing_state_diagnosis.feature @e2e @e2e_auth # ← tags become pytest markers -Feature: A known landing state is named, not reported as selector drift - Scenario: the OAuth callback error page - Given a profile whose Flow session is authenticated - When the UI transport lands on /fx/api/auth/signin?error=Callback - Then it names the sign-in state, not a missing 'New project' CTA +Feature: A known landing state is named, never reported as selector drift + Scenario: the labs gallery is answered with a NextAuth sign-in error + Given the labs Flow gallery URL + When Flow answers it with a NextAuth sign-in error page + Then the failure says the session is signed out + And the failure does not blame the New project anchor ``` ```python -# tests/e2e/test_account_chooser_landing_bdd.py +# tests/e2e/test_landing_state_diagnosis_bdd.py from pytest_bdd import given, scenarios, then, when -scenarios("../features/account_chooser_landing.feature") +scenarios("../features/landing_state_diagnosis.feature") # step defs here; tests/e2e/conftest.py fixtures (e2e_profile_dir, …) apply ``` +**Feature-level tags propagate to every scenario** — measured on pytest-bdd 8.1, not +assumed: a scenario carrying no tags of its own was still selected by `-m e2e_auth` from +its Feature's tags. So tag the Feature once; per-scenario tags are for narrowing a single +scenario to a different tier (an `@e2e_video` case inside an otherwise `@e2e_auth` +feature), not for repeating the feature's own. + | Rule | Why | |---|---| | Feature file stays in `tests/features/` | one home for Gherkin; the guard scans one directory | @@ -114,10 +121,11 @@ scenarios("../features/account_chooser_landing.feature") | One feature file, one binding module | bound from two modules, every scenario runs twice | **Enforced offline** by `tests/features/test_e2e_binding_guard.py` (no browser, normal -CI): an `@e2e` feature with no binder under `tests/e2e/` fails, so does one with no cost -tier, and so does the dangerous inverse — a feature bound from `tests/e2e/` but left -untagged, which carries no `e2e` marker, escapes `addopts`, and makes hosted CI try to -drive Chrome. +CI), in four directions: an `@e2e` feature with no binder under `tests/e2e/` fails; so +does one with no cost tier; so does the dangerous inverse — a feature bound from +`tests/e2e/` but left untagged, which carries no `e2e` marker, escapes `addopts`, and +makes hosted CI try to drive Chrome; and so does a feature bound from **both** +directories, whose scenarios would run twice. > **What this does and does not prove.** The guard proves the test **exists and is > wired**, and runs anywhere. Proving it **passes** needs a warm profile and a real diff --git a/website/docs/MCP.md b/website/docs/MCP.md index 9c388c00..c7c3e5b2 100644 --- a/website/docs/MCP.md +++ b/website/docs/MCP.md @@ -143,7 +143,12 @@ transport timeout (`TransportTimeoutError`), network blip (`NetworkError`), a dropped browser session (`BrowserSessionClosedError`), a Flow web-app crash (`FlowAppError`), an agentic-cohort flap (`FlowAgentUiError`), an unreachable UI arm (`UiModeUnavailableError`), and a partially-completed sync -(`SyncPartialError`). That list is the whole of `errors.RETRYABLE_ERRORS`. +(`SyncPartialError`). That list is `errors.RETRYABLE_ERRORS`, but it is no longer +the whole answer: `errors.is_retryable` consults the **instance** first, so a raise +site can override its class. One does today — Flow's `/about` redirect raises +`FlowAppError` with `retryable: false`, because whether a retry helps there was +measured and could not be settled ([#756](https://github.com/ffroliva/gflow-cli/issues/756)). +Read the flag off the envelope; never re-derive it from the class list. Everything else (auth, content-policy, configuration, security) is terminal (`retryable: false`): retrying the identical request fails the same way. This diff --git a/website/docs/USAGE.md b/website/docs/USAGE.md index 143fe3de..a9cccf3c 100644 --- a/website/docs/USAGE.md +++ b/website/docs/USAGE.md @@ -1752,7 +1752,7 @@ shell scripts can branch on the failure mode without parsing stderr. | `0` | — | Success | — | | `1` | unhandled exception | Anything not derived from `GFlowError` — **or a deliberate CLI verdict**: `gflow auth status` exits 1 for a dead/unverifiable session | Re-run with `--verbose`; for `auth status` follow the printed hint; file a bug if it persists | | `2` | usage error (Click) | Bad usage / missing arg / profile missing | Standard CLI usage error | -| `3` | `AuthExpiredError` | Session cookies rejected by Flow (401/403) | `gflow auth login --profile ` | +| `3` | `AuthExpiredError` | Session cookies rejected by Flow (401/403), or Flow served one of its OAuth/sign-in routes instead of the page gflow asked for ([#756](https://github.com/ffroliva/gflow-cli/issues/756)) | `gflow auth login --profile ` | | `4` | `RateLimitError` | Quota / rate limit hit, exhausted retries | Wait + reduce `GFLOW_CLI_CONCURRENCY` | | `5` | `ContentPolicyError` | Flow rejected the prompt (200 + empty `media[]`) | Soften prompt wording | | `6` | `NetworkError` | Network failure persisted across 3 attempts | Check connectivity | @@ -1780,7 +1780,7 @@ shell scripts can branch on the failure mode without parsing stderr. | `28` | `UiModeUnavailableError` | The Flow UI arm this command required (`--ui-mode`/`GFLOW_CLI_UI_MODE`; `-i` forces agentic for images; **video always requires classic** — no agentic video driver exists) couldn't be reached after a switch attempt; aborted before submitting — no credits spent (issue #299) | Retry (the cohort flaps per load); try another `--profile`; for images you can also relax `GFLOW_CLI_UI_MODE` — for video there is nothing to relax | | `29` | `MentionIndexUnavailableError` | An `@mention` was present but the catalog source needed to resolve it (character entities or media assets) failed to load — distinct from an empty index, which is not an error | Check network connectivity (character source) or `GFLOW_CLI_DB_PATH` / filesystem permissions (media source), then retry | | `30` | `QueueSchemaError` | A `gflow serve`/MCP worker-queue task payload has an unrecognized `schema_version` or fails validation against the typed request DTOs | Usually means gflow-cli was downgraded after a newer version enqueued the task, or the payload was hand-edited; re-enqueue with a compatible version | -| `31` | `FlowAppError` | Flow's web app hit a client-side exception (its error-boundary page rendered instead of the editor) — a transient Flow crash, not a gflow bug | Retry shortly; if it persists, Flow itself is degraded — wait and retry later | +| `31` | `FlowAppError` | Flow did not serve the page gflow asked for. Two shapes: its error-boundary page rendered instead of the editor (a transient client-side crash), or it redirected to `flow.google.com/about` instead of the project ([#756](https://github.com/ffroliva/gflow-cli/issues/756)) | Crash: retry shortly; if it persists, Flow itself is degraded. `/about`: open the project in a browser on that host and confirm this account can reach it — whether a retry helps is [not measured](superpowers/spikes/2026-09-10-about-redirect-stability.md), so gflow does not flag it retryable | | `32` | `ReferenceNotFoundError` | A referenced media NAME is not in this project's picker. Flow indexes a short auto-caption, not the generation prompt, so a prompt used as a reference name never matches | Reference the asset by its media UUID, pass a local file with `--ref`, or check what exists with `gflow data list images` | | `33` | — (`gflow doctor` verdict) | Doctor found warn/fail findings — a successful diagnosis, not an error class | Review the report; see [`gflow doctor`](#gflow-doctor) | | `34` | `SyncPartialError` | `gflow data sync` failed on some projects but succeeded on others — completed writes stay committed | Retryable: re-run the same command; it resumes with what is still nameless (see [`gflow data sync`](#gflow-data-sync)) | From 54f238a10439ba08de9e7175dd0b72a1101f2e49 Mon Sep 17 00:00:00 2001 From: Flavio Oliva Date: Thu, 10 Sep 2026 15:55:40 +0100 Subject: [PATCH 4/6] fix(security): strip OAuth query params from chooser error messages (#777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(security): strip OAuth query params from chooser error messages Found while live-verifying v0.73.0's release gate, in code this release did not touch. `client._handle_account_chooser` interpolates `page.url` verbatim at three raise sites, and Google's auth URLs carry `state`, `code_challenge`, `client_id` and challenge tokens. That text is the artifact users are asked to paste into a GitHub issue. Measured on the real path, not theorised. `gflow image t2i --profile ` against a profile Google had put behind a password challenge: BEFORE exit 38, 5 secret matches - accounts.google.com/v3/signin/challenge/pwd?TL=ACv9tzFkh8ZJ... plus the OAuth state and client_id AFTER exit 38, 0 secret matches, same landing still named `safe_page_url()` joins `flow_host_kind` / `flow_landing_kind` in `_common.py` and keeps scheme+host+path. All FOUR raise sites route through it - the three in client.py and `raise_if_known_landing`, which had been stripping inline. One helper rather than four copies: the council flagged this class of leak in the new code (D3) and it applied identically to the raise site next door, which is the tell that it belonged in a shared function. The landing stays in the message. Knowing WHERE the session stopped is the whole value of the diagnosis; only the credentials are gone. Regression test asserts both halves - the URL is still named AND none of client_id / code_challenge / state= / TL= survive - so a future edit that re-inlines the URL goes red instead of quietly leaking again. Verified: ruff, format, pyright 0 errors; 8 chooser tests, 86 in the affected modules; the live A/B above. * test: cover the new lines SonarCloud flagged (new_coverage 70% < 80%) Zero issues; the gate failed purely on coverage of lines this PR added or changed. Two real gaps, both legitimate: - `safe_page_url` edge branches. I had verified None / "" / non-URL / "https://[bad" by running them in a terminal and reading the output. That is evidence for one person once; it is not a test, so nothing re-checks it and the lines were exactly as untested as the gate said. Every branch is pinned now. - `client.py`'s "no account is recorded in this profile" raise had NO test at all — every chooser test writes an ACCOUNT_FILE first, which is how the line stayed uncovered while the two raise sites beside it were exercised. It is a real path: a profile authenticated before .gflow_account existed, or one whose file was removed, has nothing to auto-select with. No coverage exclusions widened, no assertions weakened — the skill forbids both, and either would turn the gate green while leaving the lines untested. --- CHANGELOG.md | 18 +++++++ src/gflow_cli/api/client.py | 11 ++-- src/gflow_cli/api/transports/_common.py | 24 ++++++++- tests/api/test_bootstrap_chooser.py | 72 +++++++++++++++++++++++++ tests/api/transports/test_common.py | 49 +++++++++++++++++ 5 files changed, 167 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93afce00..6c07ca20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security + +- **Google auth URLs no longer reach user-facing error messages with their query + intact.** `client._handle_account_chooser`'s three raise sites interpolated + `page.url` verbatim, and Google's auth URLs carry `state`, `code_challenge`, + `client_id` and challenge tokens (`TL=…`). That text is the artifact users are + asked to paste into a GitHub issue. + - **Measured, not theorised:** a real `gflow image t2i --profile ` on + 2026-09-10 exited 38 and printed + `accounts.google.com/v3/signin/challenge/pwd?TL=ACv9tzFkh8ZJ…` along with the + OAuth `state` and `client_id`. Re-running the identical command after the fix: + same exit 38, same landing named, **zero** secret matches. + - New `safe_page_url()` in `api/transports/_common.py` keeps scheme+host+path and + drops query+fragment; all four raise sites (the three in `client.py` plus + `raise_if_known_landing`) route through it rather than stripping inline. + - The landing is still named — knowing *where* the session stopped is the whole + value of the message; only the credentials are gone. + ### Fixed - **A known Flow landing page is no longer reported as selector drift** ([#756](https://github.com/ffroliva/gflow-cli/issues/756), and the 2026-09-10 RED diff --git a/src/gflow_cli/api/client.py b/src/gflow_cli/api/client.py index 6bcdbc08..ba3ba1a2 100644 --- a/src/gflow_cli/api/client.py +++ b/src/gflow_cli/api/client.py @@ -67,6 +67,7 @@ await_url_settled, flow_host_kind, raise_if_migrated, + safe_page_url, ) from gflow_cli.api.transports.base import ( FlowTransportStrategy, @@ -839,8 +840,8 @@ async def _handle_account_chooser(self, page: Page) -> bool: if not email: raise FlowAccountChooserError( detail=( - f"Google sign-in/chooser displayed at {url} but no account is recorded " - f"in this profile to auto-select." + f"Google sign-in/chooser displayed at {safe_page_url(url)} but no " + f"account is recorded in this profile to auto-select." ) ) @@ -865,8 +866,8 @@ async def _handle_account_chooser(self, page: Page) -> bool: if count == 0: raise FlowAccountChooserError( detail=( - f"Account chooser displayed at {url} but recorded account '{email}' " - f"was not found among selectable accounts." + f"Account chooser displayed at {safe_page_url(url)} but recorded " + f"account '{email}' was not found among selectable accounts." ) ) @@ -897,7 +898,7 @@ async def _handle_account_chooser(self, page: Page) -> bool: raise FlowAccountChooserError( detail=( f"Clicked recorded account '{email}' on the chooser but the session " - f"did not reach Flow within 30s — it is at {landed}." + f"did not reach Flow within 30s — it is at {safe_page_url(landed)}." ) ) from exc logger.info( diff --git a/src/gflow_cli/api/transports/_common.py b/src/gflow_cli/api/transports/_common.py index 2dcec336..ddfabbc4 100644 --- a/src/gflow_cli/api/transports/_common.py +++ b/src/gflow_cli/api/transports/_common.py @@ -181,6 +181,27 @@ def flow_landing_kind(url: object) -> str | None: return None +def safe_page_url(url: object) -> str: + """A page URL reduced to scheme+host+path — safe to put in a user-facing message. + + Google's auth URLs carry `state`, `code_challenge`, `client_id`, and challenge + tokens (`TL=...`) in the query. Error text is the artifact users are asked to paste + into GitHub issues, so the query and fragment have no business in it. Measured live + on 2026-09-10: a real `gflow image t2i` failure printed all of those. + + Anything unparseable comes back as the empty string rather than raising — this is + only ever called while another failure is already being reported. + """ + text = str(url or "") + try: + parts = urlsplit(text) + except ValueError: + return "" + if not parts.scheme or not parts.netloc: + return text + return f"{parts.scheme}://{parts.netloc}{parts.path}" + + def raise_if_known_landing(page: object, *, requested: str, at: str) -> None: """Replace an about-to-be-raised drift report when the page is a **known landing**. @@ -211,8 +232,7 @@ def raise_if_known_landing(page: object, *, requested: str, at: str) -> None: kind = flow_landing_kind(url) if kind is None: return - parts = urlsplit(url) - safe_url = f"{parts.scheme}://{parts.netloc}{parts.path}" if parts.scheme else url + safe_url = safe_page_url(url) log.info("ui_driver.known_landing", at=at, kind=kind, url=safe_url, requested=requested) if kind == "chooser": # The existing class for "we are at the chooser and cannot proceed" (#763/#764, diff --git a/tests/api/test_bootstrap_chooser.py b/tests/api/test_bootstrap_chooser.py index 73182d38..fcd834c5 100644 --- a/tests/api/test_bootstrap_chooser.py +++ b/tests/api/test_bootstrap_chooser.py @@ -238,3 +238,75 @@ async def _click_moves_to_interstitial(*_args: object, **_kwargs: object) -> Non # The CURRENT url, not the chooser url captured on entry: reporting the entry # url would claim "still on the chooser" for a click that did navigate. assert interstitial in str(exc_info.value) + + +async def test_chooser_error_message_carries_no_oauth_query_params(tmp_path: Path) -> None: + """Google's auth URLs carry `state`, `code_challenge`, `client_id` and challenge + tokens in the query, and this message is the artifact users are asked to paste into + a GitHub issue. + + Measured, not imagined: a real `gflow image t2i --profile denon82` on 2026-09-10 + exited 38 and printed + `accounts.google.com/v3/signin/challenge/pwd?TL=ACv9tzFkh8ZJ...` together with the + OAuth `state` and `client_id`. The raise sites now route the URL through + `safe_page_url`, which keeps scheme+host+path and drops query+fragment. + """ + from gflow_cli.api.client import FlowApiClient + from gflow_cli.profile_store import ACCOUNT_FILE + + profile = tmp_path / "profile_p1" + profile.mkdir() + (profile / ACCOUNT_FILE).write_text("recorded@example.com\n", encoding="utf-8") + + noisy = ( + "https://accounts.google.com/v3/signin/accountchooser" + "?client_id=365941595420-x.apps.googleusercontent.com" + "&code_challenge=rNzAdlPk4Ed_h7i0aIDLCGm6ZN4cAjFgfh_ZarFeUr8" + "&state=PKOA6qjxDhhwJkvwuMMWmPBAp0XlLtDinoP-RwgAz84" + "&TL=ACv9tzFkh8ZJPujsxSa7PrWFaArmMhVj" + ) + client = FlowApiClient(profile_dir=profile) + page, _row = _chooser_page(noisy, row_count=0) + page.get_by_text = MagicMock(return_value=MagicMock(count=AsyncMock(return_value=0))) + + with pytest.raises(FlowAccountChooserError) as exc_info: + await client._handle_account_chooser(page) + + detail = str(exc_info.value) + assert "https://accounts.google.com/v3/signin/accountchooser" in detail, ( + "the landing must still be named — knowing WHERE it stopped is the whole point" + ) + for secret in ("client_id", "code_challenge", "state=", "TL=", "PKOA6qjxDh", "ACv9tzFkh8ZJ"): + assert secret not in detail, f"{secret!r} leaked into a user-pasteable message" + + +async def test_chooser_with_no_recorded_account_raises_and_redacts(tmp_path: Path) -> None: + """A profile with no `.gflow_account` file, landing on a chooser. + + This branch had no test at all — every other chooser test writes an account file + first — which is how the line stayed uncovered while the two raise sites beside it + were exercised. Found by SonarCloud's `new_coverage` gate on the redaction PR. + + It is a real path: a profile authenticated before `.gflow_account` existed, or one + whose file was removed, has nothing to auto-select with. + """ + from gflow_cli.api.client import FlowApiClient + + profile = tmp_path / "profile_p1" + profile.mkdir() # deliberately NO ACCOUNT_FILE + + noisy = ( + "https://accounts.google.com/v3/signin/accountchooser" + "?client_id=365941595420-x.apps.googleusercontent.com&state=PKOA6qjxDh" + ) + client = FlowApiClient(profile_dir=profile) + page, _row = _chooser_page(noisy, row_count=1) + + with pytest.raises(FlowAccountChooserError) as exc_info: + await client._handle_account_chooser(page) + + detail = str(exc_info.value) + assert "no account is recorded" in detail.lower() or "auto-select" in detail + assert "https://accounts.google.com/v3/signin/accountchooser" in detail + for secret in ("client_id", "state=", "PKOA6qjxDh"): + assert secret not in detail, f"{secret!r} leaked into a user-pasteable message" diff --git a/tests/api/transports/test_common.py b/tests/api/transports/test_common.py index e9876cfd..1d6da272 100644 --- a/tests/api/transports/test_common.py +++ b/tests/api/transports/test_common.py @@ -20,6 +20,7 @@ flow_landing_kind, interpret_response, mint_batch_id, + safe_page_url, ) from gflow_cli.errors import ( AuthExpiredError, @@ -383,3 +384,51 @@ def test_total_by_construction(self, url: object) -> None: probe error must never displace the real failure. Anything unparseable — or not even a string — is None, exactly like its sibling `flow_host_kind`.""" assert flow_landing_kind(url) is None + + +class TestSafePageUrl: + """`safe_page_url` is what keeps credentials out of user-pasteable error text. + + Google's auth URLs carry `state`, `code_challenge`, `client_id` and challenge + tokens in the query, and a real `gflow image t2i` printed all of them on + 2026-09-10 before this existed. Every branch is pinned here: the helper runs + while another failure is already being reported, so it must never raise. + """ + + def test_strips_query_and_fragment_but_keeps_the_landing(self) -> None: + url = ( + "https://accounts.google.com/v3/signin/challenge/pwd" + "?TL=ACv9tzFkh8ZJ&state=PKOA6qjxDh&client_id=365941595420-x#frag" + ) + assert safe_page_url(url) == "https://accounts.google.com/v3/signin/challenge/pwd" + + def test_a_clean_url_is_unchanged(self) -> None: + assert safe_page_url("https://flow.google.com/about") == "https://flow.google.com/about" + + def test_keeps_the_path_when_there_is_no_query(self) -> None: + url = "https://flow.google.com/project/abc-123" + assert safe_page_url(url) == url + + @pytest.mark.parametrize( + ("value", "expected"), + [ + (None, ""), + ("", ""), + # Not a URL at all: returned verbatim, because a caller printing "the page + # is at " is still more useful than an empty + # string, and there is no query to strip. + ("not a url", "not a url"), + ("about:blank", "about:blank"), + ], + ) + def test_degenerate_inputs(self, value: object, expected: str) -> None: + assert safe_page_url(value) == expected + + def test_unparseable_url_returns_empty_rather_than_raising(self) -> None: + """`urlsplit("https://[bad")` raises ValueError. This helper is only ever + called while another failure is being reported, so a probe error here would + displace the real one.""" + assert safe_page_url("https://[bad") == "" + + def test_non_string_input_is_coerced_not_crashed(self) -> None: + assert safe_page_url(12345) == "12345" From 1eb62a6e6a9d4c1f19c35bf65c7d1e8c0dec8d4a Mon Sep 17 00:00:00 2001 From: Flavio Oliva Date: Thu, 10 Sep 2026 17:40:37 +0100 Subject: [PATCH 5/6] fix(migrated): say what was true when a click never lands (#776) (#778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * spike(776): measure whether the migrated settings trigger is ever visible-but-unclickable 0/3 on the overlay hypothesis — body{pointer-events:none} never occurred and the click landed in 65-130ms every run, so this settles nothing about #776 and is recorded as unmeasured, not as transience. Three things it did measure: - _dismiss_dialog runs before Angular mounts the composer, 3/3 (trigger appears 1479-3143ms after domcontentloaded, the check fires at ~0ms). The miss is structural, not flaky. - a healthy click on this control costs 65-130ms against a 5000ms budget, so #776's expiry is a control that never became actionable. - the locale-settle error the reporter asked about reproduced on all 3 runs while the click still landed - it is #643, and it is not sufficient to cause #776. Rung 1 also found #593's pointer-events measurement was taken on labs.google; migrated_composer.py:744 calling the migrated dialog '#593's twin' is asserted, not measured. Refs #776 * fix(migrated): say what was true when a click never lands (#776) video r2v on flow.google.com reached migrated.editor_ready and died 5.039s later as a bare Playwright TimeoutError - exit 1, no locator, no cause, no MP4. By elimination that is _open_pane's trigger.click(timeout=5000): the wait_for(visible) one line above IS guarded and would have raised exit 23, so the control was visible and the click expired. #752 finding #7 predicted this at this exact function before #776 was filed. Its count()->visibility half was fixed; the click half was not, leaving a comment that describes the failure the next line went on producing. It reads, it does not diagnose. Two causes were live and neither could be measured: Flow's announcement overlay (#593, measured on labs.google, never on this host - 0/3 in today's spike) and a mid-run agent-mode flip. A guard built on either would answer confidently and be wrong half the time (#770 is the precedent). So on a timeout the driver reads Playwright's four actionability conditions back and reports the ones that fired; when every reading is healthy it says so, which eliminates three and leaves 'stable' rather than inventing a fourth. - costs nothing when healthy: the read runs only in the except branch, the rule raise_if_known_landing already states - MCP gains more than the CLI: a non-GFlowError on the queued path shipped 'detail: sha256:...'; the typed error routes it to the Problem Details branch - four sites with a named reason each, not all nineteen - the occluder report is a closed allowlist (tag + <=3 framework class tokens); typing the error moves the text from hashed telemetry to a message printed raw, and a signed-in Flow page carries the account email on exactly those elements - retryable unchanged: preserved, not measured Closes #776 * test+docs(776): cover every reading offline, and fix the docstring the council caught Council D9 found a Blocker I had put on my own predict list and then dropped: UiSelectorDriftError's docstring still said 'finds no matching element', which the new raise site makes false - it now also fires when the selector DOES match and the element will not take the interaction. - errors.py: the class covers two shapes, and the detail has to say which - KNOWN_ISSUES: the lookup table documented 6 of the 8 messages the code can emit; the missing two were 'not rendered' and 'answers no hit test' - E2E_TESTING: a second worked example, chosen because it fails DIFFERENTLY from the first - a client-side redirect vs Playwright's actionability gate - tests: one offline case per reading. CI's coverage run excludes -m e2e, so a branch proven only in the browser reads as dead code to Sonar's new-code gate - the exact way PR #777 went red at 70% - tests/worker: the MCP twin A/B - a bare TimeoutError reaches an agent as 'detail: sha256:...', the typed one as problem details with exit 23 Refs #776 * refactor(776): cut the prose the council measured, and merge one fact told twice Council D14 measured 64.5% of this change's added lines as non-code prose, against the 35% issue #759 already named as a problem in this same file. Fair hit - three of the passages were the CHANGELOG's content retold at the call site, and one was an exact duplicate of the comment 80 lines above it. - _click's docstring: the #593/#752/#770 archaeology is the CHANGELOG's job - the redaction comment above the redact_sensitive_text return restated the module-level one verbatim - the _open_pane call site told the #752 story a third time - SUBMIT_BUTTON's three-line comment for a one-line constant D1 also found a real over-report: the JS only hit-tests when the box has a nonzero width and height, so an unrendered element ALWAYS comes back hit_testable=false and occluder=null. Two independent ifs then said "it is not rendered" and "it answers no hit test" about one fact. Now one chain for "can a pointer reach it", ordered most specific first, with enabled-ness and a page-wide block kept as the separate axes they are - chaining those would have hidden a disabled control behind whatever covered it. Two tests added for the invariants the real JS enforces, which the earlier parametrized cases violated by holding hit_testable=True on an unrendered element. Refs #776 * docs(776): correct a truncation claim that was wrong, and flag a fake that no longer works Council D4 caught a false statement in my own docstring: it said the queued MCP path raw-slices detail to 500 chars "while the CLI path does not". It does. redact_sensitive_text IS redact_error_detail, which truncates at the raise site, so every surface sees the same cap. Locator-first ordering is still right, for the simpler reason that the cut happens at the source - fixed in both the method docstring and the test that repeated it. Also: - tests/worker: _fail_r2v_with enqueues a t2v task. The name described the reporter's command, not the fixture. - tests/api/transports/test_migrated_composer.py defines a local PlaywrightTimeoutError that shadows the real class for that whole 2000-line file. Since #776 the driver catches the REAL one, so a future click-timeout test written with that file's dominant fake would silently exercise nothing and say so nowhere. Documented at the definition, pointing at the file that does it correctly. Refs #776 --- CHANGELOG.md | 34 ++ KNOWN_ISSUES.md | 23 ++ docs/E2E_TESTING.md | 12 + .../PREDICT.md | 174 ++++++++ .../SCENARIO.md | 111 ++++++ .../2026-09-10-migrated-click-blocked.md | 121 ++++++ scripts/dev/spike_migrated_click_blocked.py | 376 ++++++++++++++++++ .../api/transports/migrated_composer.py | 156 +++++++- src/gflow_cli/errors.py | 12 +- .../api/transports/test_click_attribution.py | 274 +++++++++++++ .../api/transports/test_migrated_composer.py | 10 +- tests/e2e/test_click_attribution_bdd.py | 263 ++++++++++++ tests/features/click_attribution.feature | 71 ++++ tests/worker/test_daemon.py | 74 +++- website/docs/E2E_TESTING.md | 12 + website/docs/KNOWN_ISSUES.md | 23 ++ 16 files changed, 1738 insertions(+), 8 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-10-776-attributable-click-timeouts/PREDICT.md create mode 100644 docs/superpowers/plans/2026-09-10-776-attributable-click-timeouts/SCENARIO.md create mode 100644 docs/superpowers/spikes/2026-09-10-migrated-click-blocked.md create mode 100644 scripts/dev/spike_migrated_click_blocked.py create mode 100644 tests/api/transports/test_click_attribution.py create mode 100644 tests/e2e/test_click_attribution_bdd.py create mode 100644 tests/features/click_attribution.feature diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c07ca20..572bd24b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 value of the message; only the credentials are gone. ### Fixed +- **A click that never lands now says what was true instead of nothing at all** + ([#776](https://github.com/ffroliva/gflow-cli/issues/776)). On `flow.google.com`, + `video r2v` reached `migrated.editor_ready` and died 5.039 s later as a bare + Playwright `TimeoutError` — exit 1, no locator, no cause, no MP4. By elimination that + is `migrated_composer.py`'s `trigger.click(timeout=5000)`: the `wait_for(visible)` one + line above it is guarded and would have raised exit 23, so the control was *visible* + and the *click* expired. [#752](https://github.com/ffroliva/gflow-cli/issues/752) + finding #7 predicted exactly this, at exactly this function, before #776 was filed — + its `count()`→visibility half was fixed and the click half was not, leaving a comment + that describes the failure the next line went on producing. + - **It reads; it does not diagnose.** Two causes were live and *neither could be + measured*: Flow's announcement overlay ([#593](https://github.com/ffroliva/gflow-cli/issues/593), + measured on labs.google, never on this host) and a mid-run agent-mode flip. A guard + built on either would answer confidently and be wrong half the time. So on a timeout + the driver reads Playwright's four actionability conditions back — agent chip, + `hidden`/`disabled`, body pointer-events, and a hit-test naming what is on top — and + reports the ones that fired. When every reading is healthy it **says so**, which + eliminates three conditions and leaves *stable*, rather than inventing a fourth. + - **Costs nothing when healthy** — the read runs only in the `except` branch, the rule + `raise_if_known_landing` already states: a guard ahead of the probe deletes the + evidence that would correct it. + - **MCP gains more than the CLI.** A non-`GFlowError` on the queued path shipped + `"detail": "sha256:…"` — a hash, not even the class name. The typed error routes it + to the Problem Details branch instead, so an agent now gets the locator and exit 23. + - Applied to four sites with a named reason each, not all nineteen: the reported one, + the composer click `_close_pane`'s own docstring records as failing this way, and + both credit-spending submits, where a bare timeout left "did it submit?" unanswerable. + - **The occluder report is a closed allowlist** — tag name plus at most three + framework-prefixed class tokens, never `aria-label`, `title`, `src` or `outerHTML`. + Typing the error moves the text from SHA-256-hashed telemetry to a message printed + raw, logged, and invited into a GitHub issue; a signed-in Flow page carries the + account email and signed media URLs on exactly the elements that occlude things. + - `retryable` is unchanged and **preserved, not measured** — the condition did not + reproduce, and a flag that moves as a side effect of retyping is a claim nobody made. - **A known Flow landing page is no longer reported as selector drift** ([#756](https://github.com/ffroliva/gflow-cli/issues/756), and the 2026-09-10 RED nightly canary). `flow_host_kind()` classifies the *origin*; `/about`, diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index f098e2b6..7e7aed0e 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -1237,6 +1237,29 @@ now names which of three things happened rather than blaming drift: **On 0.71.0 and earlier there is no recovery.** Open the project on `flow.google.com`, click the **Agent** chip off, and the account works again. +**Follow-up ([#776](https://github.com/ffroliva/gflow-cli/issues/776)) — the same +confusion survived one gate later, on the *click*.** The table above covers the readiness +*wait*. A control that passes that wait and then refuses the click used to expire as a bare +Playwright `TimeoutError`: exit 1, no locator, no cause. It now reports what was observed +at the moment it expired, because the cause could not be measured — Flow's announcement +overlay is a labs.google measurement that has never been reproduced on this host, and a +mid-run agent-mode flip is equally consistent with the evidence. + +| The message says | What it means | What to do | +|---|---|---| +| `… did not accept a click … the account is in Flow's agent mode` | the mode flipped after the editor was ready | turn the **Agent** chip off in a browser; re-run | +| `… it is covered by .` | something is stacked over the control — the class names it | dismiss it in a browser; re-run | +| `… the page is accepting no pointer events at all` | an overlay has the whole app blocked (#593's shape) | dismiss it in a browser; re-run | +| `… it carries a bare `hidden` attribute` / `it is disabled` | the control is present but not usable | usually agent mode or a cohort difference; check the Agent chip first | +| `… it is not rendered (display, visibility, or a zero-sized box)` | it is in the DOM but not on screen | as above — check the Agent chip, then file a bug with the log | +| `… it answers no hit test at its own centre` | nothing named itself as the cover, but the click still landed elsewhere | re-run once; if it repeats, file a bug — an overlay outside the document is the usual shape | +| `… it was visible, enabled and hit-testable … most likely still moving` | nothing readable was wrong | Playwright also needs a *stable* box; re-run once. If it repeats, file a bug — this message means we looked and found nothing, which is a real finding worth having | +| `… it could not be read back` | the page changed under the diagnosis | re-run; if it repeats, attach the log | + +The occluder is named by tag plus framework class only. That is deliberate — a signed-in +Flow page carries the account email and signed media URLs on exactly the elements that +tend to occlude things, and this message is printed, logged, and pasted into issues. + ### Auth verification depends on Google's NextAuth session endpoint - **Status:** Mitigated · **Severity:** Low (degrades fail-closed) · **Affects:** issue #15 fix onward · **Tracked:** issue #15 diff --git a/docs/E2E_TESTING.md b/docs/E2E_TESTING.md index 5f952ca1..e06e206b 100644 --- a/docs/E2E_TESTING.md +++ b/docs/E2E_TESTING.md @@ -132,6 +132,18 @@ directories, whose scenarios would run twice. > browser — that is the nightly canary's job (`scripts/canary/`), on a machine that has > one. Hosted CI cannot run the live tiers and never could. +**Two worked examples, deliberately different in kind:** + +| Feature | Binder | What only a browser could prove | +|---|---|---| +| `landing_state_diagnosis.feature` | `test_landing_state_diagnosis_bdd.py` | Flow's hop to `/about` is a **client-side** redirect, so `goto` returns before it runs (#639). A mocked page whose `url` the test assigns cannot fail that way | +| `click_attribution.feature` | `test_click_attribution_bdd.py` | Playwright's **actionability** gate — visible, stable, receives-events, enabled (#776). Each scenario breaks a different one *for real*: a stacked `div` that intercepts pointers, and a CSS animation that never lets the box settle while visibility and the hit test stay healthy | + +Both are route-intercepted and cost **$0** — real Chromium, `page.route(...).fulfill(...)`, +no Google, no profile, no credits. That combination is what makes a browser-only scenario +cheap enough to be non-negotiable: if a scenario needs a browser, the answer is an e2e +test, not a mocked proxy — the Bug Lane's step 5. + --- ## Environment variables diff --git a/docs/superpowers/plans/2026-09-10-776-attributable-click-timeouts/PREDICT.md b/docs/superpowers/plans/2026-09-10-776-attributable-click-timeouts/PREDICT.md new file mode 100644 index 00000000..aa6930ec --- /dev/null +++ b/docs/superpowers/plans/2026-09-10-776-attributable-click-timeouts/PREDICT.md @@ -0,0 +1,174 @@ +# Predict: attribute the migrated driver's click timeouts (#776) + +## Verdict on the proposal as submitted: **STOP** +**Confidence: 5.2/10** (mean 7.0, −2 Devil's Advocate found a simpler path the others missed; any STOP is a STOP) + +## Verdict on the revised proposal below: **CAUTION → proceed with mitigations** + +## Summary + +The proposal was: *port #593's overlay guard to the migrated driver, add a shared +`body{pointer-events:none}` + hit-test probe to `_common.py`, and call it pre-click.* + +Four personas returned GO/CAUTION on the mechanics. The Devil's Advocate returned STOP on +the **premise**, and it is right: the proposal picks a cause. The live spike run the same +hour independently agrees — the overlay mechanism is **unmeasured on this host** (0/3, +[`2026-09-10-migrated-click-blocked.md`](../../spikes/2026-09-10-migrated-click-blocked.md)). +A guard built on an unmeasured cause does not just fail to fire; it produces a +**confidently wrong** message, which #770 is already a live precedent for. + +## Persona findings + +### Architect — GO (8/10) +`_common.py` is the right home, and for a load-bearing reason nobody had stated: there is +an **existing import cycle** — `ui_automation.py:44` imports `migrated_composer`, and +`migrated_composer.py:1921` imports back with `# noqa: PLC0415 - cycle`. So the migrated +driver *cannot* top-level-import from `ui_automation.py`; `_common.py` has zero +intra-`transports` imports and is the only acyclic leaf both drivers already reach. +Recommends a `@staticmethod async def _click(...)` on `MigratedComposer` over a decorator, +matching `_dismiss_dialog`'s existing shape. Warns explicitly against harmonising the labs +driver's clicks in the same PR. + +### Security / reCAPTCHA — CAUTION (8/10) +The strongest finding of the five. **Converting a bare `TimeoutError` into a typed error +removes an accidental privacy net.** Verified in source: `_handle_unhandled_error` +(`_cli_helpers.py:324`) prints a generic message and SHA-256-hashes the telemetry, while +`_handle_gflow_error` (`_cli_helpers.py:304`) prints `exc.detail` **raw**, and +`json_output.py:55` ships it verbatim under `--json`. `redact_error_detail` is wired only +at the SQLite boundary — **not** on the console, structlog, or `--json` paths. + +So any DOM text this fix puts in `detail` is guaranteed to be printed, logged, and (by the +class's own remediation hint) invited into a GitHub issue. An occluding element can carry +an account email in `aria-label`/`title`, or a signed media URL in `src`. This is the exact +class of bug PR #777 fixed two hours ago. + +> **Mandatory:** the occluder report must be a **closed allowlist** — tag name plus a match +> against a fixed set of structural overlay markers. Never `outerHTML`, `textContent`, +> `aria-label`, `title`, `alt`, `src`, `href`, or an attribute dump. Any free-form DOM +> string must pass `redact_sensitive_text()` at the raise site. + +### Performance / Playwright — GO with a scope correction (8/10) +"Pre-click at modal-prone epochs" is ambiguous and the two readings differ by 4×: a real +r2v run makes **~16** clicks, while the labs guard it is modelled on runs at exactly **3** +sites. Worse, `_require_unblocked` has no de-duplication — on a genuinely blocked page each +call independently re-probes, waits ~1 s of jitter, re-attempts dismissal and re-probes, so +8+ pre-emptive sites would add 16–24 s of redundant latency before finally raising. No Page +pool or `__aexit__` risk: every `_checkout_page` is `try/finally`-paired. + +### CLI / MCP UX — CAUTION (8/10) +Three findings that change the implementation: + +**Exit 23 is right; do not mint a new code.** #593 already raises `UiSelectorDriftError` +for "an overlay is still covering the app" (`ui_automation.py:1345`). The project's own bar +for a new code is a *materially different caller action* (`errors.py:562`, `:594`), and +"dismiss the modal and re-run" is not different from 23's existing remediation. One +docstring line should acknowledge that the class covers *occluded*, not only *missing* — +#593 stretched it there already and the docs never caught up. + +**MCP is currently worse than the CLI, and this fix is the whole repair.** On the queued +path a non-`GFlowError` hits `worker/daemon.py:441-475`'s `else` branch, which ships +`"detail": f"sha256:{exception_message_hash(exc)}"` — a hash, not even the class name. Once +the raise site becomes a `GFlowError`, `daemon.py:449`'s `isinstance` branch fires instead +and the agent gets full problem details plus `exit_code=23`. Same transport, one fix, both +doors — but it must be *run* on the MCP path, not inferred. + +**The reporter may have seen nothing at all in `--json`.** `unexpected_payload()` +(`json_output.py:83`) emits no detail and no exception class without a debug flag, so the +`exception_class=TimeoutError` they quoted came from the **stderr structlog** event, not +stdout. An adapter reading only stdout got a bare failure. Worth telling them. + +It also flagged, independently of the Devil's Advocate, that a pre-click guard contradicts +a rule this codebase already learned: `_common.py:205-221` — *"Call this from inside a +failure branch … never before it … a guard placed ahead of the probe deletes the evidence +that would correct it."* + +### Devil's Advocate — STOP (3/10) +**Found the thing that changes the design.** [#752 finding #7](https://github.com/ffroliva/gflow-cli/issues/752), +a maintainer-authored review written *before* #776 was filed, predicts this exact symptom +at this exact function: + +> `_open_pane` still guards with `count()`, not visibility … a mode flip between +> `ensure_editor` and `apply_video_settings` escapes as a **bare Playwright TimeoutError +> with no exit-23 mapping and no mention of agent mode**. + +Half of that was fixed — `:870` became `wait_for(state="visible")`, and its comment at +`:866-869` spells the failure out. **The very next line, `:884`, is the click, still +unguarded.** The file documents the bug it still has, one line above it. + +Agent mode hides the trigger with a bare `hidden` attribute — it never touches +`body{pointer-events:none}`. So the proposed probe would return "not blocked" and the fix +would report the wrong cause. + +## High-confidence risks (2+ personas) + +1. **The proposal picks a cause it cannot see.** (Devil's Advocate STOP; Security Finding 4 + caveat; the spike's 0/3.) Playwright's actionability gate has four conditions — visible, + stable, receives-events, enabled. A body-`pointer-events` probe speaks to exactly one. +2. **A wrong typed message is worse than an honest bare one.** (Devil's Advocate; Security + Finding 2.) #770 is the live precedent. +3. **Blanket-converting 18 sites collides with open #759**, which was filed against this + very file for narrative duplication. (Devil's Advocate; Architect's scope-creep warning.) + +## Conflicts resolved + +- **Performance says "pre-emptive at epochs"; Devil's Advocate says "don't build the guard at all."** + Resolved in favour of the Devil's Advocate, on evidence Performance did not have: the spike + measured `body_pointer_events: auto` in **159/159** samples — including *while the settings + pane was open*. Angular CDK blocks with a `.cdk-overlay-backdrop` element, not by muting the + body, so on this host the **hit-test is the load-bearing detector and the body property is + the labs mechanism**. A pre-emptive body probe here would guard a mechanism this frontend + does not appear to use. +- **Architect says extract to `_common.py`; Devil's Advocate says that is a bigger structural + change than it looks.** Both hold: extraction is right *if* something shared is needed. Under + the revised proposal the read is migrated-host-specific and single-caller, so it stays local + until a second caller exists. The Architect's cycle finding remains the constraint if that + changes. + +## The revised proposal + +**Do not guess the cause. Read it, at the moment of failure, and report what was true.** + +1. One `_click` helper on `MigratedComposer`. On a Playwright timeout it performs a + post-mortem read and raises `UiSelectorDriftError` (exit 23) naming the locator and the + condition that actually failed: + - the agent-mode chip (`_agent_chip_pressed`, already exists at `:684`) — #752's cause + - `hidden` / `disabled` — the *visible* and *enabled* conditions + - `body{pointer-events}` + an allowlisted hit-test occluder — the *receives-events* condition + - none of the above ⇒ say exactly that; it rules out three and points at *stable* +2. **Zero cost on the happy path** — the read runs only in the `except` branch. +3. Applied to four sites with a named reason each, not eighteen: `:884` (#776's site), + `:1598` (named in `_close_pane`'s own docstring as historically failing this way), and + `:1725` / `:1858` (the credit-spending submits, where "did it submit?" is unanswerable today). + +### Required mitigations before EXECUTE + +1. **Allowlist the occluder report.** Tag name, plus only those classes matching a fixed + structural prefix set (`cdk-`, `mat-`, `mdc-`, `flow-`), each capped — mirroring the + existing `.slice(0, 200)` convention at `ui_automation.py:2472`. Never `outerHTML`, + `textContent`, `aria-label`, `title`, `alt`, `src`, `href`, or a generic attribute dump. + Pass the assembled detail through `redact_sensitive_text()` at the raise site. (Security + + CLI/MCP UX, reconciled: an allowlist *and* a bound.) +2. **Put the locator before the variable-length class blob in the message.** The queued MCP + path raw-slices `detail` to 500 chars (`data/redaction.py:117`) while the CLI path does + not; ordering keeps both surfaces showing the same essential text. (CLI/MCP UX) +3. **No pre-emptive guard, no shared `_common.py` probe** until a second caller or a measured + cause justifies one. Three personas and the spike converged here, and `_common.py:205-221` + already states the rule. (Devil's Advocate, CLI/MCP UX, Performance, spike) +4. **Exit 23, and add the missing docstring line** acknowledging *occluded* alongside + *missing*. No new exit code. (CLI/MCP UX) +5. **Preserve `retryable`.** Today's failure is non-retryable; the condition does not reproduce, + so per the Bug Lane's "A flag is a claim" middle row this is *preserved, not measured*. + `UiSelectorDriftError` is not in `RETRYABLE_ERRORS`, so the default already preserves it — + assert that in a test rather than leaving it to survive by luck. +6. **One helper, not eighteen message blocks** — #759. +7. **Run the MCP twin.** The fix flips `daemon.py:449`'s branch from the hashed `else` to the + `GFlowError` path; that is the larger half of the repair and the Iron Law applies to it + separately. (CLI/MCP UX) +8. **Verify via the raised error and the log line, not the incident bundle** — #722 blanks the + capture on this path. + +## Recommended next step + +Phase 3 — `/gflow:scenario`. The scenario is browser-only (a click that fails Playwright's +actionability gate cannot be expressed by a mocked page), so per the Bug Lane it binds to a +route-intercepted e2e in `tests/e2e/`, tagged `@e2e @e2e_auth`. diff --git a/docs/superpowers/plans/2026-09-10-776-attributable-click-timeouts/SCENARIO.md b/docs/superpowers/plans/2026-09-10-776-attributable-click-timeouts/SCENARIO.md new file mode 100644 index 00000000..7c81c442 --- /dev/null +++ b/docs/superpowers/plans/2026-09-10-776-attributable-click-timeouts/SCENARIO.md @@ -0,0 +1,111 @@ +# Scenario: attributable click timeouts on the migrated composer (#776) + +Feeds from [`PREDICT.md`](PREDICT.md) (STOP on the original proposal → CAUTION on the +revised one) and the spike +[`2026-09-10-migrated-click-blocked.md`](../../spikes/2026-09-10-migrated-click-blocked.md). + +## Coverage map + +| Dim | Active? | Why | +|---|---|---| +| **D3** Selector drift & locale invariance | **Yes — primary** | The whole change is what a failed click reports. The occluder must be named structurally; a translated label would violate AGENTS.md and be useless to a zh-CN reporter (#776 is one) | +| **D7** Error propagation & exit codes | **Yes — primary** | Bare `TimeoutError`/exit 1 → `UiSelectorDriftError`/exit 23. `retryable` must not move as a side effect | +| **D12** Observability | **Yes** | `detail` now reaches console raw, structlog, and `--json`. New event/field names are a contract | +| **D13** MCP parity | **Yes — the larger half** | The queued path currently hashes the detail away entirely (`daemon.py:441-475` `else`). The fix flips it to the `GFlowError` branch | +| **D10** Headless vs headed | **Yes** | The probe runs `page.evaluate` on a real page; must not break when the page is mid-teardown | +| **D8** Cross-platform | Partial | #776 is a Windows report, but the failure is upstream of any path handling. Only console encoding matters, already covered by `cli.py:76-82` | +| D1 auth · D2 WAF · D4 batch · D5 concurrency · D6 data · D9 transport · D11 input | No | The change is confined to one driver's failure branch. No auth, no wire call, no schema, no new input | + +## Scenario table + +| # | Dim | Scenario | Severity | Expected behaviour | Test category | +|---|---|---|---|---|---| +| 1 | D7/D3 | The settings trigger is visible but the click never lands; the **agent-mode chip is pressed** | **Critical** | `UiSelectorDriftError` (23) naming *agent mode*, not an overlay. This is #752 finding #7's predicted cause | E2E (BDD) | +| 2 | D7/D3 | The click never lands because an **element covers** the trigger | **Critical** | `UiSelectorDriftError` (23) naming the occluder by tag + structural class | E2E (BDD) | +| 3 | D7 | The click never lands and **every probe reads healthy** | **High** | The error says exactly that — visible, enabled, hit-testable — instead of inventing a cause. Rules out three of Playwright's four conditions and points at *stable* | E2E (BDD) | +| 4 | D7 | **A/B control** — an unobstructed trigger | **Critical** | The click lands, nothing is raised, no probe runs. Without this, scenarios 1–3 could pass against a helper that always raises | E2E (BDD) | +| 5 | D3/D12 | The occluding element carries an **account-identifying attribute** (`aria-label` with an email, `src` with a signed URL) | **Critical** | Neither appears anywhere in the message. Security persona's mandated regression test; PR #777 was this exact bug class | E2E (BDD) | +| 6 | D7 | `retryable` after the change | **High** | Still `False` — *preserved, not measured* (Bug Lane "A flag is a claim", middle row) | Unit | +| 7 | D12 | The occluder's class list is pathologically long | Medium | Capped client-side so the queued path's 500-char slice (`data/redaction.py:117`) cannot clip the remediation off | Unit | +| 8 | D13 | The same failure over **MCP** | **Critical** | Reaches the agent as RFC 9457 problem details with `exit_code: 23`, not `sha256:…` | E2E (MCP path) | +| 9 | D10 | The page is closed/navigating when the post-mortem read runs | High | The probe returns "unreadable" and the error still raises, naming the locator. A diagnostic must never replace the failure it is describing | E2E (BDD) | +| 10 | D7 | A click failing for a **non-timeout** reason | Medium | Not converted — only an actionability timeout is reinterpreted | Unit | + +## Must-cover before merge (Critical + High) + +1, 2, 3, 4, 5, 6, 8, 9 — i.e. every row above except 7 and 10, which are unit-level guards. + +## Deferred + +- The other 15 bare click sites (`_select`, `_select_model`, the frame picker). Per the + Devil's Advocate and #759, each waits for its own signature rather than a blanket + conversion. The helper exists, so adopting one later is a one-line change. +- Whether Flow's announcement modal reaches the migrated host at all — **unmeasured**, and + the fix is deliberately built not to depend on the answer. + +## Suggested BDD scenarios + +Browser-only by construction: Playwright's actionability gate (attached → visible → stable +→ receives-events → enabled) is what fails, and a mocked `Page` whose `.click()` is a stub +cannot express it. Per the Bug Lane step 5, that makes these e2e. + +```gherkin +@e2e @e2e_auth +Feature: A click that never lands says why + + Scenario: A pressed agent-mode chip is named as the cause + Given a Flow project page whose settings trigger is covered + And the agent-mode chip is pressed + When the driver opens the settings pane + Then it fails with exit 23 + And the message names Flow's agent mode + And the message does not blame an overlay + + Scenario: A covering element is named by its structure + Given a Flow project page whose settings trigger is covered + When the driver opens the settings pane + Then it fails with exit 23 + And the message names the covering element by tag and structural class + + Scenario: A healthy-looking failure is reported as unexplained + Given a Flow project page whose settings trigger accepts no click + When the driver opens the settings pane + Then it fails with exit 23 + And the message reports the control as visible, enabled and hit-testable + And the message does not name a cause it did not observe + + Scenario: An unobstructed trigger still opens the pane + Given a Flow project page whose settings trigger is clickable + When the driver opens the settings pane + Then the pane opens and nothing is raised + + Scenario: An account identifier on the covering element never reaches the message + Given a Flow project page whose settings trigger is covered + And the covering element carries an account email and a signed media URL + When the driver opens the settings pane + Then it fails with exit 23 + And the message contains neither the account email nor the signed URL + + Scenario: A page that cannot be read still reports the failed locator + Given a Flow project page whose settings trigger is covered + And the page stops answering probes + When the driver opens the settings pane + Then it fails with exit 23 + And the message names the settings trigger +``` + +Binding: `tests/e2e/test_click_attribution_bdd.py` via +`scenarios("../features/click_attribution.feature")`. One feature, one module — +`tests/features/test_e2e_binding_guard.py` enforces that offline. + +## Known-issues cross-reference + +| Entry | Relationship | +|---|---| +| [#752](https://github.com/ffroliva/gflow-cli/issues/752) finding #7 | **Predicted this symptom at this function.** The `wait_for` half was fixed; the click was not. Scenario 1 is that finding's regression test | +| [#749](https://github.com/ffroliva/gflow-cli/issues/749) / KNOWN_ISSUES "agent-mode chip hides the settings trigger" | Same mechanism, one gate later | +| [#593](https://github.com/ffroliva/gflow-cli/issues/593) / KNOWN_ISSUES "changelog modal wedges" | The labs precedent. Scenario 2 covers the shape **without** asserting it occurs on this host | +| [#722](https://github.com/ffroliva/gflow-cli/issues/722) | Blanks the incident bundle on this path — verification must lean on the raised error and the log line | +| [#759](https://github.com/ffroliva/gflow-cli/issues/759) | Comment bloat in this file. One helper, not eighteen message blocks | +| [#770](https://github.com/ffroliva/gflow-cli/issues/770) | Live precedent for a typed "most likely" message being wrong. Scenario 3 is the direct countermeasure | +| [#643](https://github.com/ffroliva/gflow-cli/issues/643) | The reporter's locale error. Measured irrelevant by the spike — 3/3 reproduced it while the click landed | diff --git a/docs/superpowers/spikes/2026-09-10-migrated-click-blocked.md b/docs/superpowers/spikes/2026-09-10-migrated-click-blocked.md new file mode 100644 index 00000000..fa793eb3 --- /dev/null +++ b/docs/superpowers/spikes/2026-09-10-migrated-click-blocked.md @@ -0,0 +1,121 @@ +# Is the migrated composer's settings trigger ever visible-but-unclickable? (#776) + +- **Date:** 2026-09-10 +- **Script:** [`scripts/dev/spike_migrated_click_blocked.py`](../../../scripts/dev/spike_migrated_click_blocked.py) +- **Profile / project:** `ci-probe` · `1e4efe0d-…` (migrated host, `flow.google.com`) +- **Cost:** $0 — navigation, DOM reads, one settings-pane click, Escape. No credits, no quota. +- **Raw:** `scripts/dev/_spike_out/spike_migrated_click_blocked_20260910_160220.json` (gitignored) + +## The question + +#776 dies 5.039 s after `migrated.editor_ready` with a bare Playwright `TimeoutError`. +By elimination that is `migrated_composer.py:884` — `await trigger.click(timeout=5000)`, +the only unguarded 5000 ms call in that window. Its sibling one line up +(`wait_for(state="visible")`, `:870`) *is* guarded, so the trigger was **visible** and the +**click** expired. + +#593 measured a mechanism with exactly that shape: an announcement overlay sets +`body { pointer-events: none }`, leaving controls visible **and enabled** but unclickable +(`ui_automation.py:1210`). The guard it produced, `_require_unblocked` +(`ui_automation.py:1313`), is called 4× on the labs path and **0×** on the migrated one. + +So: does that state occur on `flow.google.com`, and is `_dismiss_dialog` timed to miss it? + +## Pre-registered readings + +Written into the script's docstring before the run, so the result could not be respun: + +| Outcome | Reading | +|---|---| +| body blocked ≥1/N | #593's mechanism reaches the migrated host; port the guard | +| trigger `hit_testable:false` ≥1/N | occlusion without a body block; the guard needs the hit-test too | +| replayed click expires | #776 reproduced locally | +| 0/N, click always lands | **does not reproduce here; settles nothing** | + +## What was observed + +3 independent navigations, 159 readable DOM samples at 250 ms. + +| Run | trigger mounts | blocked | occluded | `wait_for(visible)` | click | +|---|---|---|---|---|---| +| 1 | 2194 ms | 0 | 0 | passed (64 ms) | **landed (130 ms)** | +| 2 | 3143 ms | 0 | 0 | passed (33 ms) | **landed (65 ms)** | +| 3 | 1479 ms | 0 | 0 | passed (39 ms) | **landed (70 ms)** | + +`body_pointer_events` was `auto` in **159 of 159** samples. `occluded_by` was `null` +whenever the trigger existed. Zero `.cdk-overlay-pane`, zero `[role='dialog']` at any +point before the click; exactly **1** overlay after it — Flow's own settings pane, which +is the pane `_open_pane` is trying to open. + +## Verdict on the overlay hypothesis: UNMEASURED + +**0/3. This settles nothing about #776**, and per the pre-registration it is not evidence +of transience — it is equally consistent with `ci-probe` never having been served the +announcement (which [`2026-09-05-migrated-frames-attach.md`](2026-09-05-migrated-frames-attach.md) +already noted: that account had dismissed it in a prior session, so the migrated-host +changelog modal has *still* never been captured live). + +The related rung-1 finding matters more than the 0/3: **#593's `pointer-events:none` was +measured on labs.google, not on the migrated host.** `migrated_composer.py:744`'s +description of the migrated dialog as "#593's twin" is **asserted, not measured** — it is +one of the few selector claims in that file with no dated spike behind it. + +**What would settle it:** the block probe running on a profile that has *not* yet +dismissed a Flow announcement, i.e. a first visit after a Flow deployment. That is a state +you cannot summon on demand — which is precisely why the fix must not depend on knowing +which overlay it is. + +## Three things this DID measure + +### 1. `_dismiss_dialog` provably runs before the app exists — 3/3 + +At the instant `_dismiss_dialog` fires (`migrated_composer.py:595`, immediately after +`goto(wait_until="domcontentloaded")`), every run read: + +``` +trigger=0 dialog=0 overlay=0 ready_state=interactive +``` + +The composer mounted **1479–3143 ms later**. So the driver's one and only overlay check +looks at a page Angular has not rendered yet, in every run. It cannot see a dialog that +mounts with the app, and no amount of retrying that call site changes it — the miss is +structural, not flaky. This confirms with numbers what `migrated_composer.py:607` asserts +in prose about the SPA race, and extends it: the race applies to `_dismiss_dialog`, not +just to the agent-chip probe that comment is about. + +### 2. A healthy click on this control costs 65–130 ms + +Two orders of magnitude under the 5000 ms budget. So #776's expiry is not a slow click or +a loaded machine — the element never became actionable at all. The `wait_for(visible)` +that precedes it returned in 33–64 ms, which is why it is `:884` and not `:870`. + +### 3. The locale-settle error is NOT sufficient to cause #776 — falsified + +The reporter asked whether `account_locale_lang_unchanged … reason=Error` is relevant. +All three runs reproduced it: + +``` +client.account_locale_lang_unchanged lang=en reason=TimeoutError waited_ms=4000.0 +``` + +…and the click landed every time. A failed locale settle therefore does not, on its own, +produce this failure. It is real and separately tracked as **#643**; it is not #776's +cause. (Consistent with the locale-invariance rule: `READY_ANCHOR = ".settings-trigger-button"` +is structural, so no locale can hide it.) + +## What this means for the fix + +The confirmed defect in #776 is **unattributability**, and that is independent of what +covers the trigger. A guard built only on `body{pointer-events:none}` would catch one of +Playwright's four actionability conditions (receives-events) and stay silent on the other +three — visible, stable, enabled. Building the fix *around the overlay* would be building +it around the one thing this spike could not measure. + +The durable move is to make the failure name itself: report the locator, and whatever the +page can tell us about why the click did not land, at the moment it did not land. + +## Related + +- [`2026-09-05-migrated-frames-attach.md`](2026-09-05-migrated-frames-attach.md) — the account had already dismissed the changelog; a non-blocking "high demand" banner was present +- [`2026-09-05-migrated-host-wire-protocol.md`](2026-09-05-migrated-host-wire-protocol.md) — `goto` 8.1–11.4 s, settled 11.1–14.7 s; `cdk-overlay-container` absent until the first overlay opens +- [`2026-09-10-about-redirect-stability.md`](2026-09-10-about-redirect-stability.md) — the other 0/N "unmeasured" result this week, same discipline diff --git a/scripts/dev/spike_migrated_click_blocked.py b/scripts/dev/spike_migrated_click_blocked.py new file mode 100644 index 00000000..07c50f88 --- /dev/null +++ b/scripts/dev/spike_migrated_click_blocked.py @@ -0,0 +1,376 @@ +r"""Is the migrated composer's settings trigger ever VISIBLE BUT UNCLICKABLE? ($0) + +Settles the mechanism question behind #776. + +A reporter on Windows / `flow.google.com` reaches `migrated.editor_ready` and then dies +5.039 s later with a bare Playwright `TimeoutError` and no exit code. By elimination that +is `migrated_composer.py:884` — `await trigger.click(timeout=5000)`, the one unguarded +5000 ms call between `editor_ready` and the next log line. Its sibling one line above +(`trigger.wait_for(state="visible", timeout=5000)`, `:870`) IS guarded and would have +raised `UiSelectorDriftError` (exit 23) instead, so the trigger was *visible* and the +*click* is what expired. + + A CLICK THAT DOES NOT LAND IS EVIDENCE ABOUT ACTIONABILITY. + IT IS NEVER EVIDENCE THAT THE CONTROL IS MISSING. + +`ui_automation.py:1210` (`_probe_page_block`, from #593) records the mechanism that +produces exactly this shape, measured live on 2026-08-27: + + while Flow's announcement modal is up the body carries `pointer-events: none` and is + neither `aria-hidden` nor `inert` — so every control reads visible and enabled yet + never receives a click. + +`_require_unblocked` guards against it in the labs driver at four call sites. The migrated +driver (`migrated_composer.py`, PR #664, later than #593's audit) calls it **zero** times, +and its only overlay handling — `_dismiss_dialog`, `:743` — matches `[role='dialog']` +alone and runs immediately after `goto(wait_until="domcontentloaded")`, which the module's +own comment at `:607` says is seconds before Angular mounts the composer. + +So there are two separate unknowns, and this probe measures both: + +1. **Does the block state occur on THIS host?** #593 measured labs.google. Whether + flow.google.com's Angular frontend blocks the body the same way has never been read. +2. **Is the driver's `_dismiss_dialog` timed to miss it?** Sampling from `goto` through + composer mount shows when overlays actually appear relative to when the driver looks. + +## Pre-registered readings — written before the run, so the result cannot be respun + +| Outcome | Reading | +|---|---| +| body `pointer-events:none` observed at/after mount in >=1 of N | the #593 mechanism reaches the migrated host; porting the guard is the fix | +| trigger present+visible but `hit_testable:false` in >=1 of N | occlusion WITHOUT a body block; the guard needs the hit-test too, not just the body probe | +| replayed click expires while the trigger reads visible | #776 reproduced locally — the strongest possible result | +| 0/N, click always lands | **does not reproduce on this profile; settles nothing** about the reporter's machine | + +That last row is the one worth pre-writing. A 0/N here does NOT clear +`migrated_composer.py:884`: the confirmed defect in #776 is that the failure arrives +**unattributable**, and that is true whatever covers the trigger. A disappearance is not +evidence of transience — it is equally consistent with this account never having been +served the announcement. Report it as *unmeasured*, and say what would settle it. + +## Cost + +Zero. Navigation, DOM reads, one click on the settings trigger (which opens Flow's own +settings pane and changes no setting), and Escape to close it. Nothing is typed, nothing +submitted, nothing created or deleted. No credits, no daily quota. + + python scripts/dev/spike_migrated_click_blocked.py \ + --profile ci-probe --project --samples 3 +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +import time +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _spike_common import ( # noqa: E402, isort: skip + build_client, + default_out_path, + resolve_profile_dir, + step, +) + +# Copied from migrated_composer.py:82/123/83 so the probe and the driver cannot disagree +# about which controls are under discussion. +READY_ANCHOR = ".settings-trigger-button" +DIALOG = "[role='dialog']" +OVERLAY = ".cdk-overlay-pane" + +#: The driver's own click budget (migrated_composer.py:884). Replayed exactly: a probe +#: that waited longer would "succeed" on a page the real run would have abandoned. +DRIVER_CLICK_TIMEOUT_MS = 5000 + +#: `_probe_page_block`'s reading, plus the hit-test it does NOT do. The body property is +#: selector-free and locale-invariant — a property of BEING blocked rather than of any +#: particular announcement — which is why it survives whatever Flow ships next. +_BLOCK_JS = r""" +() => { + const body = getComputedStyle(document.body); + const html = getComputedStyle(document.documentElement); + const trig = document.querySelector('.settings-trigger-button'); + let t = null; + if (trig) { + const box = trig.getBoundingClientRect(); + const cx = box.x + box.width / 2, cy = box.y + box.height / 2; + const top = (box.width && box.height) ? document.elementFromPoint(cx, cy) : null; + const cs = getComputedStyle(trig); + // Present, visible, enabled and CLICKABLE are four different things. Playwright's + // actionability wait fails on the fourth while the first three all read fine, which + // is the entire reason #776 arrives with no message. + t = { + hidden_attr: trig.hasAttribute('hidden'), + disabled: trig.hasAttribute('disabled'), + display: cs.display, + visibility: cs.visibility, + pointer_events: cs.pointerEvents, + w: Math.round(box.width), h: Math.round(box.height), + hit_testable: !!top && (top === trig || trig.contains(top) || top.contains(trig)), + // What is actually on top, named structurally. This is the line that turns + // "a timeout" into "a was over it". + occluded_by: top && !(top === trig || trig.contains(top) || top.contains(trig)) + ? top.tagName.toLowerCase() + + (top.classList.length ? '.' + [...top.classList].slice(0, 3).join('.') : '') + : null, + }; + } + const overlays = [...document.querySelectorAll('.cdk-overlay-pane')].map(o => { + const box = o.getBoundingClientRect(); + return { + tag: o.tagName.toLowerCase(), + classes: [...o.classList].slice(0, 4), + // Component boundaries inside the overlay identify WHAT it is without reading + // a single translated label. + custom_tags: [...new Set([...o.querySelectorAll('*')] + .map(e => e.tagName.toLowerCase()).filter(x => x.includes('-')))].slice(0, 8), + has_changelog_link: !!o.querySelector("a[href*='changelog']"), + visible: box.width > 0 && box.height > 0, + }; + }); + return { + t_ms: Math.round(performance.now()), + url: location.href, + ready_state: document.readyState, + // THE #593 signal. + body_pointer_events: body.pointerEvents, + html_pointer_events: html.pointerEvents, + body_aria_hidden: document.body.getAttribute('aria-hidden'), + body_inert: document.body.hasAttribute('inert'), + counts: { + settings_trigger: document.querySelectorAll('.settings-trigger-button').length, + dialog: document.querySelectorAll("[role='dialog']").length, + overlay: document.querySelectorAll('.cdk-overlay-pane').length, + iframe: document.querySelectorAll('iframe').length, + contenteditable: document.querySelectorAll("[contenteditable='true']").length, + }, + // A changelog iframe is the #593 carrier on labs; recorded by href, not by text. + changelog_iframes: [...document.querySelectorAll('iframe')] + .map(f => f.getAttribute('src') || '') + .filter(s => s.includes('changelog')), + trigger: t, + overlays, + }; +} +""" + + +class ProbeFailedError(RuntimeError): + """A step this probe cannot complete. Never downgraded to a verdict.""" + + +def _blocked(sample: dict[str, Any]) -> bool: + """True when the app behind is unclickable — `_overlay_blocks_page`'s reading.""" + return sample.get("body_pointer_events") == "none" + + +def _occluded(sample: dict[str, Any]) -> bool: + """True when the trigger is rendered but something else answers a hit-test on it.""" + t = sample.get("trigger") + return bool(t and t.get("w") and not t.get("hit_testable")) + + +async def _timeline(page: Any, seconds: float, every_ms: int) -> list[dict[str, Any]]: + """Sample from now until `seconds`, so WHEN a block appears is visible, not just IF. + + The driver looks for a dialog once, immediately after `goto` returns. If overlays + mount later than that single read, the miss is structural and no amount of retrying + the same call site fixes it. + """ + out: list[dict[str, Any]] = [] + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + try: + out.append(await page.evaluate(_BLOCK_JS)) + except Exception as exc: # noqa: BLE001 - a mid-navigation read is not a failure + out.append({"error": str(exc)[:200], "t_ms": None}) + await page.wait_for_timeout(every_ms) + return out + + +def _summarise(tag: str, samples: list[dict[str, Any]]) -> dict[str, Any]: + good = [s for s in samples if "error" not in s] + blocked = [s for s in good if _blocked(s)] + occluded = [s for s in good if _occluded(s)] + first_trigger = next((s for s in good if (s["counts"]["settings_trigger"] or 0) > 0), None) + summary = { + "samples": len(samples), + "readable": len(good), + "blocked_samples": len(blocked), + "occluded_samples": len(occluded), + "first_trigger_t_ms": first_trigger["t_ms"] if first_trigger else None, + "max_overlays": max((s["counts"]["overlay"] for s in good), default=0), + "max_dialogs": max((s["counts"]["dialog"] for s in good), default=0), + "changelog_iframes": sorted({i for s in good for i in s["changelog_iframes"]}), + "occluders": sorted({s["trigger"]["occluded_by"] for s in occluded if s["trigger"]}), + } + step( + tag, + f"readable={summary['readable']}/{summary['samples']} " + f"blocked={summary['blocked_samples']} occluded={summary['occluded_samples']} " + f"trigger_at={summary['first_trigger_t_ms']}ms " + f"overlays<={summary['max_overlays']} dialogs<={summary['max_dialogs']}", + ) + if summary["occluders"]: + step(f"{tag}.occluders", str(summary["occluders"])) + return summary + + +async def _replay_driver_click(page: Any) -> dict[str, Any]: + """Do exactly what `_open_pane` does, and time it. + + This is the A/B that matters: the same two calls, same timeouts, same order. A click + that lands here on a page the probe just read as unblocked is a control result; one + that expires while `wait_for(visible)` passed IS #776, reproduced. + """ + trigger = page.locator(READY_ANCHOR).first + out: dict[str, Any] = {} + t0 = time.monotonic() + try: + await trigger.wait_for(state="visible", timeout=DRIVER_CLICK_TIMEOUT_MS) + out["wait_for_visible_ms"] = round((time.monotonic() - t0) * 1000) + out["wait_for_visible"] = "passed" + except Exception as exc: # noqa: BLE001 - this branch is exit 23 in the driver + out["wait_for_visible_ms"] = round((time.monotonic() - t0) * 1000) + out["wait_for_visible"] = "TIMED OUT" + out["wait_error"] = str(exc)[:300] + step("replay", "wait_for(visible) TIMED OUT — this run is the exit-23 branch, not #776") + return out + + # State captured BEFORE the click, so a block can be attributed to the click that + # follows rather than inferred from the wreckage afterwards. + out["before"] = await page.evaluate(_BLOCK_JS) + t1 = time.monotonic() + try: + await trigger.click(timeout=DRIVER_CLICK_TIMEOUT_MS) + out["click_ms"] = round((time.monotonic() - t1) * 1000) + out["click"] = "landed" + step("replay", f"click LANDED in {out['click_ms']}ms") + except Exception as exc: # noqa: BLE001 - the whole point of the probe + out["click_ms"] = round((time.monotonic() - t1) * 1000) + out["click"] = "TIMED OUT" + out["click_error"] = str(exc)[:400] + step("replay", f"click TIMED OUT after {out['click_ms']}ms — #776 REPRODUCED") + out["after"] = await page.evaluate(_BLOCK_JS) + # Leave the account exactly as found: the pane changes no setting, but an open + # overlay would poison a later sample in this same run. + try: + await page.keyboard.press("Escape") + await page.wait_for_timeout(400) + except Exception: # noqa: BLE001 - cleanup is best-effort + pass + return out + + +async def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--profile", required=True) + ap.add_argument("--project", required=True) + ap.add_argument("--samples", type=int, default=3, help="independent navigations") + ap.add_argument("--watch-s", type=float, default=14.0, help="timeline length per sample") + ap.add_argument("--every-ms", type=int, default=250) + args = ap.parse_args() + + profile_dir = resolve_profile_dir(args.profile) + findings: dict[str, Any] = { + "profile": args.profile, + "project": args.project, + "question": ( + "is .settings-trigger-button ever visible-but-unclickable on flow.google.com, " + "and does body{pointer-events:none} (#593) occur on the migrated host? (#776)" + ), + "cost": "credit-free: navigation, DOM reads, one settings-pane click, Escape", + "driver_click_timeout_ms": DRIVER_CLICK_TIMEOUT_MS, + "runs": [], + } + url = f"https://flow.google.com/project/{args.project}" + + async with build_client(profile_dir) as client: + context = client._context # noqa: SLF001 - spike reads the live context + for i in range(args.samples): + page = await context.new_page() + run: dict[str, Any] = {"n": i + 1} + try: + step(f"run{i + 1}.goto", url) + await page.goto(url, wait_until="domcontentloaded", timeout=60_000) + + # The driver's ONE look for a dialog happens right here, before any + # settle. Recorded separately so "what _dismiss_dialog could have seen" + # is a measurement rather than an argument about the code. + run["at_dismiss_dialog_time"] = await page.evaluate(_BLOCK_JS) + step( + f"run{i + 1}.dismiss_window", + f"trigger={run['at_dismiss_dialog_time']['counts']['settings_trigger']} " + f"dialogs={run['at_dismiss_dialog_time']['counts']['dialog']} " + f"body_pe={run['at_dismiss_dialog_time']['body_pointer_events']}", + ) + + samples = await _timeline(page, args.watch_s, args.every_ms) + run["timeline_summary"] = _summarise(f"run{i + 1}.timeline", samples) + run["timeline"] = samples + + if not run["timeline_summary"]["readable"]: + raise ProbeFailedError( + f"run {i + 1}: every DOM read failed — this run says NOTHING" + ) + landed = page.url + run["landed_url"] = landed + if "flow.google.com/project/" not in landed: + # Same discipline as ensure_editor: a landing page cannot answer a + # question about the composer, and must not be read as one. + run["verdict"] = "NOT ON A PROJECT PAGE — says nothing about the click" + step(f"run{i + 1}.landed", f"{landed} — skipped") + continue + + run["replay"] = await _replay_driver_click(page) + shot = default_out_path(f"spike_click_blocked_run{i + 1}", ".png") + await page.screenshot(path=str(shot)) + run["screenshot"] = shot.name + finally: + findings["runs"].append(run) + await page.close() + + # ---- the question, answered from the read ------------------------------- + scored = [r for r in findings["runs"] if "replay" in r] + blocked_runs = [r for r in scored if r["timeline_summary"]["blocked_samples"]] + occluded_runs = [r for r in scored if r["timeline_summary"]["occluded_samples"]] + failed_clicks = [r for r in scored if r["replay"].get("click") == "TIMED OUT"] + late_trigger = [ + r + for r in scored + if not r["at_dismiss_dialog_time"]["counts"]["settings_trigger"] + and r["timeline_summary"]["first_trigger_t_ms"] is not None + ] + + findings["verdict"] = { + "scored_runs": len(scored), + "runs_with_body_block": len(blocked_runs), + "runs_with_occluded_trigger": len(occluded_runs), + "runs_where_the_click_expired": len(failed_clicks), + "runs_where_dismiss_dialog_ran_before_the_composer_existed": len(late_trigger), + # Stated as an observation, never as a conclusion about the reporter's machine. + "reading": ( + "#776 REPRODUCED — the driver's own click sequence expired here" + if failed_clicks + else "the block/occlusion state was observed, but the click still landed" + if blocked_runs or occluded_runs + else "NOT REPRODUCED on this profile — unmeasured, settles nothing about #776; " + "the confirmed defect (an unattributable timeout at migrated_composer.py:884) " + "is independent of which overlay causes it" + ), + } + + out = default_out_path("spike_migrated_click_blocked") + out.write_text(json.dumps(findings, indent=2, ensure_ascii=False), encoding="utf-8") + step("verdict", json.dumps(findings["verdict"], indent=2)) + step("out", str(out)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/src/gflow_cli/api/transports/migrated_composer.py b/src/gflow_cli/api/transports/migrated_composer.py index fced7168..770dca57 100644 --- a/src/gflow_cli/api/transports/migrated_composer.py +++ b/src/gflow_cli/api/transports/migrated_composer.py @@ -39,6 +39,7 @@ from urllib.parse import unquote_plus, urlsplit import structlog +from playwright.async_api import TimeoutError as PlaywrightTimeoutError from gflow_cli.api.dto import GeneratedImage from gflow_cli.api.image import Aspect as ImageAspect @@ -69,6 +70,7 @@ UiSelectorDriftError, WireFormatError, ) +from gflow_cli.redaction import redact_sensitive_text if TYPE_CHECKING: from playwright.async_api import Page @@ -195,6 +197,48 @@ FRAME_UPLOAD_S = 60.0 FRAME_COMMIT_HIDDEN_S = 15.0 FRAME_THUMB_VISIBLE_S = 5.0 +#: What a click that expired may be asked about — Playwright's four actionability +#: conditions, read back after the fact. See :meth:`MigratedComposer._click`. +#: +#: **Everything returned here is a closed vocabulary.** Tag names, booleans, and class +#: tokens matching a fixed framework prefix — never ``outerHTML``, ``textContent``, +#: ``aria-label``, ``title``, ``alt``, ``src`` or ``href``. An occluding element on a +#: signed-in Flow page routinely carries the account email in ``aria-label`` and a signed +#: media URL in ``src``, and this string is printed raw to the console, shipped through +#: structlog, emitted under ``--json``, and invited into a GitHub issue by the error +#: class's own remediation hint. PR #777 fixed exactly this bug one surface over. +_CLICK_POSTMORTEM_JS = r""" +(el) => { + const cs = getComputedStyle(el); + const box = el.getBoundingClientRect(); + const cx = box.x + box.width / 2, cy = box.y + box.height / 2; + const top = (box.width && box.height) ? document.elementFromPoint(cx, cy) : null; + const hit = !!top && (top === el || el.contains(top) || top.contains(el)); + // Angular/CDK, Material and Flow's own components. A layout class on a bare
+ // is an accident; `cdk-overlay-backdrop` is a component boundary and says what the + // thing IS, in any locale. + const structural = (n) => + [...n.classList].filter(c => /^(cdk|mat|mdc|flow)-/.test(c)).slice(0, 3).join('.'); + return { + visible: cs.display !== 'none' && cs.visibility !== 'hidden' + && box.width > 0 && box.height > 0, + hidden_attr: el.hasAttribute('hidden'), + enabled: !el.hasAttribute('disabled') && el.getAttribute('aria-disabled') !== 'true', + hit_testable: hit, + // The #593 mechanism. Measured on labs.google, never on this host — kept because a + // reading that never fires costs nothing, and a missing one costs a wrong answer. + body_blocked: getComputedStyle(document.body).pointerEvents === 'none', + occluder: (top && !hit) + ? (top.tagName.toLowerCase() + (structural(top) ? '.' + structural(top) : '')) + : null, + }; +} +""" + +#: Names the submit control in a message. No CSS string can express the `arrow_forward` +#: ligature filter that builds it, so there is nothing here to rot into a selector. +SUBMIT_BUTTON = "the submit button (ligature 'arrow_forward')" + #: The submit reply arrived 4.0–4.6 s after the click in both measured runs. SUBMIT_REPLY_BUDGET_S = 60.0 # Angular enables the arrow_forward button ~100 ms after `insert_text` lands in the @@ -759,6 +803,104 @@ async def _dismiss_dialog(page: Page) -> None: except Exception as e: # noqa: BLE001 - best-effort, never the failure itself log.warning("migrated.dialog_not_dismissed", error=str(e)[:200]) + # --- clicking, and saying why a click did not land --------------------------- + + async def _click(self, page: Page, locator: Any, *, named: str, timeout: int) -> None: + """Click, and when it expires report what was actually TRUE — never why. + + Playwright's actionability gate has four conditions — visible, stable, receives + events, enabled — and a click that fails any of them expires as a bare + ``TimeoutError`` carrying no locator, no exit code and no cause. That is #776: + `editor_ready`, five seconds, exit 1, nothing to act on. + + **This reads; it does not diagnose.** Two candidate causes (#593, #752 finding #7) + were unmeasurable on this host as of the 2026-09-10 spike, and a guard built on + either would sometimes answer confidently and wrong (#770). So it states + observations; "every reading was healthy" is one of them. + + Costs nothing on a healthy run — the read happens only in the except branch, which + is also the rule :func:`_common.raise_if_known_landing` already states: a guard + placed ahead of the probe deletes the evidence that would correct it. + + Only a Playwright timeout is reinterpreted. A closed page or a detached frame is a + different failure and travels unchanged. + """ + try: + await locator.click(timeout=timeout) + except PlaywrightTimeoutError as e: + raise UiSelectorDriftError( + detail=await self._why_the_click_missed(page, locator, named, timeout) + ) from e + + async def _why_the_click_missed( + self, page: Page, locator: Any, named: str, timeout: int + ) -> str: + """The message for a click that expired: locator first, observations after. + + Locator first is not cosmetic. ``redact_sensitive_text`` truncates to 500 chars + at this raise site (``data/redaction.py``), so every surface sees the same cap and + anything variable-length — the occluder's class list — has to sit behind the one + part that must always survive it. + """ + head = f"migrated host: {named} did not accept a click within {timeout} ms" + try: + # Through the LOCATOR, not a selector string. Half this driver's controls are + # built by filtering on a ligature (`button` + `arrow_forward`), which no + # `document.querySelector` can express — and the submit button, where losing + # attribution costs the most, is one of them. + state: dict[str, Any] = await locator.evaluate(_CLICK_POSTMORTEM_JS) + except Exception as e: # noqa: BLE001 - a diagnostic never replaces the failure + # Detached, cross-origin, or the document replaced under us. All three are + # "we could not look", and none of them may swallow the failure itself. + return redact_sensitive_text( + f"{head} — it could not be read back ({str(e)[:120]}) (host=migrated)" + ) + + seen: list[str] = [] + # Agent mode first: it is the one cause here with a user action attached, and + # it hides the trigger with a bare `hidden` that touches neither the body's + # pointer-events nor the hit-test — so nothing else below would notice it. + if await self._agent_chip_pressed(page): + seen.append( + "the account is in Flow's agent mode, which hides it — turn the Agent " + "chip off in a browser and re-run" + ) + # One chain, because these are competing readings of the SAME question — can a + # pointer reach it — ordered most specific first. The JS only hit-tests + # `if (box.width && box.height)`, so an unrendered element always reports no hit + # test too; as independent `if`s that said "it is not rendered" and "it answers no + # hit test" about one fact. + if state.get("hidden_attr"): + seen.append("it carries a bare `hidden` attribute") + elif not state.get("visible"): + seen.append("it is not rendered (display, visibility, or a zero-sized box)") + elif state.get("occluder"): + seen.append(f"it is covered by {state['occluder']}") + elif not state.get("hit_testable"): + # Rendered, nothing named itself: whatever is on top is outside the document. + # Not "healthy" — letting it fall through would claim hit-testable of an + # element that had just failed the hit test. + seen.append("it answers no hit test at its own centre") + + # Separate axes: an element can be disabled, or the whole page blocked, whatever + # the chain above found. + if not state.get("enabled"): + seen.append("it is disabled") + if state.get("body_blocked"): + seen.append("the page is accepting no pointer events at all") + + if not seen: + # Every readable condition is healthy. Saying so eliminates three of + # Playwright's four checks instead of inventing one of them. + seen.append( + "it was visible, enabled and hit-testable at the moment the click " + "expired, so nothing readable on the page explains it — Playwright also " + "requires a stable bounding box, so the control was most likely still " + "moving or being re-rendered" + ) + # Belt and braces over the JS allowlist above. + return redact_sensitive_text(f"{head} — {'; '.join(seen)} (host=migrated)") + # --- settings --------------------------------------------------------------- async def apply_video_settings(self, page: Page, request: GenerateVideoRequest) -> None: @@ -881,7 +1023,9 @@ async def _open_pane(self, page: Page) -> Any: f"{why} (host=migrated)" ), ) from e - await trigger.click(timeout=5000) + # The other half of #752 finding #7: the guard above became a visibility wait, + # this click stayed bare, and the comment above describes what it went on doing. + await self._click(page, trigger, named=READY_ANCHOR, timeout=5000) # THE overlay that holds the option groups — not `.last`: once the model # menu (a second overlay) has opened and closed, a detached menu pane can # still be the last one in the DOM, and every axis after `--model` then @@ -1595,7 +1739,9 @@ async def send_prompt(self, page: Page, prompt: str, *, append: bool = False) -> detail=f"migrated host: composer ({COMPOSER}) not found (host=migrated)", ) if not append: - await composer.click(timeout=5000) + # _close_pane's docstring names THIS click as the one that surfaced a + # stuck settings pane as a bare 5 s TimeoutError naming only the composer. + await self._click(page, composer, named=COMPOSER, timeout=5000) # insert_text dispatches input events without key presses: a newline in the # prompt lands as text instead of an Enter that might submit early. await page.keyboard.insert_text(prompt) @@ -1722,7 +1868,9 @@ async def on_response(response: Any) -> None: ) await asyncio.sleep(SUBMIT_ENABLE_POLL_S) deadline = time.monotonic() + poll_timeout_s - await submit.click(timeout=5000) + # The credit-spending click: a bare timeout here leaves "did it submit?" + # unanswerable, which is the worst place in this driver to lose attribution. + await self._click(page, submit, named=SUBMIT_BUTTON, timeout=5000) log.info("migrated.submit_clicked") budget = min(SUBMIT_REPLY_BUDGET_S, poll_timeout_s) await asyncio.wait( @@ -1855,7 +2003,7 @@ async def on_response(response: Any) -> None: detail="migrated host: image submit stayed disabled (host=migrated)" ) await asyncio.sleep(SUBMIT_ENABLE_POLL_S) - await submit.click(timeout=5000) + await self._click(page, submit, named=SUBMIT_BUTTON, timeout=5000) done, _ = await asyncio.wait( {result, route_error}, timeout=IMAGE_REPLY_BUDGET_S, diff --git a/src/gflow_cli/errors.py b/src/gflow_cli/errors.py index 1512e7e2..6bd04b2b 100644 --- a/src/gflow_cli/errors.py +++ b/src/gflow_cli/errors.py @@ -609,10 +609,18 @@ class ExtendUnavailableError(GFlowError): class UiSelectorDriftError(GFlowError): - """Raised when a UI-automation selector cascade finds no matching element. + """Raised when a UI-automation selector cascade cannot reach the control it needs. + + Two shapes, and the second is easy to forget: the selector **finds nothing**, or it + finds the element and the element **will not take the interaction** — occluded, + disabled, or never holding still (#593's blocked overlay, #776's click that expired + while the control read visible and enabled). Both mean the same thing to a caller — + gflow cannot drive this control — which is why they share an exit code, and why the + ``detail`` has to say which one happened. Indicates that Flow's frontend has changed in a way that invalidates one - of the selector probes (mode-switch trigger, mode tab, sub-mode tab, etc.). + of the selector probes (mode-switch trigger, mode tab, sub-mode tab, etc.), + or that something on the page is in the way. The ``detail`` names the probe label and includes the debug screenshot or diagnostics JSON path when one was captured. diff --git a/tests/api/transports/test_click_attribution.py b/tests/api/transports/test_click_attribution.py new file mode 100644 index 00000000..fa128e4a --- /dev/null +++ b/tests/api/transports/test_click_attribution.py @@ -0,0 +1,274 @@ +"""Offline guards for the click post-mortem (#776). + +The behaviour itself is proven in the browser by +``tests/e2e/test_click_attribution_bdd.py`` — Playwright's actionability gate is what +fails, and no mock can express it. These two cases are the opposite: they are about what +the helper must *not* do, and both are cheap to pin without a browser. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from playwright.async_api import TimeoutError as PlaywrightTimeoutError + +from gflow_cli.api.transports.migrated_composer import MigratedComposer +from gflow_cli.errors import UiSelectorDriftError + + +class _Locator: + """A locator that fails a click the way we ask, and reads back what we say. + + Deliberately a plain class, not ``MagicMock``: a mock answers every attribute with a + truthy child, which is exactly how a guard silently stops guarding + (memory ``magicmock-truthy-getattr-silences-guards``). + """ + + def __init__(self, *, raises: BaseException, state: dict[str, Any] | None = None) -> None: + self._raises = raises + self._state = state or {} + + async def click(self, **_: object) -> None: + raise self._raises + + async def evaluate(self, _js: str) -> dict[str, Any]: + return self._state + + +class _Page: + """Just enough Page for the post-mortem: it is only asked for the agent chip.""" + + def locator(self, _sel: str) -> Any: # pragma: no cover - never reached here + raise AssertionError("the post-mortem must read through the LOCATOR, not the page") + + +_HEALTHY = { + "visible": True, + "hidden_attr": False, + "enabled": True, + "hit_testable": True, + "body_blocked": False, + "occluder": None, +} + + +@pytest.fixture +def composer(monkeypatch: pytest.MonkeyPatch) -> MigratedComposer: + """A composer whose agent-chip probe answers False, so it is never the cause.""" + monkeypatch.setattr( + MigratedComposer, "_agent_chip_pressed", staticmethod(lambda _page: _false()) + ) + return MigratedComposer() + + +async def _false() -> bool: + return False + + +@pytest.mark.asyncio +async def test_a_non_timeout_click_failure_is_not_reinterpreted( + composer: MigratedComposer, +) -> None: + """Only an actionability timeout becomes selector drift. + + A closed page, a detached frame or a navigation abort is a different failure with a + different remedy. Converting those too would relabel every browser mishap as "Flow + changed its frontend" and send users to file drift bugs about their own laptop. + """ + boom = RuntimeError("Target page, context or browser has been closed") + with pytest.raises(RuntimeError) as caught: + await composer._click( # noqa: SLF001 + _Page(), _Locator(raises=boom), named=".x", timeout=1000 + ) + assert caught.value is boom + + +@pytest.mark.asyncio +async def test_a_click_timeout_names_the_locator_first(composer: MigratedComposer) -> None: + """The locator leads the message, ahead of anything variable-length. + + The detail is truncated to 500 chars at the raise site (``data/redaction.py``), so + whatever must survive that cut has to come before the occluder's class list. + """ + error = await _drift(composer, state=_HEALTHY) + assert error.detail is not None + assert error.detail.index(".settings-trigger-button") < error.detail.index("did not accept") + + +@pytest.mark.asyncio +async def test_an_unreadable_element_still_reports_the_failure( + composer: MigratedComposer, +) -> None: + """A diagnostic may never replace the failure it was called to describe.""" + + class _Unreadable(_Locator): + async def evaluate(self, _js: str) -> dict[str, Any]: + raise RuntimeError("Execution context was destroyed") + + with pytest.raises(UiSelectorDriftError) as caught: + await composer._click( # noqa: SLF001 + _Page(), + _Unreadable(raises=PlaywrightTimeoutError("Timeout 5000ms exceeded")), + named=".settings-trigger-button", + timeout=5000, + ) + detail = caught.value.detail or "" + assert ".settings-trigger-button" in detail + assert "could not be read back" in detail + + +@pytest.mark.asyncio +async def test_nothing_readable_wrong_is_reported_as_such(composer: MigratedComposer) -> None: + """When every reading is healthy, say so — do not pick a cause. + + This is the countermeasure to #770, where a typed error named a "most likely" cause + that was wrong for the reporting account. Three of Playwright's four conditions are + eliminated here; naming the fourth as a *possibility* is honest, asserting it is not. + """ + detail = (await _drift(composer, state=_HEALTHY)).detail or "" + assert "visible, enabled and hit-testable" in detail + for invented in ("agent mode", "covered by", "announcement", "changelog"): + assert invented not in detail.lower(), f"invented {invented!r}: {detail}" + + +@pytest.mark.asyncio +async def test_the_occluder_report_is_bounded(composer: MigratedComposer) -> None: + """A named occluder must not be able to crowd the message out. + + The JS caps the class list at three framework-prefixed tokens, so even a pathological + CDK class soup leaves the 500-char MCP slice intact. Pinned here because the cap lives + in a JS string that no type checker and no linter can see. + """ + state = {**_HEALTHY, "hit_testable": False, "occluder": "div." + ".".join(["cdk-x"] * 3)} + detail = (await _drift(composer, state=state)).detail or "" + assert "div.cdk-x.cdk-x.cdk-x" in detail + assert len(detail) < 500, f"a single occluder should not fill the MCP budget: {detail}" + + +@pytest.mark.asyncio +async def test_a_failed_hit_test_without_an_occluder_is_not_called_healthy( + composer: MigratedComposer, +) -> None: + """`elementFromPoint` can miss and name nothing — a zero-box or an out-of-document + overlay. Falling through to the healthy branch would then claim hit-testable of an + element that had just failed the hit test.""" + state = {**_HEALTHY, "hit_testable": False, "occluder": None} + detail = (await _drift(composer, state=state)).detail or "" + assert "no hit test" in detail + assert "hit-testable at the moment" not in detail + + +async def _drift(composer: MigratedComposer, *, state: dict[str, Any]) -> UiSelectorDriftError: + with pytest.raises(UiSelectorDriftError) as caught: + await composer._click( # noqa: SLF001 + _Page(), + _Locator(raises=PlaywrightTimeoutError("Timeout 5000ms exceeded"), state=state), + named=".settings-trigger-button", + timeout=5000, + ) + return caught.value + + +# --------------------------------------------------------------------------- +# One case per reading the post-mortem can report. +# +# The e2e proves the BROWSER really produces these states; these prove the message +# for each one. They are here and not only there because CI's coverage run excludes +# `-m e2e`, so a branch exercised solely by the browser reads as dead code to +# SonarCloud's new-code gate — the exact way PR #777 went red at 70%. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("override", "expected"), + [ + ({"hidden_attr": True, "hit_testable": False}, "bare `hidden` attribute"), + ({"visible": False, "hit_testable": False}, "not rendered"), + ({"enabled": False}, "it is disabled"), + ({"body_blocked": True}, "accepting no pointer events at all"), + ], + ids=["hidden", "not-rendered", "disabled", "body-blocked"], +) +@pytest.mark.asyncio +async def test_each_readable_condition_is_named( + composer: MigratedComposer, override: dict[str, Any], expected: str +) -> None: + detail = (await _drift(composer, state={**_HEALTHY, **override})).detail or "" + assert expected in detail, detail + # Whatever fired, the locator is still the first thing the reader sees. + assert ".settings-trigger-button" in detail + + +@pytest.mark.asyncio +async def test_agent_mode_leads_the_message_when_the_chip_is_pressed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """#752 finding #7's cause, and the only one here with a user action attached. + + It is reported FIRST because it is the only reading a user can act on directly, and + because agent mode hides the trigger with a bare `hidden` that touches neither the + body's pointer-events nor the hit test — nothing else in the probe would notice it. + """ + + async def _true(_page: object) -> bool: + return True + + monkeypatch.setattr(MigratedComposer, "_agent_chip_pressed", staticmethod(_true)) + state = {**_HEALTHY, "hit_testable": False, "occluder": "div.cdk-overlay-backdrop"} + detail = (await _drift(MigratedComposer(), state=state)).detail or "" + assert "agent mode" in detail + # Both facts are reported — the occluder is real too — but the actionable one leads. + assert detail.index("agent mode") < detail.index("covered by") + + +@pytest.mark.asyncio +async def test_a_missing_reading_is_not_mistaken_for_health( + composer: MigratedComposer, +) -> None: + """An empty state dict must not read as "everything was fine". + + `locator.evaluate` returning a shape we did not expect (an older Chromium, a JS + error swallowed into a partial object) would make every `.get()` falsy. Falling + through to the healthy branch there would report "visible, enabled and hit-testable" + about an element nothing was ever read from. + """ + detail = (await _drift(composer, state={})).detail or "" + assert "visible, enabled and hit-testable" not in detail, detail + + +@pytest.mark.asyncio +async def test_an_unrendered_element_reports_one_fact_once(composer: MigratedComposer) -> None: + """ "Not rendered" and "answers no hit test" are the same fact, not two. + + `_CLICK_POSTMORTEM_JS` only calls `elementFromPoint` `if (box.width && box.height)`, + so a zero-box element ALWAYS comes back `hit_testable: false, occluder: null` as well. + Reported as independent readings that told the user the same thing twice in one + sentence — the shape of over-reporting that makes a diagnostic harder to act on than + a short one. + """ + state = {**_HEALTHY, "visible": False, "hit_testable": False, "occluder": None} + detail = (await _drift(composer, state=state)).detail or "" + assert "not rendered" in detail + assert "no hit test" not in detail + + +@pytest.mark.asyncio +async def test_disabled_and_blocked_are_reported_alongside_occlusion( + composer: MigratedComposer, +) -> None: + """Enabled-ness and a page-wide block are separate axes from occlusion. + + Chaining them onto the same `elif` ladder would hide a disabled control behind + whatever covered it — two different remedies collapsed into one message. + """ + state = { + **_HEALTHY, + "enabled": False, + "body_blocked": True, + "hit_testable": False, + "occluder": "div.cdk-overlay-backdrop", + } + detail = (await _drift(composer, state=state)).detail or "" + for fact in ("covered by", "it is disabled", "accepting no pointer events"): + assert fact in detail, f"missing {fact!r}: {detail}" diff --git a/tests/api/transports/test_migrated_composer.py b/tests/api/transports/test_migrated_composer.py index 76cecec2..642ed973 100644 --- a/tests/api/transports/test_migrated_composer.py +++ b/tests/api/transports/test_migrated_composer.py @@ -354,7 +354,15 @@ async def click(self, **_: Any) -> None: class PlaywrightTimeoutError(Exception): - pass + """A stand-in, NOT ``playwright.async_api.TimeoutError`` — and that matters now. + + Since #776, ``MigratedComposer._click`` catches the *real* Playwright class to + convert a click timeout into ``UiSelectorDriftError``. Raising this one from a fake + locator therefore does **not** match that ``except``, so the post-mortem never runs + and the test silently exercises nothing. If you are writing a click-timeout + regression test, import the real class — see + ``tests/api/transports/test_click_attribution.py``. + """ class FakeFileChooser: diff --git a/tests/e2e/test_click_attribution_bdd.py b/tests/e2e/test_click_attribution_bdd.py new file mode 100644 index 00000000..15a6fc7b --- /dev/null +++ b/tests/e2e/test_click_attribution_bdd.py @@ -0,0 +1,263 @@ +"""E2E for attributable click timeouts on the migrated composer (#776). + +Binds ``tests/features/click_attribution.feature``. The Gherkin's ``@e2e`` tags become +pytest markers via pytest-bdd, so ``-m e2e`` / ``-m e2e_auth`` select this file exactly +like a hand-written e2e — see ``docs/E2E_TESTING.md`` § BDD-bound e2e. + +**Why an e2e and not a unit test.** What fails in #776 is Playwright's *actionability* +gate — attached → visible → stable → receives-events → enabled. A mocked ``Page`` whose +``.click()`` is a stub cannot express "visible, enabled, and still not clickable"; it +would pass against a fix that reads the DOM at the wrong moment, which is the trap #639 +set one surface over. Only a real browser can falsify this. + +Each scenario therefore breaks a **different** actionability condition, in the browser, +for real: + +- covered by a stacked element → *receives-events* +- an infinite CSS transform → *stable* (visible, enabled and hit-testable throughout) +- a pressed agent-mode chip → #752 finding #7's predicted cause, which touches + neither ``body{pointer-events}`` nor the hit-test + +**Cost: zero.** Every Flow origin is served by Playwright route interception, so nothing +reaches Google, no credit is spent and no authenticated profile is needed — the same +harness as ``test_landing_state_diagnosis_bdd.py``. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest +from playwright.async_api import Route, async_playwright +from pytest_bdd import given, scenarios, then, when + +from gflow_cli.api.transports.migrated_composer import MigratedComposer +from gflow_cli.errors import UiSelectorDriftError, is_retryable + +scenarios("../features/click_attribution.feature") + +PROJECT_ID = "e2e-click-attribution" +PROJECT_URL = f"https://flow.google.com/project/{PROJECT_ID}" +ELSEWHERE_URL = "https://flow.google.com/project/somewhere-else" + +#: Planted on the covering element in the redaction scenario. Neither may ever appear in +#: a message that reaches a console, a log or a GitHub issue. +ACCOUNT_EMAIL = "e2e-victim@example.com" +SIGNED_URL = "https://lh3.googleusercontent.com/x?X-Goog-Signature=deadbeefcafe" + +_STYLE = """ + body { margin: 0; font-family: sans-serif; } + .settings-trigger-button { position: absolute; top: 100px; left: 100px; + width: 147px; height: 32px; } + #cover { position: absolute; top: 0; left: 0; width: 100vw; height: 100vh; + z-index: 9999; background: rgba(0,0,0,.01); } + /* Never stable, never still: Playwright's stability check can never pass, while + visibility, enabled-ness and the hit-test all read perfectly healthy. */ + @keyframes drift { from { transform: translateX(0); } to { transform: translateX(60px); } } + .jitter { animation: drift .18s linear infinite alternate; } +""" + +#: Clicking the trigger mounts the overlay `_open_pane` waits for, so the control +#: scenario exercises the whole happy path rather than just "no exception". +_OPENS_PANE_JS = """ + document.querySelector('.settings-trigger-button').addEventListener('click', () => { + const pane = document.createElement('div'); + pane.className = 'cdk-overlay-pane'; + pane.innerHTML = "
16:9
"; + document.body.appendChild(pane); + }); +""" + + +def _page( + *, + cover: str = "", + chip: bool = False, + jitter: bool = False, + script: str = "", +) -> str: + """A migrated-host editor stub: a real trigger, plus whatever is wrong with it.""" + chip_html = "" if chip else "" + cls = "settings-trigger-button jitter" if jitter else "settings-trigger-button" + return ( + f"" + f"{chip_html}" + f"" + f"{cover}" + f"" + "" + ) + + +#: A plain stacked overlay — the shape Angular CDK actually uses on this host (the +#: 2026-09-10 spike measured `body{pointer-events}` staying `auto` in 159/159 samples, +#: including while Flow's own pane was open, so the hit-test is the load-bearing +#: detector here and the body property is the labs mechanism). +_CDK_COVER = "
" + +#: The same cover, carrying exactly what must never be echoed back. +_LEAKY_COVER = ( + f"
{ACCOUNT_EMAIL}
" +) + + +@pytest.fixture +def world() -> dict[str, Any]: + return {} + + +async def _drive(html: str, run: Any) -> BaseException | None: + """Launch a real browser, serve `html` for every Flow URL, run `run(page)`.""" + async with async_playwright() as pw: + browser = await pw.chromium.launch(headless=True) + try: + page = await (await browser.new_context()).new_page() + + async def _handler(route: Route) -> None: + await route.fulfill(status=200, content_type="text/html", body=html) + + await page.route("https://flow.google.com/**", _handler) + await page.goto(PROJECT_URL, wait_until="domcontentloaded") + try: + await run(page) + except BaseException as exc: # noqa: BLE001 - the failure IS the assertion + return exc + return None + finally: + await browser.close() + + +def _open_pane(world: dict[str, Any]) -> None: + world["error"] = asyncio.run( + _drive( + world["html"], + lambda page: MigratedComposer()._open_pane(page), # noqa: SLF001 + ) + ) + + +def _text(world: dict[str, Any]) -> str: + return str(world["error"]) + + +# --------------------------------------------------------------------------- given + + +@given("a Flow project page whose settings trigger is covered") +def _covered(world: dict[str, Any]) -> None: + world["html"] = _page(cover=_CDK_COVER) + + +@given("a Flow project page whose settings trigger accepts no click") +def _never_stable(world: dict[str, Any]) -> None: + # Nothing covers it and nothing disables it — it simply never holds still. + world["html"] = _page(jitter=True) + + +@given("a Flow project page whose settings trigger is clickable") +def _clickable(world: dict[str, Any]) -> None: + world["html"] = _page() + + +@given("the agent-mode chip is pressed") +def _chip_pressed(world: dict[str, Any]) -> None: + world["html"] = _page(cover=_CDK_COVER, chip=True) + + +@given("the covering element carries an account email and a signed media URL") +def _leaky(world: dict[str, Any]) -> None: + world["html"] = _page(cover=_LEAKY_COVER) + + +@given("the page navigates away while the click is pending") +def _navigates_away(world: dict[str, Any]) -> None: + # #722's shape: by the time the failure is diagnosed the document the click was + # made against is gone, so the post-mortem read has nothing to answer with. + world["html"] = _page( + cover=_CDK_COVER, + script=f"setTimeout(() => location.replace({ELSEWHERE_URL!r}), 400);", + ) + + +# ---------------------------------------------------------------------------- when + + +@when("the driver opens the settings pane") +def _drive_open_pane(world: dict[str, Any]) -> None: + _open_pane(world) + + +# ---------------------------------------------------------------------------- then + + +@then("it fails with exit 23") +def _exit_23(world: dict[str, Any]) -> None: + from gflow_cli.errors import EXIT_CODE_MAP + + error = world["error"] + assert isinstance(error, UiSelectorDriftError), ( + f"expected UiSelectorDriftError, got {error!r} — a bare Playwright TimeoutError " + "is exactly the #776 defect: exit 1, no locator, no cause" + ) + assert EXIT_CODE_MAP[UiSelectorDriftError] == 23 + # A flag is a claim. Today's failure is non-retryable; the condition does not + # reproduce, so this is PRESERVED, not measured (Bug Lane, "A flag is a claim"). + assert is_retryable(error) is False, "retryable moved as a side effect of retyping" + + +@then("the message names the settings trigger") +def _names_trigger(world: dict[str, Any]) -> None: + assert ".settings-trigger-button" in _text(world), _text(world) + + +@then("the message names Flow's agent mode") +def _names_agent_mode(world: dict[str, Any]) -> None: + assert "agent mode" in _text(world).lower(), _text(world) + + +@then("the message does not blame an overlay") +def _not_an_overlay(world: dict[str, Any]) -> None: + # #752 finding #7: agent mode hides the trigger with a bare `hidden` and never + # touches body{pointer-events}. Reporting it as an announcement modal sends the + # user to dismiss something that was never there — #770's failure, repeated. + text = _text(world).lower() + for claim in ("announcement", "changelog", "dismiss the"): + assert claim not in text, f"blames an overlay it did not observe ({claim!r}): {text}" + + +@then("the message names the covering element by tag and structural class") +def _names_occluder(world: dict[str, Any]) -> None: + text = _text(world) + assert "div" in text, text + assert "cdk-overlay-backdrop" in text, text + + +@then("the message reports the control as visible, enabled and hit-testable") +def _reports_healthy(world: dict[str, Any]) -> None: + text = _text(world).lower() + for word in ("visible", "enabled", "hit-testable"): + assert word in text, f"missing {word!r} in: {text}" + + +@then("the message does not name a cause it did not observe") +def _no_invented_cause(world: dict[str, Any]) -> None: + text = _text(world).lower() + for claim in ("agent mode", "covered by", "announcement", "changelog"): + assert claim not in text, f"invented a cause ({claim!r}): {text}" + + +@then("the pane opens and nothing is raised") +def _control(world: dict[str, Any]) -> None: + # The A/B control. Without it every assertion above would also pass against a + # helper that raises unconditionally. + assert world["error"] is None, f"a healthy click was rejected: {world['error']!r}" + + +@then("the message contains neither the account email nor the signed URL") +def _no_pii(world: dict[str, Any]) -> None: + text = _text(world) + assert ACCOUNT_EMAIL not in text, f"leaked the account email: {text}" + assert "X-Goog-Signature" not in text, f"leaked a signed URL: {text}" + assert "googleusercontent" not in text, f"leaked a media host: {text}" diff --git a/tests/features/click_attribution.feature b/tests/features/click_attribution.feature new file mode 100644 index 00000000..8b8f88a4 --- /dev/null +++ b/tests/features/click_attribution.feature @@ -0,0 +1,71 @@ +@e2e @e2e_auth +Feature: A click that never lands says why + # #776. On flow.google.com the settings trigger reads visible, the click expires after + # 5000ms, and the run dies as a bare Playwright TimeoutError — exit 1, no locator, no + # cause. migrated_composer.py:866-869 describes that exact failure in a comment, and + # :884 is the line below the comment, still unguarded. + # + # These are e2e because Playwright's actionability gate is what fails. A mocked Page + # whose .click() is a stub cannot express "visible, enabled, and still not clickable" — + # it would pass against a fix that reads the DOM at the wrong moment, which is the #639 + # trap one surface over. Only a real browser can falsify this. + # + # Cost: zero. Every origin is served by Playwright route interception; nothing reaches + # Google and no profile is needed. + # + # The driver must never name a cause it did not observe (#770 is the live precedent for + # a confident wrong message), so "unexplained" is a first-class outcome here, not a gap. + + Scenario: A pressed agent-mode chip is named as the cause + # #752 finding #7 predicted this before #776 was filed: agent mode hides the trigger + # with a bare `hidden`, never with body{pointer-events:none}, so an overlay probe + # would read "not blocked" and report the wrong thing. + Given a Flow project page whose settings trigger is covered + And the agent-mode chip is pressed + When the driver opens the settings pane + Then it fails with exit 23 + And the message names Flow's agent mode + And the message does not blame an overlay + + Scenario: A covering element is named by its structure + Given a Flow project page whose settings trigger is covered + When the driver opens the settings pane + Then it fails with exit 23 + And the message names the covering element by tag and structural class + + Scenario: A healthy-looking failure is reported as unexplained + # Rules out three of Playwright's four conditions and points at the fourth (stable), + # which is the honest answer when nothing readable is wrong. + Given a Flow project page whose settings trigger accepts no click + When the driver opens the settings pane + Then it fails with exit 23 + And the message reports the control as visible, enabled and hit-testable + And the message does not name a cause it did not observe + + Scenario: An unobstructed trigger still opens the pane + # The A/B control. Without it every scenario above would also pass against a helper + # that raises unconditionally. + Given a Flow project page whose settings trigger is clickable + When the driver opens the settings pane + Then the pane opens and nothing is raised + + Scenario: An account identifier on the covering element never reaches the message + # PR #777 shipped two hours before this feature for the same bug class one surface + # over. A typed error prints `detail` raw to the console, to structlog and to --json, + # while the bare TimeoutError it replaces was SHA-256 hashed — so this fix REMOVES a + # privacy net and has to put back a deliberate one. + Given a Flow project page whose settings trigger is covered + And the covering element carries an account email and a signed media URL + When the driver opens the settings pane + Then it fails with exit 23 + And the message contains neither the account email nor the signed URL + + Scenario: A page that cannot be read still reports the failed locator + # #722's shape: by the time the failure is diagnosed, the document the click was made + # against is gone and the post-mortem read has nothing to answer with. A diagnostic + # must never replace the failure it is describing. + Given a Flow project page whose settings trigger is covered + And the page navigates away while the click is pending + When the driver opens the settings pane + Then it fails with exit 23 + And the message names the settings trigger diff --git a/tests/worker/test_daemon.py b/tests/worker/test_daemon.py index 449bd156..afc04b3c 100644 --- a/tests/worker/test_daemon.py +++ b/tests/worker/test_daemon.py @@ -10,7 +10,13 @@ from gflow_cli.api.video import VideoResult, VideoStatus from gflow_cli.data.store import DataStore -from gflow_cli.errors import DataIntegrityError, DataStoreError, FlowApiError, MediaAttributionError +from gflow_cli.errors import ( + DataIntegrityError, + DataStoreError, + FlowApiError, + MediaAttributionError, + UiSelectorDriftError, +) from gflow_cli.worker.daemon import FlowWorker from gflow_cli.worker.queue import QueueRepository @@ -785,3 +791,69 @@ async def test_migrated_host_error_crosses_the_queued_path(temp_db: DataStore) - assert updated.error["retryable"] is False assert updated.error["retryable"] is is_retryable(exc) worker.close() + + +# --------------------------------------------------------------------------- +# #776 — what an MCP caller actually receives when a click never lands +# +# The Iron Law applies to the MCP twin separately: the CLI and the queued path are +# two doors, and the adapter — not the shared transport — was always the risk. These +# two run the SAME `process_task` branch with the two exception shapes, so the +# difference between them is attributable to the retyping and nothing else. +# --------------------------------------------------------------------------- + + +async def _fail_t2v_with(temp_db: DataStore, exc: BaseException, task_id: str) -> dict: + repo = QueueRepository(temp_db) + task = repo.enqueue_task( + task_id=task_id, + profile_name="default", + task_type="t2v", + payload={"prompt": "a click that never lands"}, + ) + worker = FlowWorker("default", str(temp_db.path)) + fake_client = FakeFlowApiClient() + fake_client.generate_video.side_effect = exc + with patch("gflow_cli.worker.daemon.FlowApiClient", return_value=fake_client): + await worker.process_task(task) + updated = repo.get_task(task_id) + worker.close() + assert updated is not None and updated.error is not None + return updated.error + + +@pytest.mark.asyncio +async def test_a_bare_timeout_reaches_an_mcp_caller_as_a_hash(temp_db: DataStore) -> None: + """The control, and the reason #776 was unactionable over MCP. + + A non-``GFlowError`` takes `daemon.py`'s `else` branch, which ships a SHA-256 of the + message and nothing else — not the locator, not even the exception class. An agent + receiving this cannot tell a covered button from a dead network. + """ + error = await _fail_t2v_with(temp_db, TimeoutError("Timeout 5000ms exceeded"), "task-776-bare") + assert error["exit_code"] == 1 + assert error["detail"].startswith("sha256:") + assert "settings-trigger" not in error["detail"] + + +@pytest.mark.asyncio +async def test_the_typed_failure_reaches_an_mcp_caller_as_problem_details( + temp_db: DataStore, +) -> None: + """The fix, measured on the surface it changes most. + + Retyping the raise site is the whole MCP repair: the same failure now takes the + ``isinstance(exc, GFlowError)`` branch and arrives as RFC 9457 problem details with + exit 23 and the locator intact. + """ + detail = ( + "migrated host: .settings-trigger-button did not accept a click within 5000 ms " + "— it is covered by div.cdk-overlay-backdrop (host=migrated)" + ) + error = await _fail_t2v_with(temp_db, UiSelectorDriftError(detail=detail), "task-776-typed") + assert error["exit_code"] == 23 + assert not error["detail"].startswith("sha256:") + assert ".settings-trigger-button" in error["detail"] + assert "cdk-overlay-backdrop" in error["detail"] + # A flag is a claim: retyping must not have made this retryable by side effect. + assert error["retryable"] is False diff --git a/website/docs/E2E_TESTING.md b/website/docs/E2E_TESTING.md index 5f952ca1..e06e206b 100644 --- a/website/docs/E2E_TESTING.md +++ b/website/docs/E2E_TESTING.md @@ -132,6 +132,18 @@ directories, whose scenarios would run twice. > browser — that is the nightly canary's job (`scripts/canary/`), on a machine that has > one. Hosted CI cannot run the live tiers and never could. +**Two worked examples, deliberately different in kind:** + +| Feature | Binder | What only a browser could prove | +|---|---|---| +| `landing_state_diagnosis.feature` | `test_landing_state_diagnosis_bdd.py` | Flow's hop to `/about` is a **client-side** redirect, so `goto` returns before it runs (#639). A mocked page whose `url` the test assigns cannot fail that way | +| `click_attribution.feature` | `test_click_attribution_bdd.py` | Playwright's **actionability** gate — visible, stable, receives-events, enabled (#776). Each scenario breaks a different one *for real*: a stacked `div` that intercepts pointers, and a CSS animation that never lets the box settle while visibility and the hit test stay healthy | + +Both are route-intercepted and cost **$0** — real Chromium, `page.route(...).fulfill(...)`, +no Google, no profile, no credits. That combination is what makes a browser-only scenario +cheap enough to be non-negotiable: if a scenario needs a browser, the answer is an e2e +test, not a mocked proxy — the Bug Lane's step 5. + --- ## Environment variables diff --git a/website/docs/KNOWN_ISSUES.md b/website/docs/KNOWN_ISSUES.md index d5433ba6..007c91aa 100644 --- a/website/docs/KNOWN_ISSUES.md +++ b/website/docs/KNOWN_ISSUES.md @@ -1237,6 +1237,29 @@ now names which of three things happened rather than blaming drift: **On 0.71.0 and earlier there is no recovery.** Open the project on `flow.google.com`, click the **Agent** chip off, and the account works again. +**Follow-up ([#776](https://github.com/ffroliva/gflow-cli/issues/776)) — the same +confusion survived one gate later, on the *click*.** The table above covers the readiness +*wait*. A control that passes that wait and then refuses the click used to expire as a bare +Playwright `TimeoutError`: exit 1, no locator, no cause. It now reports what was observed +at the moment it expired, because the cause could not be measured — Flow's announcement +overlay is a labs.google measurement that has never been reproduced on this host, and a +mid-run agent-mode flip is equally consistent with the evidence. + +| The message says | What it means | What to do | +|---|---|---| +| `… did not accept a click … the account is in Flow's agent mode` | the mode flipped after the editor was ready | turn the **Agent** chip off in a browser; re-run | +| `… it is covered by .` | something is stacked over the control — the class names it | dismiss it in a browser; re-run | +| `… the page is accepting no pointer events at all` | an overlay has the whole app blocked (#593's shape) | dismiss it in a browser; re-run | +| `… it carries a bare `hidden` attribute` / `it is disabled` | the control is present but not usable | usually agent mode or a cohort difference; check the Agent chip first | +| `… it is not rendered (display, visibility, or a zero-sized box)` | it is in the DOM but not on screen | as above — check the Agent chip, then file a bug with the log | +| `… it answers no hit test at its own centre` | nothing named itself as the cover, but the click still landed elsewhere | re-run once; if it repeats, file a bug — an overlay outside the document is the usual shape | +| `… it was visible, enabled and hit-testable … most likely still moving` | nothing readable was wrong | Playwright also needs a *stable* box; re-run once. If it repeats, file a bug — this message means we looked and found nothing, which is a real finding worth having | +| `… it could not be read back` | the page changed under the diagnosis | re-run; if it repeats, attach the log | + +The occluder is named by tag plus framework class only. That is deliberate — a signed-in +Flow page carries the account email and signed media URLs on exactly the elements that +tend to occlude things, and this message is printed, logged, and pasted into issues. + ### Auth verification depends on Google's NextAuth session endpoint - **Status:** Mitigated · **Severity:** Low (degrades fail-closed) · **Affects:** issue #15 fix onward · **Tracked:** issue #15 From 7c29691485f4d2b02f0478a923a5eaa98e24ff03 Mon Sep 17 00:00:00 2001 From: Flavio Oliva Date: Thu, 10 Sep 2026 18:52:00 +0100 Subject: [PATCH 6/6] chore(release): v0.73.0 --- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 61 +++--- KNOWN_ISSUES.md | 22 +++ docs/INDEX.md | 2 +- docs/LIVE_VERIFICATION_v0.73.0.md | 144 +++++++++++++++ docs/PROJECT_STATUS.md | 50 +++++ docs/SECURITY.md | 12 ++ .../PREDICT.md | 174 ------------------ .../SCENARIO.md | 111 ----------- .../2026-09-10-migrated-click-blocked.md | 11 ++ pyproject.toml | 2 +- src/gflow_cli/__init__.py | 2 +- uv.lock | 2 +- website/docs/KNOWN_ISSUES.md | 22 +++ website/docs/SECURITY.md | 12 ++ 15 files changed, 312 insertions(+), 317 deletions(-) create mode 100644 docs/LIVE_VERIFICATION_v0.73.0.md delete mode 100644 docs/superpowers/plans/2026-09-10-776-attributable-click-timeouts/PREDICT.md delete mode 100644 docs/superpowers/plans/2026-09-10-776-attributable-click-timeouts/SCENARIO.md diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 558af528..bbbe932a 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "gflow", - "version": "0.72.0", + "version": "0.73.0", "description": "Reusable development and operations workflows for gflow-cli", "author": { "name": "Flavio Oliva", diff --git a/CHANGELOG.md b/CHANGELOG.md index 572bd24b..249b583e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.73.0] — 2026-09-10 + ### Security - **Google auth URLs no longer reach user-facing error messages with their query @@ -52,7 +54,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 to the Problem Details branch instead, so an agent now gets the locator and exit 23. - Applied to four sites with a named reason each, not all nineteen: the reported one, the composer click `_close_pane`'s own docstring records as failing this way, and - both credit-spending submits, where a bare timeout left "did it submit?" unanswerable. + both submit sites, where a bare timeout left "did it submit?" unanswerable — the + video one spends Veo credits, the image one spends only daily quota. - **The occluder report is a closed allowlist** — tag name plus at most three framework-prefixed class tokens, never `aria-label`, `title`, `src` or `outerHTML`. Typing the error moves the text from SHA-256-hashed telemetry to a message printed @@ -108,8 +111,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 raises `FlowAccountChooserError` (38) for a chooser and `AuthExpiredError` (3) for other Google sign-in surfaces, URL stripped. The bot-rejection hop (`/v3/signin/rejected`) keeps returning `None` — it has its own error. +- **`gflow auth list` no longer fails on a profile whose `.gflow_account` is damaged** + (PR [#764](https://github.com/ffroliva/gflow-cli/pull/764)). The reader decoded as + UTF-8 and caught only `OSError`, so a non-UTF-8 or truncated file raised out of + `list_profiles()` and broke the listing for *every* profile, not just the damaged one. + The value is also interpolated into a DOM attribute selector, where a stray quote + produced an untyped failure. Unusable content now reads as "no account recorded", + which every caller already handles. +- **Google's post-migration account chooser no longer stalls a run** + ([#763](https://github.com/ffroliva/gflow-cli/issues/763), PR + [#764](https://github.com/ffroliva/gflow-cli/pull/764) — thanks @stgmt). When Google + hands the session to `flow.google.com` and redirects to a chooser, `FlowApiClient` + now auto-selects the profile's recorded account from `.gflow_account` instead of + stalling into an opaque `RecaptchaError`/exit 1. + - The row match is exact and case-insensitive on both tiers, and **anchored so the + chooser's `Remove ` / `Sign out of ` rows can never be clicked**. + - A chooser is identified *positively* (chooser path, or account rows), so an + ordinary expired session still classifies as `AuthExpiredError` (exit 3) rather + than being swept into the new class. + - Cases that cannot be selected raise a typed, non-retryable + `FlowAccountChooserError` (**exit 38**) naming the URL the session actually + landed on. + - `.gflow_account` is treated as untrusted input — this fixes an untyped failure in + the selector and a crash in `gflow auth list` on a damaged file. + - Follow-up hardening is tracked in + [#773](https://github.com/ffroliva/gflow-cli/issues/773). ### Added +- **`gflow auth login --account `** asserts the login authenticated as the + required account, failing closed on a mismatch rather than leaving a profile signed + in as somebody else (PR [#764](https://github.com/ffroliva/gflow-cli/pull/764)). - **`FlowAppError.retryable`** — a per-instance override of that class's `RETRYABLE_ERRORS` membership. `None` (the default) keeps the class answer, so no existing raise changes. Scoped to the one class that needs it: `is_retryable()` reads @@ -179,31 +210,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 exit 23 — which told the user to file a frontend-drift bug about a frontend that was behaving correctly. -- **Account auto-selection at post-migration sign-in chooser** - ([#763](https://github.com/ffroliva/gflow-cli/issues/763)). When Google - Flow hands the session over to `flow.google.com` and redirects to an account - chooser, `FlowApiClient` now auto-selects the profile's recorded account from - `.gflow_account`. If the recorded account is absent or cannot be selected, the - client raises a dedicated, non-retryable `FlowAccountChooserError` (exit code 38), - avoiding generic `UnexpectedError` or selector drift stalls. `gflow auth login` - gains an optional `--account ` option to assert that login authenticates - as the required account. When the click-through does not reach Flow, the error - names the URL the session actually landed on, so a Google challenge that needs a - human is distinguishable from a click that never navigated. Account matching is - case-insensitive on both tiers, matching `--account`'s own comparison, so a - recorded address whose case differs from Google's rendering still selects its - row instead of reporting the account as absent. - -### Fixed - -- **`gflow auth list` no longer fails on a profile whose `.gflow_account` is - damaged.** The reader decoded as UTF-8 and caught only `OSError`, so a - non-UTF-8 or truncated file raised out of `list_profiles()` and broke the - listing for *every* profile, not just the damaged one. The value is also - interpolated into a DOM attribute selector, where a stray quote produced an - untyped failure; unusable content now reads as "no account recorded", which - every caller already handles. - ### Changed - **`gflow auth login` closes the browser for you.** It drives your real Google Chrome @@ -4839,7 +4845,8 @@ shell-script template that branches on these codes. First skeleton. Not functional end-to-end yet. -[Unreleased]: https://github.com/ffroliva/gflow-cli/compare/v0.72.0...HEAD +[Unreleased]: https://github.com/ffroliva/gflow-cli/compare/v0.73.0...HEAD +[0.73.0]: https://github.com/ffroliva/gflow-cli/compare/v0.72.0...v0.73.0 [0.72.0]: https://github.com/ffroliva/gflow-cli/compare/v0.71.1...v0.72.0 [0.71.1]: https://github.com/ffroliva/gflow-cli/compare/v0.71.0...v0.71.1 [0.71.0]: https://github.com/ffroliva/gflow-cli/compare/v0.70.0...v0.71.0 diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 7e7aed0e..9bf3db97 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -1260,6 +1260,28 @@ The occluder is named by tag plus framework class only. That is deliberate — a Flow page carries the account email and signed media URLs on exactly the elements that tend to occlude things, and this message is printed, logged, and pasted into issues. +### `gflow auth login --account` reports a mismatch but leaves the profile in place + +- **Status:** Open · **Severity:** Medium (no data loss; the risk is *which account pays*) · **Affects:** `gflow auth login --account `, v0.73.0 onward · **Tracked:** [#773](https://github.com/ffroliva/gflow-cli/issues/773) item 3 + +`--account` asserts that the login authenticated as the account you named, and a mismatch +raises **exit 38** with one `auth.account_assert_failed` log line. What it does **not** do is +quarantine, rename or otherwise mark the profile — so a later run re-reads a profile +authenticated as somebody else, with nothing persisted to say so. On a product that bills +generations to the signed-in Google account, that is the wrong account paying. + +This was a deliberate "minimum" in review round 4 of +[#764](https://github.com/ffroliva/gflow-cli/pull/764), recorded here so the decision stays +revisitable rather than lost in a merged thread. + +**Workaround:** after any exit 38 from `--account`, check `gflow auth list` and re-run +`gflow auth login --account ` for that profile before generating. Do not assume the +failed assert left the profile unusable — it is usable, just possibly as the wrong person. + +Two further items on the same issue are unfixed and worth knowing about: a second chooser +hop (chooser → consent → chooser) is not handled and degrades into the landing timeout, and +that timeout is still an unmeasured number. + ### Auth verification depends on Google's NextAuth session endpoint - **Status:** Mitigated · **Severity:** Low (degrades fail-closed) · **Affects:** issue #15 fix onward · **Tracked:** issue #15 diff --git a/docs/INDEX.md b/docs/INDEX.md index 32023578..63fd7dd4 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -160,7 +160,7 @@ Slash commands for Claude Code, stored in `.claude/commands/gflow/`. All prefixe **"A command failed — where is the incident bundle and what's in it?"** → [DEBUGGING § Automatic incident bundles](DEBUGGING.md#automatic-incident-bundles) (layout, triggers, review-before-sharing); privacy boundaries: [SECURITY § Automatic incident bundles](SECURITY.md#automatic-incident-bundles-gflow_cli_incident_capture-default-on); disable via [CONFIGURATION § GFLOW_CLI_INCIDENT_CAPTURE](CONFIGURATION.md#gflow_cli_incident_capture) **"Flow's UI broke a selector — how do I diagnose it?"** → [DEBUGGING § Inspecting Flow's live UI](DEBUGGING.md#inspecting-flows-live-ui) **"What does each `ui_automation.*` log event mean?"** → [DEBUGGING § Listener & HTTP-layer debugging](DEBUGGING.md#listener--http-layer-debugging) -**What was actually live-verified for the latest release?** → latest: [LIVE_VERIFICATION_v0.72.0](LIVE_VERIFICATION_v0.72.0.md) (**auto-closing `gflow auth login`, verified 2026-09-09 on a migrated account at $0 across six runs.** Four paths: auto-close fires (`probe=in_context` at `elapsed_s` **84.8**, reproduced 3x), a manual close still verifies from disk (exit 0, never exit 12), `--browser auto` selects and completes, and a window closed **mid-2FA** is noticed in **2 s** (exit 8) where it previously ran the full 600 s deadline with the session endpoint touched **zero** times. **Two runs were discarded rather than counted**, and that is the useful part: the first returned exit 0 at `elapsed_s: 0.2` because a throwaway profile had survived the previous session and nobody signed in — a cached session reading as a clean pass — and three attempts at the manual-close path all returned exit 0 while being the *auto-close* path in disguise, separable only by an **absent** `probe=in_context`. The manual path was obtained by widening the poll interval for one run so the competing branch could not fire; the 2FA path by killing the profile's own Chrome, because hand-timing lost every race on a host that completes passkey sign-in in ~15 s. **Recorded as NOT verified rather than omitted:** the migrated-host image slice (#692) was **re-run this cycle** — CLI `t2i` PASSED first-hand on a moved account, while the queued-MCP `i2i` twin failed with a typed non-retryable exit 27 whose **cause is unknown** — some dialog opened after the file was chosen and no `maseQ` left the page, on an account that has ALREADY consented, so the message's "most likely the one-time upload-terms dialog" does not fit; no incident bundle was written, so nothing names the dialog, and it is filed for a spike rather than guessed; the subprocess fallback is unit-tested only; and the OAuth-callback *mechanism* behind the poll fix is **inferred, not proven** — a host-only gate does not exclude NextAuth's callback, since it runs on the app's own origin, yet all four sign-ins passed under it, so #769 carries the spike that would settle it.) · previous: [LIVE_VERIFICATION_v0.71.1](LIVE_VERIFICATION_v0.71.1.md) (**two migrated-host error paths stopped blaming the wrong thing, 2026-09-08, $0 across three profiles.** The agent-mode recovery landed earlier in this same release (#749) and worked, but its first cut collapsed three outcomes into one message — chip clicked / chip found but click blocked / chip clicked and mode still on — so a modal eating the click sent users to toggle a chip that was never the problem, and genuine selector drift *after* the mode was left was filed under an account setting the driver had already changed. Verified by an e2e that drives the account INTO agent mode and carries its own neutered-selector control; run **twice**, because review fixes landed after the first run and it no longer covered the code. **The larger find:** an account's FIRST upload on this host is blocked by Flow's one-time *"Rights to use this image"* dialog, which renders *after* the file chooser hands the file over — so `_dismiss_dialog` never sees it and the driver spent 60 s waiting for a request the page had already declined to make, then advised re-encoding the image. Six runs on `ci-probe` close the chain: guard fires while the dialog is up → owner-authorised accept → uploads (`media_id 8914400f`) → fresh session uploads clean with `dialog: None`. The order was forced, because the dialog is one-off and accepting it destroys the failing state — so the guard was written and verified BEFORE anything was clicked, and its firing branch is now unrepeatable here. `maseQ` is confirmed **not** renamed (six uploads, three profiles), killing #719's top hypothesis. Also: Flow's queue listing is on **`Zzl0ze`**, not the `jwpduf`/`as29s` progress polls, and two `abra_r2v_8s` records sat at status 6 for ~20 h unseen by any CLI command (#741). Three documents are corrected rather than patched — a KNOWN_ISSUES entry that told #719's reader this dialog did not affect gflow-cli, and a v0.71.0 host label calling `ci-probe` *labs* when it is migrated, which helped a credit-based theory survive four runs. Recorded as NOT verified: #719's second failure shape — an upload request that leaves the page and is never answered, ~1 run in 4 — which is unfixed and keeps #719 open.) · previous: [LIVE_VERIFICATION_v0.71.0](LIVE_VERIFICATION_v0.71.0.md) (**`gflow character create --voice` verified end to end for the first time, 2026-09-07 on profile `ffroliva`.** Before this release a repo-wide grep for `--voice` across `tests/e2e/` matched **nothing** — every voice test was a unit test of the hardcoded `VOICES` constant and the one that looked live parsed a fixture, so a voice that silently failed to attach was invisible to the whole suite while the command exited 0. A live create + `character show` read-back settles a contradiction between two of our own documents: `sent='Charon' stored='Charon' identical=True`, so the Capitalized canonical form round-trips and `CHARACTER_RECON.md`'s "preset id is the lowercased name" does not describe today's wire. Both docs also now record that `personalityNotes` is **Agent-scoped** per Flow's own editor copy, not a control on the audio engine. **A claim that was in the code is overturned:** the `_unported_form` entity guard said the submit "never produces a reply" and that the backend refuses — in fact `MZZa6b` replies with a null payload and the generation is **accepted and queued** (Flow types it `abra_r2v_8s` and renders it); the failure is the *observer*, which waits out `SUBMIT_REPLY_BUDGET_S` (60 s, calibrated on 4.0–4.6 s replies against an idle queue) and exits 9 while the video is still rendering. The guard stays until the observer is fixed, because a timeout reported on a healthy generation is worse than an explicit refusal. Also verified: credit shortfall reports exit **37** with `_raise_if_out_of_credits` called from **both** give-up paths — and it is *short for the selected model*, not empty (the measured account held **50** and asked for `veo-quality`, which costs **100**); the "+ New project" CTA anchored structurally on `add` (Tier-1 matches **1** where every previous entry matched **0**, control 47 ligature nodes) after this same release first asserted the control did not exist and a $0 run disproved it in one click; and the incident-bundle DOM dump de-blinded — it queried `i.google-symbols` only, so **every bundle a migrated user sent carried an empty ligature list**, which is why #727 and #731 stayed invisible. One item recorded as NOT verified rather than omitted: whether a bound character's **voice reaches rendered audio** (#738) — attachment is proven, application is not, because no entity-bound generation has yet returned a file to measure. The blocker is no longer credits or account access but retrieval, and the measurement is pre-calibrated: plate-bound takes of one character gave **88 / 103 / 118 Hz** against a **4.3 Hz** engine noise floor, so an applied Algenib should sit near its sample's **163.3 Hz**.) · previous: [LIVE_VERIFICATION_v0.70.0](LIVE_VERIFICATION_v0.70.0.md) (**reference-to-video on the migrated `flow.google.com` host, verified 2026-09-06 on profile `denon82`.** Three r2v runs bound their references every time; the decisive one is semantic rather than structural — two hand-drawn reference images went in and the clip came back carrying the same marker strokes, face and paper texture, which is what separates "Flow bound the reference" from "Flow accepted and ignored it". A four-beat, two-character piece was then produced end to end and joined with the concat filter: 19.000s, 1280x576, `clip_qa` **ok**, A/V **+0.000s**, both actors recognisable across every cut from one face plate plus the canon repeated verbatim. Also verified as exit-code corrections on the real CLI: `-o ` now exits **2 in 0.8s** instead of exit 1 after ~2 min **and a billed, orphaned clip**; `character create` on a moved account exits **36 immediately** instead of a bare `RuntimeError` exit 1 after a 20s wait. Four items recorded as NOT verified rather than omitted: #692's original failure could not be reproduced [the reporter's own re-run was on a build without the fix, so it shows the failure is intermittent, not that the fix works]; Flow CHARACTER entities were never exercised because `character create` cannot run on any account available here; `image`/`scene`/`movie`/`extend` all exit 36 on the migrated host and no unmoved account exists here; and the sign-in-interstitial fidelity question from #699 has no bundle evidence either way.) · previous: [LIVE_VERIFICATION_v0.68.0](LIVE_VERIFICATION_v0.68.0.md) (`gflow update` self-upgrading a real `uv tool` and a real `pipx` install from their own running `gflow.exe` to the PyPI release, Windows 11, zero credits — see its own row below) · previous: [LIVE_VERIFICATION_v0.67.0](LIVE_VERIFICATION_v0.67.0.md) (the migrated `flow.google.com` driver — see its own row below) · previous: [LIVE_VERIFICATION_v0.64.0](LIVE_VERIFICATION_v0.64.0.md) (**#626 `--model omni-flash --end-frame` — first+last interpolation on Omni 1.1 Flash, verified 2026-09-02.** The guard this release removes was evidence-gated, so it was retired on evidence: a route-aborted submit capture on **two distinct Google accounts** (`ffroliva`, `denon82`) fired `video:batchAsyncGenerateVideoStartAndEndImage` with `startImage` AND `endImage` non-null at **zero credits**, ruling out a single-account artifact. The decisive layer is semantic, not structural: the paid 4s generation's **last frame IS the supplied end image** and its first frame is the start image — two visibly different subjects — which is the only check that distinguishes "Flow bound the end frame" from "Flow accepted and ignored it", and which layers 1–4 would pass either way. Render: 4.01s / 720×1280 / h264+aac / 2.4 MB, `MEDIA_GENERATION_STATUS_SUCCESSFUL`. The static capability table is **deleted**, not corrected — it went stale silently once and would again; a post-submit route check (`_assert_i2v_route`) now fails a run whose end frame Flow dropped back to the `StartImage` route, catching a rollback on any account without anyone re-reading a support page. One item is recorded as NOT verified rather than omitted: `--duration 10` + end frame is **submit-verified only** (`duration_set seconds=10` on the correct route with both images bound), because the status poll returned HTTP 401 mid-run and a retry 401'd at `project.create` before submitting — [#561](https://github.com/ffroliva/gflow-cli/issues/561), pre-existing, so verification was stopped rather than spending further credits on an environmental fault.) · previous: [LIVE_VERIFICATION_v0.63.0](LIVE_VERIFICATION_v0.63.0.md) (**`gflow video extend` — continuing a clip past Flow's 8s ceiling, verified 2026-09-01 on profile `ffroliva`, 20 Veo credits.** The decisive observation is that segment 2's `source_media_id` **is** segment 1's `media_id` (`0c9364f3…` seeded from `b9458021…`, then `648f9291…` seeded from `0c9364f3…`) — tail-only chaining, which no mock can establish; had it re-seeded from the source, the output would have been two divergent continuations of the same moment instead of one continuous shot. `extend_model_resolved` logged `candidate_count=99` against `SERVICE_TIER_INTERMEDIATE`, proving the key came from the live capability listing rather than a constant, and plan cost matched actual spend (`20 credits total, balance 875` → `Extended — 2/2 segment(s), 20 credits`). Render: 23.02s / 1280×720 / 24fps / h264+aac. **The run found a defect the offline suite is structurally incapable of seeing:** an extend segment carries **7.000000s** of content, not the 8 Flow advertises and bills, so server-side concat pads every internal seam with a frozen frame and digital silence (15s at −75.1 dB against −29 neighbours, reproduced on a second independent render) — filed in KNOWN_ISSUES with the three questions that must be answered before any clamp, and the reason `--extend N` on t2v/i2v was deliberately NOT shipped. Seven items are recorded as NOT verified rather than omitted: portrait `9:16`, `--aspect` against a mismatched source, chains longer than 2 segments, `--resume-from` against a live partial scene, the insufficient-credits refusal, a live Ctrl+C, and `OperationKind.EXTEND` rows.) · previous: [LIVE_VERIFICATION_v0.62.1](LIVE_VERIFICATION_v0.62.1.md) (**#604 `--model omni-flash` selects again after Flow's `Omni Flash` → `Omni 1.1 Flash` rename, verified 2026-08-30 on profile `ffroliva` at ZERO Veo credits.** The production `_select_video_model` was driven against the real picker: the shipped selector resolves verbatim — `model_selected model=omni_flash via="[role='menuitem']:has-text('Omni'):has-text('Flash'):not(:has-text('[Lower Priority]'))"` — and all six cases came back as expected: the four offered tiers SELECT, a tier Flow does not offer REFUSES, and a deliberately ambiguous selector REFUSES rather than resolving `.first`. Selection happens before submit, so the whole matrix is free. The transport's own refusal diagnostic read the live menu back as `Omni 1.1 Flash / Veo 3.1 - Lite / Fast / Quality`, independently corroborating the fixture. Four items are recorded as NOT verified rather than omitted: the rename *direction* [the `Omni Flash` baseline is a different account, locale and date], an actual `Omni ... [Lower Priority]` entry [Flow has never offered one], a full credit-spending generation on omni-flash, and #539's absence question, which stays open.) · previous: [LIVE_VERIFICATION_v0.62.0](LIVE_VERIFICATION_v0.62.0.md) (**#595 `auto` ≡ `classic` for images, verified 2026-08-28 on profile `ci-probe` — an account Flow had moved to the agentic cohort the day before — at ZERO Veo credits.** With no flags and no env vars the run logged `ui_driver.ui_mode.attempt_exit_agent` → `ui_driver.bound mode=classic ui_mode=classic` and generated a real 768x1376 JPEG (exit 0); the day before, the identical command on the same account bound `auto`→`agentic` and failed. The same log independently closed **#183**: `mode_switch_trigger` and `image_mode_tab`, the selectors it reported as unfindable, both matched. #591 was proven against the real 500-row catalog — **0** occurrences of the string `"None"`, 31 proper JSON `null`s, over a catalog holding 119 NULL-bearing rows. #592 shows exactly one `client.account_locale_cached` per command. Three items are recorded as NOT verified rather than omitted: the exit-28 abort on a *pinned* agentic account, the #597 batch inter-prompt guard, and any fresh announcement modal — the last two because all three accounts have already acked the current changelog, so no modal can be raised until Google ships the next one.) · previous: [LIVE_VERIFICATION_v0.61.0](LIVE_VERIFICATION_v0.61.0.md) (**#539 video-model refusal + #586 image-model refusal and server-side attribution, verified 2026-08-26/27 on profile `denon82`, ONE Veo generation total** — the video fix was proven by a **zero-credit A/B against the stashed pre-fix source running on live Flow**: the old code returned SELECTED for `veo-lite-lp`, a model Flow does not offer to this account, and for a deliberately ambiguous selector matching 3 entries; the new code refuses both with exit 18 naming what Flow offered. Model selection happens before submit, so refusals cost nothing and the A/B was free. The happy path still generates: `--model omni-flash` → exit 0, a real 2.2 MB `ftypisom` mp4, catalog recording `omni_flash`. Two items are recorded as NOT verified rather than omitted: three of #584's four navigation-settle sites were never exercised [no bearer refresh occurred in these runs], and #582's canary self-re-exec has its first real exercise tonight. Also falsified: #539's recorded note that the video picker uses a different trigger — the two constants are byte-identical strings, and the repeated empty menu captures were the capture's own fault.) · previous: [LIVE_VERIFICATION_v0.60.0](LIVE_VERIFICATION_v0.60.0.md) +**What was actually live-verified for the latest release?** → latest: [LIVE_VERIFICATION_v0.73.0](LIVE_VERIFICATION_v0.73.0.md) (**four error paths that stopped lying about what went wrong, verified 2026-09-10 on `ci-probe` and `denon82` at $0 — no run below reaches a Veo submit.** The decisive arm is an **A/B**, not an inspection: the pre-fix run printed an `accounts.google.com` challenge URL together with the OAuth `state` and `client_id`, and the identical command after the fix gave the same exit 38 and the same landing named with **five secret matches down to zero** — reading the post-fix output alone would only have shown that *these* secrets were absent. A second arm **falsified a claim already written into PR #775's body**: it said the change fixed the RED canary, and the live run showed `denon82` had moved to `accounts.google.com` mid-run, a landing the classifier excluded **by design**, with a unit test encoding the wrong reason — the offline suite was green throughout and could not have caught it. #776's happy path is verified by a real `gflow image t2i` (exit 0, 367 135-byte JPEG, `image_settings_applied` and `prompt_typed` both present, so **three of the four converted click sites** clicked successfully through the new helper); its **failure** path is real Chromium driving a page we wrote, which proves the mechanism and explicitly **not** that Flow still produces that state. **Three items recorded as NOT verified rather than omitted:** the `/about` redirect stopped reproducing (**0/5**, reported as *unmeasured* rather than argued into a retry flag), the `/fx/api/auth/*` landing moved before the run reached it, and #764's *success* path — an external contribution whose error boundary this release verifies but whose auto-selection needs an account currently behind a Google password challenge. The fourth converted click site is the **video** submit, unrun because it spends Veo credits, named rather than implied.) · previous: [LIVE_VERIFICATION_v0.72.0](LIVE_VERIFICATION_v0.72.0.md) (**auto-closing `gflow auth login`, verified 2026-09-09 on a migrated account at $0 across six runs.** Four paths: auto-close fires (`probe=in_context` at `elapsed_s` **84.8**, reproduced 3x), a manual close still verifies from disk (exit 0, never exit 12), `--browser auto` selects and completes, and a window closed **mid-2FA** is noticed in **2 s** (exit 8) where it previously ran the full 600 s deadline with the session endpoint touched **zero** times. **Two runs were discarded rather than counted**, and that is the useful part: the first returned exit 0 at `elapsed_s: 0.2` because a throwaway profile had survived the previous session and nobody signed in — a cached session reading as a clean pass — and three attempts at the manual-close path all returned exit 0 while being the *auto-close* path in disguise, separable only by an **absent** `probe=in_context`. The manual path was obtained by widening the poll interval for one run so the competing branch could not fire; the 2FA path by killing the profile's own Chrome, because hand-timing lost every race on a host that completes passkey sign-in in ~15 s. **Recorded as NOT verified rather than omitted:** the migrated-host image slice (#692) was **re-run this cycle** — CLI `t2i` PASSED first-hand on a moved account, while the queued-MCP `i2i` twin failed with a typed non-retryable exit 27 whose **cause is unknown** — some dialog opened after the file was chosen and no `maseQ` left the page, on an account that has ALREADY consented, so the message's "most likely the one-time upload-terms dialog" does not fit; no incident bundle was written, so nothing names the dialog, and it is filed for a spike rather than guessed; the subprocess fallback is unit-tested only; and the OAuth-callback *mechanism* behind the poll fix is **inferred, not proven** — a host-only gate does not exclude NextAuth's callback, since it runs on the app's own origin, yet all four sign-ins passed under it, so #769 carries the spike that would settle it.) · previous: [LIVE_VERIFICATION_v0.71.1](LIVE_VERIFICATION_v0.71.1.md) (**two migrated-host error paths stopped blaming the wrong thing, 2026-09-08, $0 across three profiles.** The agent-mode recovery landed earlier in this same release (#749) and worked, but its first cut collapsed three outcomes into one message — chip clicked / chip found but click blocked / chip clicked and mode still on — so a modal eating the click sent users to toggle a chip that was never the problem, and genuine selector drift *after* the mode was left was filed under an account setting the driver had already changed. Verified by an e2e that drives the account INTO agent mode and carries its own neutered-selector control; run **twice**, because review fixes landed after the first run and it no longer covered the code. **The larger find:** an account's FIRST upload on this host is blocked by Flow's one-time *"Rights to use this image"* dialog, which renders *after* the file chooser hands the file over — so `_dismiss_dialog` never sees it and the driver spent 60 s waiting for a request the page had already declined to make, then advised re-encoding the image. Six runs on `ci-probe` close the chain: guard fires while the dialog is up → owner-authorised accept → uploads (`media_id 8914400f`) → fresh session uploads clean with `dialog: None`. The order was forced, because the dialog is one-off and accepting it destroys the failing state — so the guard was written and verified BEFORE anything was clicked, and its firing branch is now unrepeatable here. `maseQ` is confirmed **not** renamed (six uploads, three profiles), killing #719's top hypothesis. Also: Flow's queue listing is on **`Zzl0ze`**, not the `jwpduf`/`as29s` progress polls, and two `abra_r2v_8s` records sat at status 6 for ~20 h unseen by any CLI command (#741). Three documents are corrected rather than patched — a KNOWN_ISSUES entry that told #719's reader this dialog did not affect gflow-cli, and a v0.71.0 host label calling `ci-probe` *labs* when it is migrated, which helped a credit-based theory survive four runs. Recorded as NOT verified: #719's second failure shape — an upload request that leaves the page and is never answered, ~1 run in 4 — which is unfixed and keeps #719 open.) · previous: [LIVE_VERIFICATION_v0.71.0](LIVE_VERIFICATION_v0.71.0.md) (**`gflow character create --voice` verified end to end for the first time, 2026-09-07 on profile `ffroliva`.** Before this release a repo-wide grep for `--voice` across `tests/e2e/` matched **nothing** — every voice test was a unit test of the hardcoded `VOICES` constant and the one that looked live parsed a fixture, so a voice that silently failed to attach was invisible to the whole suite while the command exited 0. A live create + `character show` read-back settles a contradiction between two of our own documents: `sent='Charon' stored='Charon' identical=True`, so the Capitalized canonical form round-trips and `CHARACTER_RECON.md`'s "preset id is the lowercased name" does not describe today's wire. Both docs also now record that `personalityNotes` is **Agent-scoped** per Flow's own editor copy, not a control on the audio engine. **A claim that was in the code is overturned:** the `_unported_form` entity guard said the submit "never produces a reply" and that the backend refuses — in fact `MZZa6b` replies with a null payload and the generation is **accepted and queued** (Flow types it `abra_r2v_8s` and renders it); the failure is the *observer*, which waits out `SUBMIT_REPLY_BUDGET_S` (60 s, calibrated on 4.0–4.6 s replies against an idle queue) and exits 9 while the video is still rendering. The guard stays until the observer is fixed, because a timeout reported on a healthy generation is worse than an explicit refusal. Also verified: credit shortfall reports exit **37** with `_raise_if_out_of_credits` called from **both** give-up paths — and it is *short for the selected model*, not empty (the measured account held **50** and asked for `veo-quality`, which costs **100**); the "+ New project" CTA anchored structurally on `add` (Tier-1 matches **1** where every previous entry matched **0**, control 47 ligature nodes) after this same release first asserted the control did not exist and a $0 run disproved it in one click; and the incident-bundle DOM dump de-blinded — it queried `i.google-symbols` only, so **every bundle a migrated user sent carried an empty ligature list**, which is why #727 and #731 stayed invisible. One item recorded as NOT verified rather than omitted: whether a bound character's **voice reaches rendered audio** (#738) — attachment is proven, application is not, because no entity-bound generation has yet returned a file to measure. The blocker is no longer credits or account access but retrieval, and the measurement is pre-calibrated: plate-bound takes of one character gave **88 / 103 / 118 Hz** against a **4.3 Hz** engine noise floor, so an applied Algenib should sit near its sample's **163.3 Hz**.) · previous: [LIVE_VERIFICATION_v0.70.0](LIVE_VERIFICATION_v0.70.0.md) (**reference-to-video on the migrated `flow.google.com` host, verified 2026-09-06 on profile `denon82`.** Three r2v runs bound their references every time; the decisive one is semantic rather than structural — two hand-drawn reference images went in and the clip came back carrying the same marker strokes, face and paper texture, which is what separates "Flow bound the reference" from "Flow accepted and ignored it". A four-beat, two-character piece was then produced end to end and joined with the concat filter: 19.000s, 1280x576, `clip_qa` **ok**, A/V **+0.000s**, both actors recognisable across every cut from one face plate plus the canon repeated verbatim. Also verified as exit-code corrections on the real CLI: `-o ` now exits **2 in 0.8s** instead of exit 1 after ~2 min **and a billed, orphaned clip**; `character create` on a moved account exits **36 immediately** instead of a bare `RuntimeError` exit 1 after a 20s wait. Four items recorded as NOT verified rather than omitted: #692's original failure could not be reproduced [the reporter's own re-run was on a build without the fix, so it shows the failure is intermittent, not that the fix works]; Flow CHARACTER entities were never exercised because `character create` cannot run on any account available here; `image`/`scene`/`movie`/`extend` all exit 36 on the migrated host and no unmoved account exists here; and the sign-in-interstitial fidelity question from #699 has no bundle evidence either way.) · previous: [LIVE_VERIFICATION_v0.68.0](LIVE_VERIFICATION_v0.68.0.md) (`gflow update` self-upgrading a real `uv tool` and a real `pipx` install from their own running `gflow.exe` to the PyPI release, Windows 11, zero credits — see its own row below) · previous: [LIVE_VERIFICATION_v0.67.0](LIVE_VERIFICATION_v0.67.0.md) (the migrated `flow.google.com` driver — see its own row below) · previous: [LIVE_VERIFICATION_v0.64.0](LIVE_VERIFICATION_v0.64.0.md) (**#626 `--model omni-flash --end-frame` — first+last interpolation on Omni 1.1 Flash, verified 2026-09-02.** The guard this release removes was evidence-gated, so it was retired on evidence: a route-aborted submit capture on **two distinct Google accounts** (`ffroliva`, `denon82`) fired `video:batchAsyncGenerateVideoStartAndEndImage` with `startImage` AND `endImage` non-null at **zero credits**, ruling out a single-account artifact. The decisive layer is semantic, not structural: the paid 4s generation's **last frame IS the supplied end image** and its first frame is the start image — two visibly different subjects — which is the only check that distinguishes "Flow bound the end frame" from "Flow accepted and ignored it", and which layers 1–4 would pass either way. Render: 4.01s / 720×1280 / h264+aac / 2.4 MB, `MEDIA_GENERATION_STATUS_SUCCESSFUL`. The static capability table is **deleted**, not corrected — it went stale silently once and would again; a post-submit route check (`_assert_i2v_route`) now fails a run whose end frame Flow dropped back to the `StartImage` route, catching a rollback on any account without anyone re-reading a support page. One item is recorded as NOT verified rather than omitted: `--duration 10` + end frame is **submit-verified only** (`duration_set seconds=10` on the correct route with both images bound), because the status poll returned HTTP 401 mid-run and a retry 401'd at `project.create` before submitting — [#561](https://github.com/ffroliva/gflow-cli/issues/561), pre-existing, so verification was stopped rather than spending further credits on an environmental fault.) · previous: [LIVE_VERIFICATION_v0.63.0](LIVE_VERIFICATION_v0.63.0.md) (**`gflow video extend` — continuing a clip past Flow's 8s ceiling, verified 2026-09-01 on profile `ffroliva`, 20 Veo credits.** The decisive observation is that segment 2's `source_media_id` **is** segment 1's `media_id` (`0c9364f3…` seeded from `b9458021…`, then `648f9291…` seeded from `0c9364f3…`) — tail-only chaining, which no mock can establish; had it re-seeded from the source, the output would have been two divergent continuations of the same moment instead of one continuous shot. `extend_model_resolved` logged `candidate_count=99` against `SERVICE_TIER_INTERMEDIATE`, proving the key came from the live capability listing rather than a constant, and plan cost matched actual spend (`20 credits total, balance 875` → `Extended — 2/2 segment(s), 20 credits`). Render: 23.02s / 1280×720 / 24fps / h264+aac. **The run found a defect the offline suite is structurally incapable of seeing:** an extend segment carries **7.000000s** of content, not the 8 Flow advertises and bills, so server-side concat pads every internal seam with a frozen frame and digital silence (15s at −75.1 dB against −29 neighbours, reproduced on a second independent render) — filed in KNOWN_ISSUES with the three questions that must be answered before any clamp, and the reason `--extend N` on t2v/i2v was deliberately NOT shipped. Seven items are recorded as NOT verified rather than omitted: portrait `9:16`, `--aspect` against a mismatched source, chains longer than 2 segments, `--resume-from` against a live partial scene, the insufficient-credits refusal, a live Ctrl+C, and `OperationKind.EXTEND` rows.) · previous: [LIVE_VERIFICATION_v0.62.1](LIVE_VERIFICATION_v0.62.1.md) (**#604 `--model omni-flash` selects again after Flow's `Omni Flash` → `Omni 1.1 Flash` rename, verified 2026-08-30 on profile `ffroliva` at ZERO Veo credits.** The production `_select_video_model` was driven against the real picker: the shipped selector resolves verbatim — `model_selected model=omni_flash via="[role='menuitem']:has-text('Omni'):has-text('Flash'):not(:has-text('[Lower Priority]'))"` — and all six cases came back as expected: the four offered tiers SELECT, a tier Flow does not offer REFUSES, and a deliberately ambiguous selector REFUSES rather than resolving `.first`. Selection happens before submit, so the whole matrix is free. The transport's own refusal diagnostic read the live menu back as `Omni 1.1 Flash / Veo 3.1 - Lite / Fast / Quality`, independently corroborating the fixture. Four items are recorded as NOT verified rather than omitted: the rename *direction* [the `Omni Flash` baseline is a different account, locale and date], an actual `Omni ... [Lower Priority]` entry [Flow has never offered one], a full credit-spending generation on omni-flash, and #539's absence question, which stays open.) · previous: [LIVE_VERIFICATION_v0.62.0](LIVE_VERIFICATION_v0.62.0.md) (**#595 `auto` ≡ `classic` for images, verified 2026-08-28 on profile `ci-probe` — an account Flow had moved to the agentic cohort the day before — at ZERO Veo credits.** With no flags and no env vars the run logged `ui_driver.ui_mode.attempt_exit_agent` → `ui_driver.bound mode=classic ui_mode=classic` and generated a real 768x1376 JPEG (exit 0); the day before, the identical command on the same account bound `auto`→`agentic` and failed. The same log independently closed **#183**: `mode_switch_trigger` and `image_mode_tab`, the selectors it reported as unfindable, both matched. #591 was proven against the real 500-row catalog — **0** occurrences of the string `"None"`, 31 proper JSON `null`s, over a catalog holding 119 NULL-bearing rows. #592 shows exactly one `client.account_locale_cached` per command. Three items are recorded as NOT verified rather than omitted: the exit-28 abort on a *pinned* agentic account, the #597 batch inter-prompt guard, and any fresh announcement modal — the last two because all three accounts have already acked the current changelog, so no modal can be raised until Google ships the next one.) · previous: [LIVE_VERIFICATION_v0.61.0](LIVE_VERIFICATION_v0.61.0.md) (**#539 video-model refusal + #586 image-model refusal and server-side attribution, verified 2026-08-26/27 on profile `denon82`, ONE Veo generation total** — the video fix was proven by a **zero-credit A/B against the stashed pre-fix source running on live Flow**: the old code returned SELECTED for `veo-lite-lp`, a model Flow does not offer to this account, and for a deliberately ambiguous selector matching 3 entries; the new code refuses both with exit 18 naming what Flow offered. Model selection happens before submit, so refusals cost nothing and the A/B was free. The happy path still generates: `--model omni-flash` → exit 0, a real 2.2 MB `ftypisom` mp4, catalog recording `omni_flash`. Two items are recorded as NOT verified rather than omitted: three of #584's four navigation-settle sites were never exercised [no bearer refresh occurred in these runs], and #582's canary self-re-exec has its first real exercise tonight. Also falsified: #539's recorded note that the video picker uses a different trigger — the two constants are byte-identical strings, and the repeated empty menu captures were the capture's own fault.) · previous: [LIVE_VERIFICATION_v0.60.0](LIVE_VERIFICATION_v0.60.0.md) **"Was the playwright upper bound raised, and on what evidence?"** → [LIVE_VERIFICATION_playwright_1.61](LIVE_VERIFICATION_playwright_1.61.md) (**`>=1.59.0,<1.60.0` → `>=1.61.0,<1.62.0` verified 2026-08-05, 1 Imagen + 1 Veo credit** — the 2026-08-03 regression that motivated the bound does NOT reproduce on 1.61.0: a live `i2v` drove the full chain with `image_uploaded status=200` → `frame_attached` → `generate_captured status=200` with `startImage` parsed, where **1.62.0 hung silently at exactly that upload step**; live `i2i` local-ref attach passed outright [96.7s]. **1.62.0 stays excluded** — never root-caused. Also found, A/B-proven pre-existing on BOTH 1.59.0 and 1.61.0: Flow dropped the duration-tab UI, so **`--duration` is currently broken for i2v** — `UiSelectorDriftError` fires correctly [refusing rather than silently accepting Flow's default, #288] but the selector needs re-deriving; that is why the i2v proof was driven via the CLI without `--duration`, and Flow's own server-side `PUBLIC_ERROR_VIDEO_GENERATION_TIMED_OUT` means no finished mp4 is claimed). **"Where is the reverse-engineered wire protocol for a feature?"** → the `*_RECON.md` design docs: [CHARACTER_RECON](CHARACTER_RECON.md) (Flow character entity protocol), [IMAGE_UPSCALE_RECON](IMAGE_UPSCALE_RECON.md) (`/v1/flow/upsampleImage` wire). Naming convention: one `_RECON.md` per reverse-engineered surface, kept as the durable spec after the feature ships. Pre-capture research recons: [ASSET_TAGGING_RECON](ASSET_TAGGING_RECON.md) (`@`-mention asset tagging — shipped in v0.40.0, see [LIVE_VERIFICATION_v0.40.0](LIVE_VERIFICATION_v0.40.0.md)). **"What was live-verified for v0.66.1?"** → [LIVE_VERIFICATION_v0.66.1](LIVE_VERIFICATION_v0.66.1.md) — **the two migrated-origin fixes, verified 2026-09-03 on profile `ffroliva` at ZERO credits, on BOTH sides of what was then read as a flapping rollout** (superseded 2026-09-04: the handoff is a one-way per-account flag and exit 36 is non-retryable since). On a real `flow.google.com` load `get_ui_driver` raised `FlowHostMigratedError` in **0 ms** (was ~36 s: ~8 s detect window + ~24 s crop cascade + 4 s URL settle), exit 36, `retryable: true` (then; `false` since 2026-09-04), `ui_driver.migrated_host_bail` logged; `await_url_settled` returned `null` in **0 ms** (measured 4018 ms before); and the locale was recovered as `en` from `html lang=en-GB` where the URL gave `null`. The no-regression half is the stronger evidence: minutes later the same command landed on the OLD host and completed **exit 0** with a real 768x1376 JPEG in 42.2 s, proving the host guard is scoped and does not short-circuit the working path. Three items recorded as NOT verified rather than omitted: driving the migrated frontend (still impossible, #639 stays open), the pt-BR recovery live (measured `html lang=pt` on `denon82` but exercised live only on `en-GB`), and the `en-GB`→`en` region reduction against a locale where region is load-bearing (`zh-Hans`/`zh-Hant`) — only two locales observed. diff --git a/docs/LIVE_VERIFICATION_v0.73.0.md b/docs/LIVE_VERIFICATION_v0.73.0.md new file mode 100644 index 00000000..b94148e7 --- /dev/null +++ b/docs/LIVE_VERIFICATION_v0.73.0.md @@ -0,0 +1,144 @@ +# Live verification — v0.73.0 + +> What was exercised against **real** Flow for this release, and — just as importantly — +> what was **not**. + +**Dates:** 2026-09-10 · **Profiles:** `ci-probe` (`compiledgrowth…`, **migrated**, +`flow.google.com`) and `denon82` (**migrated**) · **Host:** Windows 11, real Google +Chrome · **Cost: $0** — every run below is navigation, DOM reads, an image generation +(zero Veo credits, daily quota only), or route-interception. No Veo credit was spent. + +**Seven arms. Four verified live, one verified in a real browser against a synthetic +page, two not verified — with reasons.** Nothing is left blank. + +| # | Arm | Live? | +|---|---|---| +| 1 | CLI boundary → exit 38 with a stripped URL (#777) | ✅ real run, twice | +| 2 | Token redaction A/B (#777) | ✅ measured, 5 → 0 | +| 3 | Google chooser named, not blamed on the selector (#775) | ✅ A/B on `denon82` | +| 4 | Click attribution — **happy path** (#776) | ✅ real `image t2i`, exit 0 | +| 5 | Click attribution — **failure path** (#776) | ⚠️ real browser, synthetic page | +| 6 | `/about` landing on the migrated host (#775) | ❌ does not reproduce | +| 7 | `/fx/api/auth/*` landing on a Flow host (#775) | ❌ state moved first | +| — | Chooser autoselect (#764, external PR) | ❌ not re-verified this cycle | + +--- + +## 1. Chooser boundary → exit 38, URL stripped — VERIFIED ✅ + +A real `gflow image t2i --profile ` on a profile parked at Google's account +chooser, run twice. Both exited **38** (`FlowAccountChooserError`) and named the landing +page — scheme + host + path only. + +## 2. Token redaction — VERIFIED ✅ (A/B, not inspection) + +The pre-fix run printed `accounts.google.com/v3/signin/challenge/pwd?TL=ACv9tzFkh8ZJ…` +together with the OAuth `state` and `client_id`. Re-running the identical command after +the fix: **same exit 38, same landing named, zero secret matches** (5 → 0). + +The A/B matters more than the count. Reading the post-fix output alone would only show +that *these* secrets are absent; running the same command with the fix stashed is what +shows the message ever carried them. + +## 3. A known Flow landing is named, not blamed on the selector — VERIFIED ✅ + +A pytest A/B on `denon82` while that account was genuinely sitting at a Google chooser +mid-run. With the fix neutered the run reported `UiSelectorDriftError` naming +`.settings-trigger-button`; with the fix it reported the landing. + +> **This arm falsified a claim already written into PR #775's body.** The body said the +> change "fixes the RED canary". It did not: `denon82` had moved to `accounts.google.com` +> mid-run, a landing `flow_landing_kind` excluded **by design**, with a unit test encoding +> the wrong reason. Found by the live run, fixed in `aac0ef83`, and the PR body corrected. +> The offline suite could not have caught it — it was green throughout. + +## 4. Click attribution, happy path — VERIFIED ✅ + +The #776 change routes **four** click sites through a new `_click` helper. If that helper +were wrong, every generation on the migrated host would break, so the happy path is the +load-bearing regression risk. + +``` +gflow image t2i "a single matte grey cube on a plain white studio backdrop" \ + --profile ci-probe --project 1e4efe0d-… --json +``` + +**Exit 0.** Event trace, in order: + +``` +migrated.navigate → migrated.editor_ready → migrated.image_model_selected +→ migrated.image_settings_applied → migrated.prompt_typed → (submit) → image returned +``` + +5-layer ledger: + +| Layer | Evidence | +|---|---| +| File count | 1 | +| Magic bytes | JPEG, downloaded from a signed `flow-content.google` URL | +| Size | 367 135 bytes | +| Structlog invariants | `image_settings_applied` and `prompt_typed` both present — i.e. `_open_pane` and `send_prompt` both clicked successfully through the new helper | +| User-confirmable artifact | `…/Downloads/gflow-cli/images/2026-09-10/45726039-…_1.jpg` | + +**Three of the four converted sites are covered by this run** — `_open_pane`, +`send_prompt`, and `submit_images_and_observe`. The fourth, +`submit_and_observe` (video), is the same helper with the same arguments but spends Veo +credits, so it was **not** run. Named here rather than implied. + +## 5. Click attribution, failure path — REAL BROWSER, SYNTHETIC PAGE ⚠️ + +Six BDD scenarios in real headless Chromium via route interception, **$0** +(`tests/e2e/test_click_attribution_bdd.py`). Each breaks a *different* Playwright +actionability condition for real — a stacked `div` that intercepts pointers, a CSS +animation that never lets the box settle, a pressed agent-mode chip. Playwright's own log +confirms the mechanism: + +``` +element is visible, enabled and stable +
intercepts pointer events +``` + +Went **RED 5/6 before the fix** (the pass was the A/B control) and **6/6 after**. + +**What it does not prove:** that Flow itself still produces this state. The page is ours. +That is the honest limit of route interception, and it is why this row is ⚠️ and not ✅. + +## 6. `/about` landing on the migrated host — NOT VERIFIED ❌ + +**Reason: it stopped reproducing.** A dedicated spike ran the navigation 5× on `ci-probe` +and got **0/5** — recorded as *unmeasured*, not as *transient* +([`2026-09-10-about-redirect-stability.md`](superpowers/spikes/2026-09-10-about-redirect-stability.md)). +A disappearance is equally consistent with state having changed underneath it. + +The `/about` branch is covered by unit tests and by a route-intercepted e2e; what is +unverified is that **Flow still redirects there**. Settling it needs an account Flow +actually serves `/about` to, which is not a state we can summon. + +## 7. `/fx/api/auth/*` landing on a Flow host — NOT VERIFIED ❌ + +**Reason: the state moved before it could be reached.** The 03:00 canary run that was +sitting on this landing had progressed by the time the run was attempted. Not blocked by +anything structural — simply missed, and recorded rather than dropped. + +## — Chooser autoselect (#764) — NOT RE-VERIFIED THIS CYCLE ❌ + +**Reason: external contribution, verified by its author, not re-run here.** PR #764 +(thanks @stgmt) ships the auto-selection itself. This cycle's work sat *downstream* of it +— arms 1–3 verify the **error boundary** when auto-selection cannot proceed, not the +successful selection. + +Re-running the success path needs a profile parked at a chooser with a matching +`.gflow_account`, and `denon82` — the account that reaches a chooser — is currently behind +a Google password challenge that needs a human sign-in. Follow-up hardening is tracked in +[#773](https://github.com/ffroliva/gflow-cli/issues/773), items 3–5 of which remain open. + +--- + +## What a reader should take from this + +Four arms verified against real Flow, one against a real browser driving a page we wrote, +two not verified with named reasons, and one inherited from an external PR. + +The pattern worth keeping: **arm 3 falsified a claim that was already in a PR body**, and +arm 6 returned "unmeasured" and was recorded as such rather than argued into a retry flag. +Offline green was never the thing that caught either. diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 72ce92e2..b98111e5 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -4,6 +4,53 @@ ## Current release +**v0.73.0 — alpha.** **Four error paths stopped lying about what went wrong.** + +Every fix in this release is the same shape: gflow knew something had failed, and blamed the +wrong thing. None of them changed what the tool can do — they changed what it says when it +cannot, which is the difference between a user filing a useful bug and re-running blind. + +**A click that never lands now reports what was actually true** (#776). On the migrated host +a run reached `migrated.editor_ready` and died five seconds later as a bare Playwright +`TimeoutError`: exit 1, no locator, no cause, no file. The control was *visible* — the guard +above it proves that — and the *click* expired. Two causes were live and **neither could be +measured**: Flow's announcement overlay (measured on labs.google, never on this host — a +spike read `body{pointer-events}` as `auto` in 159/159 samples, including while Flow's own +pane was open) and a mid-run agent-mode flip. So the driver reads Playwright's four +actionability conditions back and reports the ones that fired; when every reading is healthy +it **says so**, eliminating three and pointing at the fourth rather than inventing one. The +MCP surface gains more than the CLI, where the same failure had been arriving as +`detail: "sha256:…"` — a hash, not even the exception class. + +**A known Flow landing page is no longer reported as selector drift** (#756). `flow_host_kind()` +classifies the *origin*, and `/about`, `/project/` and `/fx/api/auth/signin` all share +one — so a readiness wait that timed out had nothing left to blame but its own anchor, +sending the operator to "check for a newer release, then file a bug" over a session state no +release changes. + +**Google's auth URLs no longer reach error messages with their query intact** (#777). Those +messages are the artifact users are asked to paste into an issue, and Google's auth URLs +carry `state`, `code_challenge`, `client_id` and challenge tokens. Measured, not theorised: +a real run printed five secret matches before the fix and **zero** after, with the landing +still named — knowing *where* the session stopped is the whole value of the message. + +**Google's post-migration account chooser no longer stalls a run** (#763/#764, thanks +@stgmt). gflow now auto-selects the profile's recorded account instead of stalling into an +opaque exit 1 — with the row match anchored so the chooser's *Remove* and *Sign out* rows can +never be clicked, and unselectable cases raising a typed exit 38. + +Also shipped: **the Bug Lane is now the documented route from symptom to fix** (#774) — +spike → debug → BDD → TDD → fix → e2e, written once and cited everywhere, with an offline +guard that fails CI when a browser-only scenario has no e2e test bound to it. + +See [LIVE_VERIFICATION_v0.73.0.md](LIVE_VERIFICATION_v0.73.0.md) for what was exercised +against real Flow — and what was not. Four arms verified live, one in a real browser against +a page we wrote, and three recorded as **not** verified with named reasons: `/about` stopped +reproducing (0/5), the `/fx/api/auth/*` landing moved before it could be reached, and #764's +success path needs an account currently behind a Google password challenge. + +
v0.72.0 — auth login closes the browser, and the migrated host generates images + **v0.72.0 — alpha.** **`gflow auth login` closes the browser for you, and Flow's migrated host now generates images.** @@ -41,6 +88,8 @@ See [LIVE_VERIFICATION_v0.72.0.md](LIVE_VERIFICATION_v0.72.0.md) for what was ex against real Flow — and what was not. The OAuth-callback *mechanism* is inferred rather than proven; issue #769 carries the spike that would settle it. +
+
v0.71.1 — two migrated-host failures stop blaming the wrong thing @@ -925,6 +974,7 @@ reporter-verified e2e on macOS). | Milestone | Status | |---|---| +| Four error paths stop lying about what went wrong: a click that never lands reports the actionability condition that failed instead of a bare timeout (#776), a known Flow landing is named rather than blamed on the selector (#756), Google's auth URLs are stripped from error messages (#777), and the post-migration account chooser auto-selects instead of stalling (#763/#764) | ✅ done (v0.73.0) | | `gflow auth login` closes the sign-in browser itself, on a measured retraction — G12 blocks `navigator.webdriver`, not bundled Chromium (#767); `gflow image t2i`/local-file `i2i` driven on the migrated host (#692) | ✅ done (v0.72.0) | | Two migrated-host error paths stop blaming the wrong thing: Flow's agent mode (three distinct outcomes, not one message) and its one-time upload-terms dialog (#749/#752, #719 shape A) | ✅ done (v0.71.1) | | `gflow character create --voice` verified end to end for the first time; a credit shortfall reports exit 37; incident bundles no longer blind on the migrated host | ✅ done (v0.71.0) | diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 0f5d0c70..0bdf59d8 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -51,6 +51,18 @@ Not used by v0.4.0a2's reverse-engineered Flow provider. Documented here in adva - **Location:** stdout/stderr by default. No log file unless you redirect. - **Content scrubbing:** Prompts, asset UUIDs, job IDs, profile names. No cookies, no tokens, no API keys. - The structured `error_unhandled` telemetry event is **always** SHA-256-hashed, regardless of any debug flag below — this guarantee is unconditional. +- **Google auth URLs are stripped before they reach a message** (v0.73.0). A typed + `GFlowError`'s `detail` is printed raw to the console, shipped through structlog and + emitted under `--json` — it is the artifact users are asked to paste into an issue — and + Google's auth URLs carry `state`, `code_challenge`, `client_id` and challenge tokens + (`TL=…`). `safe_page_url()` keeps scheme + host + path and drops query and fragment; the + landing is still named, because knowing *where* the session stopped is the whole value of + the message. Measured, not assumed: an identical real run went from five secret matches + to zero. +- **Note the asymmetry this creates.** An *unhandled* exception is hashed (above); a + **typed** one is not. Retyping a raise site therefore moves its text from hashed + telemetry into plain output, so any DOM- or URL-derived content added to a `detail` must + be an allowlist at the raise site — nothing downstream will catch it. ### Automatic incident bundles (`GFLOW_CLI_INCIDENT_CAPTURE`, default on) diff --git a/docs/superpowers/plans/2026-09-10-776-attributable-click-timeouts/PREDICT.md b/docs/superpowers/plans/2026-09-10-776-attributable-click-timeouts/PREDICT.md deleted file mode 100644 index aa6930ec..00000000 --- a/docs/superpowers/plans/2026-09-10-776-attributable-click-timeouts/PREDICT.md +++ /dev/null @@ -1,174 +0,0 @@ -# Predict: attribute the migrated driver's click timeouts (#776) - -## Verdict on the proposal as submitted: **STOP** -**Confidence: 5.2/10** (mean 7.0, −2 Devil's Advocate found a simpler path the others missed; any STOP is a STOP) - -## Verdict on the revised proposal below: **CAUTION → proceed with mitigations** - -## Summary - -The proposal was: *port #593's overlay guard to the migrated driver, add a shared -`body{pointer-events:none}` + hit-test probe to `_common.py`, and call it pre-click.* - -Four personas returned GO/CAUTION on the mechanics. The Devil's Advocate returned STOP on -the **premise**, and it is right: the proposal picks a cause. The live spike run the same -hour independently agrees — the overlay mechanism is **unmeasured on this host** (0/3, -[`2026-09-10-migrated-click-blocked.md`](../../spikes/2026-09-10-migrated-click-blocked.md)). -A guard built on an unmeasured cause does not just fail to fire; it produces a -**confidently wrong** message, which #770 is already a live precedent for. - -## Persona findings - -### Architect — GO (8/10) -`_common.py` is the right home, and for a load-bearing reason nobody had stated: there is -an **existing import cycle** — `ui_automation.py:44` imports `migrated_composer`, and -`migrated_composer.py:1921` imports back with `# noqa: PLC0415 - cycle`. So the migrated -driver *cannot* top-level-import from `ui_automation.py`; `_common.py` has zero -intra-`transports` imports and is the only acyclic leaf both drivers already reach. -Recommends a `@staticmethod async def _click(...)` on `MigratedComposer` over a decorator, -matching `_dismiss_dialog`'s existing shape. Warns explicitly against harmonising the labs -driver's clicks in the same PR. - -### Security / reCAPTCHA — CAUTION (8/10) -The strongest finding of the five. **Converting a bare `TimeoutError` into a typed error -removes an accidental privacy net.** Verified in source: `_handle_unhandled_error` -(`_cli_helpers.py:324`) prints a generic message and SHA-256-hashes the telemetry, while -`_handle_gflow_error` (`_cli_helpers.py:304`) prints `exc.detail` **raw**, and -`json_output.py:55` ships it verbatim under `--json`. `redact_error_detail` is wired only -at the SQLite boundary — **not** on the console, structlog, or `--json` paths. - -So any DOM text this fix puts in `detail` is guaranteed to be printed, logged, and (by the -class's own remediation hint) invited into a GitHub issue. An occluding element can carry -an account email in `aria-label`/`title`, or a signed media URL in `src`. This is the exact -class of bug PR #777 fixed two hours ago. - -> **Mandatory:** the occluder report must be a **closed allowlist** — tag name plus a match -> against a fixed set of structural overlay markers. Never `outerHTML`, `textContent`, -> `aria-label`, `title`, `alt`, `src`, `href`, or an attribute dump. Any free-form DOM -> string must pass `redact_sensitive_text()` at the raise site. - -### Performance / Playwright — GO with a scope correction (8/10) -"Pre-click at modal-prone epochs" is ambiguous and the two readings differ by 4×: a real -r2v run makes **~16** clicks, while the labs guard it is modelled on runs at exactly **3** -sites. Worse, `_require_unblocked` has no de-duplication — on a genuinely blocked page each -call independently re-probes, waits ~1 s of jitter, re-attempts dismissal and re-probes, so -8+ pre-emptive sites would add 16–24 s of redundant latency before finally raising. No Page -pool or `__aexit__` risk: every `_checkout_page` is `try/finally`-paired. - -### CLI / MCP UX — CAUTION (8/10) -Three findings that change the implementation: - -**Exit 23 is right; do not mint a new code.** #593 already raises `UiSelectorDriftError` -for "an overlay is still covering the app" (`ui_automation.py:1345`). The project's own bar -for a new code is a *materially different caller action* (`errors.py:562`, `:594`), and -"dismiss the modal and re-run" is not different from 23's existing remediation. One -docstring line should acknowledge that the class covers *occluded*, not only *missing* — -#593 stretched it there already and the docs never caught up. - -**MCP is currently worse than the CLI, and this fix is the whole repair.** On the queued -path a non-`GFlowError` hits `worker/daemon.py:441-475`'s `else` branch, which ships -`"detail": f"sha256:{exception_message_hash(exc)}"` — a hash, not even the class name. Once -the raise site becomes a `GFlowError`, `daemon.py:449`'s `isinstance` branch fires instead -and the agent gets full problem details plus `exit_code=23`. Same transport, one fix, both -doors — but it must be *run* on the MCP path, not inferred. - -**The reporter may have seen nothing at all in `--json`.** `unexpected_payload()` -(`json_output.py:83`) emits no detail and no exception class without a debug flag, so the -`exception_class=TimeoutError` they quoted came from the **stderr structlog** event, not -stdout. An adapter reading only stdout got a bare failure. Worth telling them. - -It also flagged, independently of the Devil's Advocate, that a pre-click guard contradicts -a rule this codebase already learned: `_common.py:205-221` — *"Call this from inside a -failure branch … never before it … a guard placed ahead of the probe deletes the evidence -that would correct it."* - -### Devil's Advocate — STOP (3/10) -**Found the thing that changes the design.** [#752 finding #7](https://github.com/ffroliva/gflow-cli/issues/752), -a maintainer-authored review written *before* #776 was filed, predicts this exact symptom -at this exact function: - -> `_open_pane` still guards with `count()`, not visibility … a mode flip between -> `ensure_editor` and `apply_video_settings` escapes as a **bare Playwright TimeoutError -> with no exit-23 mapping and no mention of agent mode**. - -Half of that was fixed — `:870` became `wait_for(state="visible")`, and its comment at -`:866-869` spells the failure out. **The very next line, `:884`, is the click, still -unguarded.** The file documents the bug it still has, one line above it. - -Agent mode hides the trigger with a bare `hidden` attribute — it never touches -`body{pointer-events:none}`. So the proposed probe would return "not blocked" and the fix -would report the wrong cause. - -## High-confidence risks (2+ personas) - -1. **The proposal picks a cause it cannot see.** (Devil's Advocate STOP; Security Finding 4 - caveat; the spike's 0/3.) Playwright's actionability gate has four conditions — visible, - stable, receives-events, enabled. A body-`pointer-events` probe speaks to exactly one. -2. **A wrong typed message is worse than an honest bare one.** (Devil's Advocate; Security - Finding 2.) #770 is the live precedent. -3. **Blanket-converting 18 sites collides with open #759**, which was filed against this - very file for narrative duplication. (Devil's Advocate; Architect's scope-creep warning.) - -## Conflicts resolved - -- **Performance says "pre-emptive at epochs"; Devil's Advocate says "don't build the guard at all."** - Resolved in favour of the Devil's Advocate, on evidence Performance did not have: the spike - measured `body_pointer_events: auto` in **159/159** samples — including *while the settings - pane was open*. Angular CDK blocks with a `.cdk-overlay-backdrop` element, not by muting the - body, so on this host the **hit-test is the load-bearing detector and the body property is - the labs mechanism**. A pre-emptive body probe here would guard a mechanism this frontend - does not appear to use. -- **Architect says extract to `_common.py`; Devil's Advocate says that is a bigger structural - change than it looks.** Both hold: extraction is right *if* something shared is needed. Under - the revised proposal the read is migrated-host-specific and single-caller, so it stays local - until a second caller exists. The Architect's cycle finding remains the constraint if that - changes. - -## The revised proposal - -**Do not guess the cause. Read it, at the moment of failure, and report what was true.** - -1. One `_click` helper on `MigratedComposer`. On a Playwright timeout it performs a - post-mortem read and raises `UiSelectorDriftError` (exit 23) naming the locator and the - condition that actually failed: - - the agent-mode chip (`_agent_chip_pressed`, already exists at `:684`) — #752's cause - - `hidden` / `disabled` — the *visible* and *enabled* conditions - - `body{pointer-events}` + an allowlisted hit-test occluder — the *receives-events* condition - - none of the above ⇒ say exactly that; it rules out three and points at *stable* -2. **Zero cost on the happy path** — the read runs only in the `except` branch. -3. Applied to four sites with a named reason each, not eighteen: `:884` (#776's site), - `:1598` (named in `_close_pane`'s own docstring as historically failing this way), and - `:1725` / `:1858` (the credit-spending submits, where "did it submit?" is unanswerable today). - -### Required mitigations before EXECUTE - -1. **Allowlist the occluder report.** Tag name, plus only those classes matching a fixed - structural prefix set (`cdk-`, `mat-`, `mdc-`, `flow-`), each capped — mirroring the - existing `.slice(0, 200)` convention at `ui_automation.py:2472`. Never `outerHTML`, - `textContent`, `aria-label`, `title`, `alt`, `src`, `href`, or a generic attribute dump. - Pass the assembled detail through `redact_sensitive_text()` at the raise site. (Security + - CLI/MCP UX, reconciled: an allowlist *and* a bound.) -2. **Put the locator before the variable-length class blob in the message.** The queued MCP - path raw-slices `detail` to 500 chars (`data/redaction.py:117`) while the CLI path does - not; ordering keeps both surfaces showing the same essential text. (CLI/MCP UX) -3. **No pre-emptive guard, no shared `_common.py` probe** until a second caller or a measured - cause justifies one. Three personas and the spike converged here, and `_common.py:205-221` - already states the rule. (Devil's Advocate, CLI/MCP UX, Performance, spike) -4. **Exit 23, and add the missing docstring line** acknowledging *occluded* alongside - *missing*. No new exit code. (CLI/MCP UX) -5. **Preserve `retryable`.** Today's failure is non-retryable; the condition does not reproduce, - so per the Bug Lane's "A flag is a claim" middle row this is *preserved, not measured*. - `UiSelectorDriftError` is not in `RETRYABLE_ERRORS`, so the default already preserves it — - assert that in a test rather than leaving it to survive by luck. -6. **One helper, not eighteen message blocks** — #759. -7. **Run the MCP twin.** The fix flips `daemon.py:449`'s branch from the hashed `else` to the - `GFlowError` path; that is the larger half of the repair and the Iron Law applies to it - separately. (CLI/MCP UX) -8. **Verify via the raised error and the log line, not the incident bundle** — #722 blanks the - capture on this path. - -## Recommended next step - -Phase 3 — `/gflow:scenario`. The scenario is browser-only (a click that fails Playwright's -actionability gate cannot be expressed by a mocked page), so per the Bug Lane it binds to a -route-intercepted e2e in `tests/e2e/`, tagged `@e2e @e2e_auth`. diff --git a/docs/superpowers/plans/2026-09-10-776-attributable-click-timeouts/SCENARIO.md b/docs/superpowers/plans/2026-09-10-776-attributable-click-timeouts/SCENARIO.md deleted file mode 100644 index 7c81c442..00000000 --- a/docs/superpowers/plans/2026-09-10-776-attributable-click-timeouts/SCENARIO.md +++ /dev/null @@ -1,111 +0,0 @@ -# Scenario: attributable click timeouts on the migrated composer (#776) - -Feeds from [`PREDICT.md`](PREDICT.md) (STOP on the original proposal → CAUTION on the -revised one) and the spike -[`2026-09-10-migrated-click-blocked.md`](../../spikes/2026-09-10-migrated-click-blocked.md). - -## Coverage map - -| Dim | Active? | Why | -|---|---|---| -| **D3** Selector drift & locale invariance | **Yes — primary** | The whole change is what a failed click reports. The occluder must be named structurally; a translated label would violate AGENTS.md and be useless to a zh-CN reporter (#776 is one) | -| **D7** Error propagation & exit codes | **Yes — primary** | Bare `TimeoutError`/exit 1 → `UiSelectorDriftError`/exit 23. `retryable` must not move as a side effect | -| **D12** Observability | **Yes** | `detail` now reaches console raw, structlog, and `--json`. New event/field names are a contract | -| **D13** MCP parity | **Yes — the larger half** | The queued path currently hashes the detail away entirely (`daemon.py:441-475` `else`). The fix flips it to the `GFlowError` branch | -| **D10** Headless vs headed | **Yes** | The probe runs `page.evaluate` on a real page; must not break when the page is mid-teardown | -| **D8** Cross-platform | Partial | #776 is a Windows report, but the failure is upstream of any path handling. Only console encoding matters, already covered by `cli.py:76-82` | -| D1 auth · D2 WAF · D4 batch · D5 concurrency · D6 data · D9 transport · D11 input | No | The change is confined to one driver's failure branch. No auth, no wire call, no schema, no new input | - -## Scenario table - -| # | Dim | Scenario | Severity | Expected behaviour | Test category | -|---|---|---|---|---|---| -| 1 | D7/D3 | The settings trigger is visible but the click never lands; the **agent-mode chip is pressed** | **Critical** | `UiSelectorDriftError` (23) naming *agent mode*, not an overlay. This is #752 finding #7's predicted cause | E2E (BDD) | -| 2 | D7/D3 | The click never lands because an **element covers** the trigger | **Critical** | `UiSelectorDriftError` (23) naming the occluder by tag + structural class | E2E (BDD) | -| 3 | D7 | The click never lands and **every probe reads healthy** | **High** | The error says exactly that — visible, enabled, hit-testable — instead of inventing a cause. Rules out three of Playwright's four conditions and points at *stable* | E2E (BDD) | -| 4 | D7 | **A/B control** — an unobstructed trigger | **Critical** | The click lands, nothing is raised, no probe runs. Without this, scenarios 1–3 could pass against a helper that always raises | E2E (BDD) | -| 5 | D3/D12 | The occluding element carries an **account-identifying attribute** (`aria-label` with an email, `src` with a signed URL) | **Critical** | Neither appears anywhere in the message. Security persona's mandated regression test; PR #777 was this exact bug class | E2E (BDD) | -| 6 | D7 | `retryable` after the change | **High** | Still `False` — *preserved, not measured* (Bug Lane "A flag is a claim", middle row) | Unit | -| 7 | D12 | The occluder's class list is pathologically long | Medium | Capped client-side so the queued path's 500-char slice (`data/redaction.py:117`) cannot clip the remediation off | Unit | -| 8 | D13 | The same failure over **MCP** | **Critical** | Reaches the agent as RFC 9457 problem details with `exit_code: 23`, not `sha256:…` | E2E (MCP path) | -| 9 | D10 | The page is closed/navigating when the post-mortem read runs | High | The probe returns "unreadable" and the error still raises, naming the locator. A diagnostic must never replace the failure it is describing | E2E (BDD) | -| 10 | D7 | A click failing for a **non-timeout** reason | Medium | Not converted — only an actionability timeout is reinterpreted | Unit | - -## Must-cover before merge (Critical + High) - -1, 2, 3, 4, 5, 6, 8, 9 — i.e. every row above except 7 and 10, which are unit-level guards. - -## Deferred - -- The other 15 bare click sites (`_select`, `_select_model`, the frame picker). Per the - Devil's Advocate and #759, each waits for its own signature rather than a blanket - conversion. The helper exists, so adopting one later is a one-line change. -- Whether Flow's announcement modal reaches the migrated host at all — **unmeasured**, and - the fix is deliberately built not to depend on the answer. - -## Suggested BDD scenarios - -Browser-only by construction: Playwright's actionability gate (attached → visible → stable -→ receives-events → enabled) is what fails, and a mocked `Page` whose `.click()` is a stub -cannot express it. Per the Bug Lane step 5, that makes these e2e. - -```gherkin -@e2e @e2e_auth -Feature: A click that never lands says why - - Scenario: A pressed agent-mode chip is named as the cause - Given a Flow project page whose settings trigger is covered - And the agent-mode chip is pressed - When the driver opens the settings pane - Then it fails with exit 23 - And the message names Flow's agent mode - And the message does not blame an overlay - - Scenario: A covering element is named by its structure - Given a Flow project page whose settings trigger is covered - When the driver opens the settings pane - Then it fails with exit 23 - And the message names the covering element by tag and structural class - - Scenario: A healthy-looking failure is reported as unexplained - Given a Flow project page whose settings trigger accepts no click - When the driver opens the settings pane - Then it fails with exit 23 - And the message reports the control as visible, enabled and hit-testable - And the message does not name a cause it did not observe - - Scenario: An unobstructed trigger still opens the pane - Given a Flow project page whose settings trigger is clickable - When the driver opens the settings pane - Then the pane opens and nothing is raised - - Scenario: An account identifier on the covering element never reaches the message - Given a Flow project page whose settings trigger is covered - And the covering element carries an account email and a signed media URL - When the driver opens the settings pane - Then it fails with exit 23 - And the message contains neither the account email nor the signed URL - - Scenario: A page that cannot be read still reports the failed locator - Given a Flow project page whose settings trigger is covered - And the page stops answering probes - When the driver opens the settings pane - Then it fails with exit 23 - And the message names the settings trigger -``` - -Binding: `tests/e2e/test_click_attribution_bdd.py` via -`scenarios("../features/click_attribution.feature")`. One feature, one module — -`tests/features/test_e2e_binding_guard.py` enforces that offline. - -## Known-issues cross-reference - -| Entry | Relationship | -|---|---| -| [#752](https://github.com/ffroliva/gflow-cli/issues/752) finding #7 | **Predicted this symptom at this function.** The `wait_for` half was fixed; the click was not. Scenario 1 is that finding's regression test | -| [#749](https://github.com/ffroliva/gflow-cli/issues/749) / KNOWN_ISSUES "agent-mode chip hides the settings trigger" | Same mechanism, one gate later | -| [#593](https://github.com/ffroliva/gflow-cli/issues/593) / KNOWN_ISSUES "changelog modal wedges" | The labs precedent. Scenario 2 covers the shape **without** asserting it occurs on this host | -| [#722](https://github.com/ffroliva/gflow-cli/issues/722) | Blanks the incident bundle on this path — verification must lean on the raised error and the log line | -| [#759](https://github.com/ffroliva/gflow-cli/issues/759) | Comment bloat in this file. One helper, not eighteen message blocks | -| [#770](https://github.com/ffroliva/gflow-cli/issues/770) | Live precedent for a typed "most likely" message being wrong. Scenario 3 is the direct countermeasure | -| [#643](https://github.com/ffroliva/gflow-cli/issues/643) | The reporter's locale error. Measured irrelevant by the spike — 3/3 reproduced it while the click landed | diff --git a/docs/superpowers/spikes/2026-09-10-migrated-click-blocked.md b/docs/superpowers/spikes/2026-09-10-migrated-click-blocked.md index fa793eb3..df357eb4 100644 --- a/docs/superpowers/spikes/2026-09-10-migrated-click-blocked.md +++ b/docs/superpowers/spikes/2026-09-10-migrated-click-blocked.md @@ -105,6 +105,17 @@ is structural, so no locale can hide it.) ## What this means for the fix +> **This result turned a `predict` GO into a STOP.** The proposal it was gating was +> "port #593's overlay guard to the migrated driver". Four of five personas returned +> GO/CAUTION on the mechanics; the Devil's Advocate returned **STOP on the premise**, +> having found [#752](https://github.com/ffroliva/gflow-cli/issues/752) finding #7 — +> which predicted #776's symptom at #776's function before it was filed, and whose cause +> (a mid-run agent-mode flip) touches neither `body{pointer-events}` nor the hit test. A +> guard built on the overlay would have reported the wrong cause with confidence. The +> spike above agreed from the other direction, and the fix was redesigned to **read** +> rather than diagnose. + + The confirmed defect in #776 is **unattributability**, and that is independent of what covers the trigger. A guard built only on `body{pointer-events:none}` would catch one of Playwright's four actionability conditions (receives-events) and stay silent on the other diff --git a/pyproject.toml b/pyproject.toml index b048aeb2..0252fa2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gflow-cli" -version = "0.72.0" +version = "0.73.0" description = "Unofficial CLI for Google Flow — drive Veo image-to-video generations from the terminal." readme = "README.md" license = { file = "LICENSE" } diff --git a/src/gflow_cli/__init__.py b/src/gflow_cli/__init__.py index 1ea7e8a1..e30e90f9 100644 --- a/src/gflow_cli/__init__.py +++ b/src/gflow_cli/__init__.py @@ -1,3 +1,3 @@ """gflow-cli — unofficial CLI for Google Flow.""" -__version__ = "0.72.0" +__version__ = "0.73.0" diff --git a/uv.lock b/uv.lock index ffc18341..4bb46a8e 100644 --- a/uv.lock +++ b/uv.lock @@ -801,7 +801,7 @@ wheels = [ [[package]] name = "gflow-cli" -version = "0.72.0" +version = "0.73.0" source = { editable = "." } dependencies = [ { name = "browser-cookie3" }, diff --git a/website/docs/KNOWN_ISSUES.md b/website/docs/KNOWN_ISSUES.md index 007c91aa..5c82c977 100644 --- a/website/docs/KNOWN_ISSUES.md +++ b/website/docs/KNOWN_ISSUES.md @@ -1260,6 +1260,28 @@ The occluder is named by tag plus framework class only. That is deliberate — a Flow page carries the account email and signed media URLs on exactly the elements that tend to occlude things, and this message is printed, logged, and pasted into issues. +### `gflow auth login --account` reports a mismatch but leaves the profile in place + +- **Status:** Open · **Severity:** Medium (no data loss; the risk is *which account pays*) · **Affects:** `gflow auth login --account `, v0.73.0 onward · **Tracked:** [#773](https://github.com/ffroliva/gflow-cli/issues/773) item 3 + +`--account` asserts that the login authenticated as the account you named, and a mismatch +raises **exit 38** with one `auth.account_assert_failed` log line. What it does **not** do is +quarantine, rename or otherwise mark the profile — so a later run re-reads a profile +authenticated as somebody else, with nothing persisted to say so. On a product that bills +generations to the signed-in Google account, that is the wrong account paying. + +This was a deliberate "minimum" in review round 4 of +[#764](https://github.com/ffroliva/gflow-cli/pull/764), recorded here so the decision stays +revisitable rather than lost in a merged thread. + +**Workaround:** after any exit 38 from `--account`, check `gflow auth list` and re-run +`gflow auth login --account ` for that profile before generating. Do not assume the +failed assert left the profile unusable — it is usable, just possibly as the wrong person. + +Two further items on the same issue are unfixed and worth knowing about: a second chooser +hop (chooser → consent → chooser) is not handled and degrades into the landing timeout, and +that timeout is still an unmeasured number. + ### Auth verification depends on Google's NextAuth session endpoint - **Status:** Mitigated · **Severity:** Low (degrades fail-closed) · **Affects:** issue #15 fix onward · **Tracked:** issue #15 diff --git a/website/docs/SECURITY.md b/website/docs/SECURITY.md index 5683fd98..9574b209 100644 --- a/website/docs/SECURITY.md +++ b/website/docs/SECURITY.md @@ -51,6 +51,18 @@ Not used by v0.4.0a2's reverse-engineered Flow provider. Documented here in adva - **Location:** stdout/stderr by default. No log file unless you redirect. - **Content scrubbing:** Prompts, asset UUIDs, job IDs, profile names. No cookies, no tokens, no API keys. - The structured `error_unhandled` telemetry event is **always** SHA-256-hashed, regardless of any debug flag below — this guarantee is unconditional. +- **Google auth URLs are stripped before they reach a message** (v0.73.0). A typed + `GFlowError`'s `detail` is printed raw to the console, shipped through structlog and + emitted under `--json` — it is the artifact users are asked to paste into an issue — and + Google's auth URLs carry `state`, `code_challenge`, `client_id` and challenge tokens + (`TL=…`). `safe_page_url()` keeps scheme + host + path and drops query and fragment; the + landing is still named, because knowing *where* the session stopped is the whole value of + the message. Measured, not assumed: an identical real run went from five secret matches + to zero. +- **Note the asymmetry this creates.** An *unhandled* exception is hashed (above); a + **typed** one is not. Retyping a raise site therefore moves its text from hashed + telemetry into plain output, so any DOM- or URL-derived content added to a `detail` must + be an allowlist at the raise site — nothing downstream will catch it. ### Automatic incident bundles (`GFLOW_CLI_INCIDENT_CAPTURE`, default on)