From 7256ac20810c0ad26bc098f4d688563c47bb756f Mon Sep 17 00:00:00 2001 From: Flavio Oliva Date: Tue, 8 Sep 2026 22:50:21 +0100 Subject: [PATCH 01/12] spike(auth): G12 rejects navigator.webdriver, not Playwright (#480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gflow auth login --browser chrome` asks the user to close Chrome by hand because `RealChromeStrategy` runs it as a bare subprocess with no debugging port, so there is no signal channel. Sibling `notebooklm-py` auto-closes by owning the browser instead. Whether gflow's zero-automation-surface constraint is still load-bearing at sign-in had never been measured. Three arms, human-driven, each on a fresh unauthenticated throwaway profile, $0: bare real Chrome, no stealth flags webdriver=True BLOCKED at /v3/signin/rejected (17.5s) stealth real Chrome + stealth flags webdriver=False PASS, cookie at 59.4s bundled bundled Chromium + same flags webdriver=False PASS, cookie at 276.0s `bare` is the control that makes this conclusive. Without it two passes would have read as "the block no longer fires", which would have justified dropping the mitigations. It fires. The discriminator is not the browser binary — bundled Chromium, the browser KNOWN_ISSUES names as rejected, passed with flags, while real Chrome was blocked without them. A Playwright connection is fine; an advertised one is not. Both passing arms detected the session from the owned context and closed the window themselves, so auto-close is reachable. Also records a doc defect: KNOWN_ISSUES describes RealChromeStrategy as driving Chrome via Playwright `channel="chrome"`, which has never been true — real_chrome.py was born as passive capture at eb0de133 and `git log -S'channel="chrome"' -- src/gflow_cli/auth/` returns zero commits. Not measured: headless (every arm was headed), generation (reCAPTCHA-gated, separate), which flag does the work, and anything beyond N=1 account/machine/IP/day. Refs #480 --- ...-08-g12-blocks-webdriver-not-playwright.md | 131 +++++++++ scripts/dev/spike_playwright_chrome_login.py | 272 ++++++++++++++++++ 2 files changed, 403 insertions(+) create mode 100644 docs/superpowers/spikes/2026-09-08-g12-blocks-webdriver-not-playwright.md create mode 100644 scripts/dev/spike_playwright_chrome_login.py diff --git a/docs/superpowers/spikes/2026-09-08-g12-blocks-webdriver-not-playwright.md b/docs/superpowers/spikes/2026-09-08-g12-blocks-webdriver-not-playwright.md new file mode 100644 index 00000000..2750a541 --- /dev/null +++ b/docs/superpowers/spikes/2026-09-08-g12-blocks-webdriver-not-playwright.md @@ -0,0 +1,131 @@ +# The G12 block is alive, and `navigator.webdriver` is the discriminator (2026-09-08) + +**Question.** `gflow auth login --browser chrome` asks the user to close the Chrome +window by hand, because `RealChromeStrategy` launches Chrome as a bare subprocess with +no debugging port and therefore has no signal channel. Sibling project `notebooklm-py` +auto-closes instead, and its mechanism is not a trick — it *owns* the browser +(`page.wait_for_url(...)`, then `context.close()`), accepting an automation surface and +mitigating it. Is gflow's zero-automation-surface constraint still load-bearing at +sign-in? + +**Answer: the constraint is real, but it is finer-grained than "no automation surface".** +What Google rejects is a browser that admits to being automated. A Playwright-driven +browser that does not — either binary — signs in normally, and can then close itself. + +**Instrument.** `scripts/dev/spike_playwright_chrome_login.py`. Three arms, each on its +own **unauthenticated** throwaway profile, each driven by a human signing in by hand. +Cost `$0` — navigation and cookie reads only, nothing submitted. Profiles deleted after. + +## Observed + +| Arm | Browser | Stealth flags | `navigator.webdriver` | `/v3/signin/rejected` | Session cookie | Verdict | +|---|---|---|---|---|---|---| +| `bare` | real Chrome | **no** | `True` | **reached at t=17.5 s** | — | **BLOCKED** | +| `stealth` | real Chrome | yes | `False` | never | t=59.43 s | PASS | +| `bundled` | bundled Chromium | yes | `False` | never | t=276.01 s | PASS | + +Stealth flags = `--disable-blink-features=AutomationControlled` + +`ignore_default_args=["--enable-automation"]`. All three runs recorded +`pre_authenticated: false`, so every arm genuinely exercised the sign-in gate. + +URL trails (query strings stripped — the rejection URL carries OAuth `state` and +`code_challenge`): + +``` +bare 1.67 labs.google/fx/tools/flow + 7.81 accounts.google.com/v3/signin/identifier + 17.50 accounts.google.com/v3/signin/rejected <-- G12 + +stealth 1.78 labs.google/fx/tools/flow + 18.38 accounts.google.com/v3/signin/identifier + 26.94 accounts.google.com/v3/signin/challenge/pwd + 32.78 accounts.google.com/v3/signin/challenge/dp + 54.36 accounts.google.com.br/accounts/SetSID + 59.27 labs.google/fx/tools/flow + +bundled 1.68 labs.google/fx/tools/flow + 265.37 accounts.google.com/v3/signin/identifier + 275.31 accounts.google.com.br/accounts/SetSID +``` + +Evidence: `scripts/dev/_spike_out/spike_pw_chrome_login_{bare,stealth,bundled}_20260908_*.json` +(gitignored — the trails carry account-scoped OAuth URLs). + +## What the control establishes + +**`bare` is a positive control, and it is the reason this spike concludes anything.** +Without it, two passes would have read as "the G12 block no longer fires" — and that +conclusion would have justified dropping the mitigations. It fires. It took 17.5 s. + +Three things follow, in order of how much they change: + +1. **The block is current, not historical.** [`KNOWN_ISSUES.md`](../../../KNOWN_ISSUES.md) + *"G12 'browser not secure' block"* is marked Resolved/v0.6.0a2; the underlying Google + behaviour is still live and still rejects on `/v3/signin/rejected`. +2. **The binary is NOT the discriminator.** Bundled Chromium — the browser that entry + names as the thing Google rejects — passed, *with* flags. Real Chrome — the browser + the entire `RealChromeStrategy` exists to use — was **blocked**, *without* them. + `navigator.webdriver` tracked the outcome in all three arms. +3. **Therefore the stealth flags are load-bearing and the automation surface is not.** + A Playwright connection is fine; an advertised one is not. + +**Auto-close works.** In both passing arms the session cookie was detected from the owned +context and the script closed the window itself — `notebooklm-py`'s mechanism running on +gflow's surface. + +## Not measured — do not read these as answered + +- **Headless.** Every arm ran `headless=False`. This says *nothing* about headless in + either direction. +- **Generation.** Sign-in and generation are gated by different machinery: generation is + reCAPTCHA-Enterprise-gated (see [[flow-google-com-batchexecute-headless-proven]] — + reads already work over pure `httpx`; the generation RPC carries a ~2.4 KB Enterprise + token minted ~120 ms before submit). A login result does not move that. +- **Which flag does the work.** `--disable-blink-features=AutomationControlled` and + `ignore_default_args=["--enable-automation"]` were only ever applied together. Whether + either alone suffices is untested — keep both. +- **Anything beyond N=1.** One account, one Windows machine, one residential IP, one + Chrome build (`Chrome/149.0.0.0`), one day. Google's sign-in risk scoring varies with + account age, IP reputation and history, so this does **not** predict CI, a VPS, or a + fresh account. Same discipline as [[flow-capabilities-are-cohort-dependent]]. +- **Like-for-like risk evaluation between the passing arms.** `stealth` traversed + `challenge/pwd` and `challenge/dp`; `bundled` went identifier → SetSID in ~10 s. The + two passes are not directly comparable to each other. Neither is compromised as a + contrast against `bare`, which never got past `identifier`. + +## Defect found on the way + +[`KNOWN_ISSUES.md`](../../../KNOWN_ISSUES.md) documents the G12 resolution as +*"`RealChromeStrategy` — launches the system's real Google Chrome via Playwright's +`channel="chrome"` with stealth flags."* **That implementation does not exist.** +`src/gflow_cli/auth/real_chrome.py` was created at `eb0de133` (2026-07-19) already as +passive capture, and `git log -S'channel="chrome"' -- src/gflow_cli/auth/` returns zero +commits — the auth strategy has never used Playwright. The entry describes the design +this spike now recommends, which is why the drift went unnoticed: it reads as correct. + +## Implication for the design + +Auto-close is reachable. The shape it wants is the one `KNOWN_ISSUES.md` already claims +we have: launch real Chrome through Playwright with `channel="chrome"`, +`no_viewport=True`, `chromium_sandbox=True` and **both** stealth flags; detect the +session from the owned context; `context.close()`. + +`channel="chrome"` stays — **not** for the sign-in gate, which `bundled` shows does not +care about the binary, but because the profile produced must be a chrome-strategy profile +or `channel_for_profile()` returns `None` and generation silently downgrades to bundled +Chromium (see [[real-browser-auth-mandatory]]). + +Two instrument bugs found mid-spike, both of which would be defects in an implementation: + +1. **`viewport=` on a headed context makes the UI unusable.** Passing an explicit + `viewport={"width": 1920, "height": 1080}` makes Playwright *emulate* that size + independently of the real OS window; on a smaller or scaled display the sign-in form + renders outside the visible area and zoom cannot recover it. A human-driven window + needs `no_viewport=True`. **`internal_chromium.py` currently passes an explicit + `viewport` to a window a human must sign into** — same shape, unverified there, worth + checking. +2. **Playwright injects `--no-sandbox`** unless `chromium_sandbox=True`, producing + Chrome's "You are using an unsupported command-line flag" banner and an extra + automation signal that `real_chrome.py`'s raw subprocess does not carry. + +Next gate: `/gflow:predict` before any auth code — this is a transport/auth change. diff --git a/scripts/dev/spike_playwright_chrome_login.py b/scripts/dev/spike_playwright_chrome_login.py new file mode 100644 index 00000000..3ca0cc35 --- /dev/null +++ b/scripts/dev/spike_playwright_chrome_login.py @@ -0,0 +1,272 @@ +"""Does Google's sign-in accept a Playwright-driven REAL Chrome? — $0, no generation. + +``RealChromeStrategy`` launches Chrome as a bare subprocess with no debugging port, so it +has no signal channel and must ask the user to close the window by hand. Sibling project +``notebooklm-py`` auto-closes instead, and its mechanism is not a trick: it *owns* the +browser (``page.wait_for_url(...)``, then ``context.close()``), accepting an automation +surface and mitigating it with ``--disable-blink-features=AutomationControlled`` plus +``ignore_default_args=["--enable-automation"]``. + +gflow assumes that surface is fatal at sign-in — the G12 block, ``KNOWN_ISSUES.md:1516``. +That entry is about Playwright's **bundled Chromium**, and its stated resolution ("real +Chrome via Playwright's channel='chrome' with stealth flags") describes an implementation +that does not exist: ``real_chrome.py`` was born as passive capture (eb0de133, 2026-07-19) +and never used Playwright. Meanwhile ``channel="chrome"`` IS driven by Playwright daily — +``api/client.py:569``, ``auth/verification.py:287`` — but only ever on an ALREADY +authenticated profile, so nothing has ever tested the ``accounts.google.com`` gate itself. + +So the question is open, not settled. This measures it directly. + + uv run python scripts/dev/spike_playwright_chrome_login.py --arm stealth + uv run python scripts/dev/spike_playwright_chrome_login.py --arm bare # control + uv run python scripts/dev/spike_playwright_chrome_login.py --arm bundled # control + +Three arms, because one result cannot separate the causes: + + stealth channel="chrome" + the anti-automation flags (notebooklm-py's posture) + bare channel="chrome", Playwright defaults (isolates: do the flags matter?) + bundled bundled Chromium + the flags (isolates: does the binary matter?) + +A PASS on ``stealth`` with a BLOCK on ``bundled`` means the binary and flags are what save +you, and auto-close is reachable. A BLOCK on all three means the zero-automation-surface +constraint is still load-bearing and the honest answer to "why no auto-close" is "because +Google says so". + +Each run uses a THROWAWAY profile (``--profile``, default ``spike-login-probe``) so a real +sign-in is actually required; an already-authenticated profile would skip the gate and +prove nothing. Delete it afterwards — the script prints the path. + +COST: $0. Navigation, cookie reads and DOM only. Nothing is submitted, no generation +starts, no credit and no image quota is spent. + +WHAT IS OBSERVED (never inferred): + +* ``navigator.webdriver`` as the page actually sees it +* every main-frame URL the sign-in walks through, with timestamps +* whether ``accounts.google.com/v3/signin/rejected`` is EVER reached — the G12 marker, + the same constant ``internal_chromium.py`` already watches for +* whether ``__Secure-next-auth.session-token`` appears in the context cookie jar, which + is also a direct test of whether notebooklm-py's detect-and-close would work here + +"It timed out" is not evidence of a block. The URL trail is: it says what WAS reached. +""" + +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().parents[2] / "src")) + +from gflow_cli import auth as _auth_mod # noqa: E402 +from gflow_cli.profile_lease import ProfileLease # noqa: E402 + +from _spike_common import default_out_path # noqa: E402, isort: skip + +FLOW_URL = "https://labs.google/fx/tools/flow?hl=en" +# The same marker internal_chromium.py watches — a positive observation of the block. +G12_ROUTE = "accounts.google.com/v3/signin/rejected" +FLOW_SESSION_COOKIE = "__Secure-next-auth.session-token" # noqa: S105 - a cookie NAME + +STEALTH_ARGS = ["--disable-blink-features=AutomationControlled", "--password-store=basic"] + + +def _launch_kwargs(arm: str, profile_dir: Path) -> dict[str, Any]: + """Launch options per arm. Only the arm differs; everything else is held constant.""" + # no_viewport is REQUIRED for a window a human drives. Passing an explicit + # `viewport=` makes Playwright EMULATE that size independently of the real OS + # window, so on any display smaller (or more scaled) than the emulated size the + # sign-in form renders outside the visible area and zooming cannot recover it — + # zoom changes CSS pixels, not the emulated viewport. Measured 2026-09-08: a + # 1920x1080 viewport made Google's sign-in unusable on this machine. + # chromium_sandbox defaults to False in Playwright, which injects --no-sandbox. + # Observed 2026-09-08: Chrome then shows "You are using an unsupported command-line + # flag: --no-sandbox". real_chrome.py's raw subprocess passes no such flag, so leaving + # it in would make this arm strictly noisier than the path it is being compared + # against — and --no-sandbox is itself an automation signal, which is the whole + # variable under test. + kw: dict[str, Any] = { + "user_data_dir": str(profile_dir), + "headless": False, + "no_viewport": True, + "chromium_sandbox": True, + } + if arm == "stealth": + kw["channel"] = "chrome" + kw["args"] = STEALTH_ARGS + kw["ignore_default_args"] = ["--enable-automation"] + elif arm == "bare": + kw["channel"] = "chrome" + kw["args"] = ["--password-store=basic"] + elif arm == "bundled": + kw["args"] = STEALTH_ARGS + kw["ignore_default_args"] = ["--enable-automation"] + else: # pragma: no cover - argparse constrains this + msg = f"unknown arm: {arm}" + raise ValueError(msg) + return kw + + +def _verdict_lines(arm: str, result: dict[str, Any], timeout_s: int) -> str: + if result.get("pre_authenticated"): + return ( + f"VERDICT [{arm}]: VOID — the profile was already authenticated, so the " + "sign-in gate was never exercised.\nRe-run on a fresh profile." + ) + if result.get("g12_block_observed"): + return f"VERDICT [{arm}]: BLOCKED — reached {G12_ROUTE}" + if result.get("session_cookie_detected"): + return ( + f"VERDICT [{arm}]: PASS — signed in, and the cookie was detected from the " + f"owned context at t={result['session_cookie_detected_at_s']}s.\n" + "Auto-close works on this arm." + ) + # A run nobody drove is a NULL RESULT, not a weak one. Distinguishing the two is the + # whole point: the first run of this spike lapsed at 300s having never left the Flow + # host, and "INCONCLUSIVE" undersold that — nothing was tested at all. + if not any("accounts.google.com" in e.get("url", "") for e in result.get("url_trail", [])): + return ( + f"VERDICT [{arm}]: NOT DRIVEN — the sign-in was never started (no " + "accounts.google.com navigation in the trail).\n" + "This run tested NOTHING about the block. Re-run and sign in by hand." + ) + return ( + f"VERDICT [{arm}]: INCONCLUSIVE — sign-in was reached, but no block and no session " + f"cookie within {timeout_s}s.\nThis is NOT evidence of a block; read url_trail for " + "what was reached." + ) + + +async def _watch_for_session( + context: Any, timeout_s: int, t0: float, state: dict[str, Any] +) -> None: + """Poll the owned context's in-memory jar — notebooklm-py's mechanism, on our surface.""" + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + if state["blocked"]: + return + try: + names = {c.get("name") for c in await context.cookies()} + except Exception as exc: # noqa: BLE001 - the context can close under us + state["trail"].append( + {"t": round(time.monotonic() - t0, 2), "cookie_read_error": repr(exc)} + ) + return + if FLOW_SESSION_COOKIE in names: + state["detected_at"] = round(time.monotonic() - t0, 2) + print( + f"[spike] SESSION COOKIE DETECTED at t={state['detected_at']}s — closing now", + file=sys.stderr, + flush=True, + ) + return + await asyncio.sleep(1) + + +async def run(arm: str, profile: str, timeout_s: int) -> int: + from playwright.async_api import async_playwright + + profile_dir = _auth_mod.profile_dir(profile) + profile_dir.mkdir(parents=True, exist_ok=True) + print(f"[spike] arm={arm} profile_dir={profile_dir}", file=sys.stderr, flush=True) + + t0 = time.monotonic() + state: dict[str, Any] = {"blocked": False, "detected_at": None, "trail": []} + result: dict[str, Any] + + # Chrome must never start on a profile this process does not own (spike SKILL.md). + async with ProfileLease(profile_dir), async_playwright() as pw: + context = await pw.chromium.launch_persistent_context(**_launch_kwargs(arm, profile_dir)) + try: + page = context.pages[0] if context.pages else await context.new_page() + + def _on_nav(frame: Any) -> None: + if frame is not page.main_frame: + return + url = frame.url + state["trail"].append({"t": round(time.monotonic() - t0, 2), "url": url}) + if G12_ROUTE in url: + state["blocked"] = True + print(f"[spike] G12 BLOCK OBSERVED at {url}", file=sys.stderr, flush=True) + + page.on("framenavigated", _on_nav) + await page.goto(FLOW_URL, wait_until="domcontentloaded", timeout=60_000) + + # Read-validity check, not a gate: if the session cookie is ALREADY here, this + # profile was authenticated before the run and the sign-in gate is never + # exercised. The arm would then report PASS without having tested anything. + pre_auth = FLOW_SESSION_COOKIE in {c.get("name") for c in await context.cookies()} + if pre_auth: + print( + "[spike] WARNING: profile is ALREADY authenticated — this arm cannot " + "test the sign-in gate. Use a fresh --profile.", + file=sys.stderr, + flush=True, + ) + + webdriver = await page.evaluate("() => navigator.webdriver") + user_agent = await page.evaluate("() => navigator.userAgent") + print(f"[spike] navigator.webdriver = {webdriver!r}", file=sys.stderr, flush=True) + print( + "\n[spike] Sign in by hand in the window that opened.\n" + " Continue until the Flow editor loads (prompt box / your projects).\n" + f" The script watches for {FLOW_SESSION_COOKIE} and closes the window\n" + " ITSELF the moment it appears — that is the thing under test.\n", + file=sys.stderr, + flush=True, + ) + + await _watch_for_session(context, timeout_s, t0, state) + + result = { + "arm": arm, + "profile_dir": str(profile_dir), + "navigator_webdriver": webdriver, + "user_agent": user_agent, + "pre_authenticated": pre_auth, + "g12_block_observed": state["blocked"], + "session_cookie_detected": state["detected_at"] is not None, + "session_cookie_detected_at_s": state["detected_at"], + "final_url": page.url, + "elapsed_s": round(time.monotonic() - t0, 2), + "url_trail": state["trail"], + } + finally: + await context.close() + + out = default_out_path(f"spike_pw_chrome_login_{arm}", ".json") + out.write_text(json.dumps(result, indent=2), encoding="utf-8") + + print("\n" + "=" * 68, file=sys.stderr) + print(_verdict_lines(arm, result, timeout_s), file=sys.stderr) + print(f"evidence: {out}", file=sys.stderr) + print(f"throwaway profile (delete when done): {result['profile_dir']}", file=sys.stderr) + print("=" * 68, file=sys.stderr) + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Playwright real-Chrome login probe (see module docstring)." + ) + ap.add_argument("--arm", choices=("stealth", "bare", "bundled"), default="stealth") + # Per-arm by default. A control arm re-using the arm-1 profile would start ALREADY + # authenticated, sail past accounts.google.com without touching the gate, and report + # PASS — a fake result that looks exactly like a real one. + ap.add_argument( + "--profile", + default=None, + help="THROWAWAY profile name (default: spike-login-; must be UNAUTHENTICATED)", + ) + ap.add_argument("--timeout", type=int, default=300, help="seconds to wait for sign-in") + args = ap.parse_args() + return asyncio.run(run(args.arm, args.profile or f"spike-login-{args.arm}", args.timeout)) + + +if __name__ == "__main__": + raise SystemExit(main()) From 193a7912f48c75182001ea414ba9c1a0ad43f4eb Mon Sep 17 00:00:00 2001 From: Flavio Oliva Date: Tue, 8 Sep 2026 23:32:41 +0100 Subject: [PATCH 02/12] feat(auth): close the sign-in browser when the sign-in is done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gflow auth login` told you to close the Chrome window yourself, because `RealChromeStrategy` launched Chrome as a bare subprocess with no debugging port and so had no way to know when you were finished. It now owns the browser through Playwright and closes it for you. Measured 2026-09-08 (docs/superpowers/spikes/2026-09-08-g12-blocks-webdriver-not-playwright.md), three human-driven arms on fresh profiles: real Chrome, no stealth flags webdriver=True BLOCKED at /v3/signin/rejected, 17.5s real Chrome + stealth flags webdriver=False signed in, closed itself at 59.4s bundled Chromium + same flags webdriver=False signed in, closed itself at 276.0s The discriminator is `navigator.webdriver`, not the browser binary. Google rejects a browser that advertises automation, not an automated browser — so owning the context is fine as long as the advertisement is suppressed. What this changes: - `real_chrome.py` drives Chrome via Playwright `channel="chrome"` by default, polls the Flow session endpoint from the context it owns, and closes when the session is AUTHENTICATED. Not on a cookie name: the cookie can be present while the endpoint still rejects, and `verify_flow_profile` reads the on-disk store, which Chrome batches. The on-disk probe still runs after close, unchanged, as the durability check. - Closing the window yourself is still a first-class way to finish. It routes to the same verification and never raises — three releases of docs told users to do that. - The bare-subprocess flow is retained and runs automatically when no Chrome channel resolves, when the launch fails, or when Google rejects the browser. There is no flag and nothing to choose. `channel="chrome"` resolves only to Google Chrome, while the auth gate deliberately accepts plain Chromium, so without this a Chromium-only Linux user who can onboard today would have been locked out. - `headless=True` routes to the subprocess path: every spike arm was headed, so a headless Playwright sign-in is unmeasured against Google's gate. - `internal_chromium.py` gains the same flags. It had none — which is the exact configuration measured as BLOCKED — and a test asserted the flag must be *absent*. Its emulated 1920x1080 viewport is replaced by a real window: an explicit viewport is emulated independently of the OS window and pushed Google's sign-in form off-screen on scaled displays, with no zoom recovery. - `CHROME_BINARY` no longer makes the Playwright chrome channel look resolvable. Playwright honours a custom binary via `executable_path=`, never via `channel=`, so that path handed Playwright a channel it could not launch. Teardown goes through `close_context_bounded` + `run_teardown_step` in pw -> lease order, so a cancelled login still stops the driver and releases the lease. The two cancellation guards that were fixed twice before are kept for the subprocess path and mirrored for the Playwright path as order assertions. Not verified: no live run of either path (sign-in needs a human at a Google password prompt — no e2e test can cover it, and all 39 e2e tests consume an already authenticated profile); the browser-rejected fallback is exercised by simulation only. Evidence is N=1 — one account, one Windows host, one IP, one Chrome build, one day — and shipping this as the default rather than opt-in is a maintainer decision recorded in the plan, taken against two personas' advice. The follow-up measurements that would close that gap are listed there. Refs #480 --- CHANGELOG.md | 25 + KNOWN_ISSUES.md | 49 +- docs/ARCHITECTURE.md | 8 +- docs/AUTHENTICATION.md | 34 +- docs/USER_GUIDE.md | 8 +- .../memory/real-browser-auth-mandatory.md | 26 +- .../plans/2026-09-08-auth-autoclose/PLAN.md | 146 +++++ src/gflow_cli/auth/internal_chromium.py | 72 ++- src/gflow_cli/auth/real_chrome.py | 241 ++++++-- src/gflow_cli/browser_manager.py | 30 +- tests/auth/strategies/test_strategies.py | 42 +- tests/auth/test_real_chrome.py | 521 ++++++++++++++++++ tests/test_browser_manager.py | 62 ++- website/docs/ARCHITECTURE.md | 8 +- website/docs/AUTHENTICATION.md | 34 +- website/docs/KNOWN_ISSUES.md | 49 +- website/docs/USER_GUIDE.md | 8 +- website/docs/onboarding-mockup.html | 4 +- website/docs/onboarding.md | 2 +- 19 files changed, 1245 insertions(+), 124 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-08-auth-autoclose/PLAN.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a8ee36e7..4e69444d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,17 @@ 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. +### Changed + +- **`gflow auth login` closes the browser for you.** It drives your real Google Chrome + through Playwright, watches for the completed Flow sign-in, and closes the window itself — + the "now close Chrome" step is gone. Closing the window yourself still works and still + verifies; it is not an error. On a machine where Playwright cannot resolve a Chrome + channel, or where Google rejects the browser anyway, login falls back automatically to the + previous flow (Chrome as a plain subprocess, you close the window). **There is no new flag + and nothing to choose.** + ([spike](docs/superpowers/spikes/2026-09-08-g12-blocks-webdriver-not-playwright.md)) + ### Fixed - **The second image in one session no longer falls back to the labs reCAPTCHA mint** @@ -48,6 +59,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 driving only t2v and local-frame i2v, so an image refusal printed a `detail` saying t2i/i2i are driven directly above a remediation saying they are not. Both it and the class docstring now name the full ported matrix. +- **`gflow auth login --browser internal` launched the exact browser configuration Google + rejects.** The bundled-Chromium path shipped with no anti-automation flags, so + `navigator.webdriver` was `true` — measured 2026-09-08 as rejected at + `/v3/signin/rejected` 17.5 s into the flow. It now passes + `--disable-blink-features=AutomationControlled`, `ignore_default_args=["--enable-automation"]` + and `chromium_sandbox=True` — the last of which also removes Chrome's cosmetic *"You are + using an unsupported command-line flag"* banner — and signs in on the real OS window + instead of an emulated 1920×1080 viewport that pushed Google's sign-in form off-screen on + smaller or scaled displays. +- **Setting `CHROME_BINARY` no longer makes Playwright's `channel="chrome"` look resolvable + when it is not.** The availability check treated the variable as proof, passed, and then + failed at launch with *"Chromium distribution 'chrome' is not found"*. Playwright honours a + custom binary only via `executable_path=`, never via `channel=`, so the variable is now + ignored by that check (it still resolves a Chrome binary everywhere else). ## [0.71.1] — 2026-09-08 diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index e5b19776..f098e2b6 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -1523,9 +1523,10 @@ End-to-end live-verified on the `ffroliva` profile across `9:16`, `16:9`, `1:1`, ### G12 "browser not secure" block — Google rejects automated sign-in -- **Status:** Resolved · **Severity:** Critical (blocked `gflow auth login`) · **Fixed in:** v0.6.0a2 +- **Status:** Resolved · **Severity:** Critical (blocked `gflow auth login`) · **Fixed in:** v0.6.0a2 · **Mitigation reimplemented + re-measured:** 2026-09-08 -Google's sign-in flow (`accounts.google.com/v3/signin/rejected`) detected Playwright's bundled Chromium as an automated browser and refused the login with no user-facing error. +Google's sign-in flow (`accounts.google.com/v3/signin/rejected`) rejects a browser that +advertises itself as automated, and refuses the login with no user-facing error. **Root cause (timing race):** Without `--disable-blink-features=AutomationControlled`, Blink's C++ engine sets `navigator.webdriver = true` as a non-configurable, non-writable @@ -1533,20 +1534,52 @@ native property at Chrome startup — before any JavaScript (including `add_init can run. The `Object.defineProperty` override silently fails. With the flag, the property is never set; the JS override then works as belt-and-suspenders. -**Resolution:** `v0.6.0a2` adds `RealChromeStrategy` — a new auth strategy that launches -the system's real Google Chrome via Playwright's `channel="chrome"` with stealth flags. +**Resolution:** `gflow auth login` launches the system's real Google Chrome through +Playwright's `channel="chrome"` with `chromium_sandbox=True`, `no_viewport=True`, and both +stealth flags — `--disable-blink-features=AutomationControlled` and +`ignore_default_args=["--enable-automation"]`. Because gflow owns that browser it also +detects the completed Flow sign-in and closes the window itself; see +[docs/AUTHENTICATION.md](docs/AUTHENTICATION.md). When no Chrome channel resolves, or +Google rejects the browser anyway, login falls back automatically to launching Chrome as a +plain subprocess and waiting for you to close the window. There is no flag and no choice to +make, and closing the window yourself works on either path. + +> **This entry described that Playwright implementation long before it existed.** +> It read *"`v0.6.0a2` adds `RealChromeStrategy` — launches the system's real Google Chrome +> via Playwright's `channel="chrome"` with stealth flags."* `src/gflow_cli/auth/real_chrome.py` +> was created at `eb0de133` (2026-07-19) as a bare `subprocess.Popen` passive capture, and +> `git log -S'channel="chrome"' -- src/gflow_cli/auth/` returned **zero** commits until the +> auto-close change. The paragraph above is the same shape restated deliberately as current +> fact, not the same accident left standing. ```bash -# Bypass G12 block explicitly: +# Ask for real Chrome explicitly: gflow auth login --browser chrome # Or rely on auto-detection (default behaviour; picks real Chrome if installed): gflow auth login ``` -A cosmetic "You are using an unsupported command-line flag" notice may appear briefly in -the Chrome window — this is harmless and can be dismissed. It is the accepted trade-off -for bypassing G12. +**The block is current Google behaviour — "Resolved" means the mitigation holds, not that +Google stopped.** Re-measured 2026-09-08 across three throwaway *unauthenticated* profiles, +each signed into by hand +([spike](docs/superpowers/spikes/2026-09-08-g12-blocks-webdriver-not-playwright.md)): a +browser advertising `navigator.webdriver === true` — real Chrome, no stealth flags — was +rejected at `/v3/signin/rejected` **17.5 s** into the flow, while the same real Chrome +*with* the flags reported `false`, never saw the rejection, and reached a Flow session +cookie at 59.4 s. Playwright's bundled Chromium with the flags passed too, so the binary is +not the discriminator; `navigator.webdriver` tracked the outcome in all three arms. + +> **This is N=1 — do not read it as a capability claim.** One account, one Windows host, one +> residential IP, one Chrome build (`Chrome/149.0.0.0`), one day. Google's sign-in risk +> scoring varies with account age and IP reputation, so it does not predict CI, a VPS, or a +> fresh account. Every arm ran headed, so it says nothing about headless in either +> direction. Sign-in is also a different gate from generation's reCAPTCHA Enterprise check; +> a result on one does not move the other. + +The Chrome window no longer shows the "You are using an unsupported command-line flag" +notice this entry used to warn about: that banner came from the `--no-sandbox` Playwright +injects by default, and `chromium_sandbox=True` stops the injection. --- diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8dbbcabc..113358ec 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -94,10 +94,10 @@ class AuthStrategy(Protocol): - `mode="internal"` — explicit `InternalChromiumStrategy`. **RealChromeStrategy stealth design** (`real_chrome.py`): -- Uses a **Passive Capture** pattern: launches system Chrome via `subprocess.Popen` without any automation flags or remote-debugging ports. -- Provides a 100% clean browser process that Google's G12 block cannot detect. -- The CLI blocks on `proc.wait()`, prompting the user to complete the sign-in and **close the browser completely**. -- Post-close: performs a fast, headless `launch_persistent_context` probe to verify the `SAPISID` cookie was successfully captured. +- **Default — Playwright-owned Chrome.** Launches the system's real Google Chrome via Playwright's `channel="chrome"` with `chromium_sandbox=True`, `no_viewport=True`, `--disable-blink-features=AutomationControlled`, and `ignore_default_args=["--enable-automation"]`. What Google's G12 block keys on is a browser that *advertises* automation (`navigator.webdriver`), not the Playwright connection itself; with these flags the property is `false` and sign-in proceeds normally. See the G12 entry in [KNOWN_ISSUES.md](../KNOWN_ISSUES.md) for the 2026-09-08 measurement and its N=1 caveat. +- Because gflow owns that context, it polls the Flow session endpoint from it until the outcome is `AUTHENTICATED` and then **closes the browser itself** — the user is not asked to close anything. A user who closes the window anyway is routed to the same `verify_flow_profile` check, never to an error. +- **Automatic fallback — Passive Capture.** When Playwright cannot resolve a Chrome channel (`browser_manager.is_playwright_chrome_channel_available()`), or Google rejects the browser anyway, the strategy silently falls back to the older shape: system Chrome via `subprocess.Popen` with no automation flags and no remote-debugging port, the CLI blocking on `proc.wait()` until the user closes the window. There is **no user-facing flag and no choice to make** — a Chromium-only host is never locked out of onboarding. +- Verification: both paths end in the same `verify_flow_profile` call after the browser is gone. That probe is **httpx-first** — it reads the profile's cookie store directly via `browser_cookie3` and only falls back to a headless `launch_persistent_context` when cookie decryption fails (DPAPI on Windows, keychain on macOS, libsecret on Linux). The default path additionally polls the same session contract *from the browser it owns*, which is what tells it when to close. Both write the `.gflow_browser_strategy = "chrome"` marker that `channel_for_profile()` later reads. - Privacy guard: raises `SecurityError` if the resolved `profile_dir` is outside `GFLOW_CLI_HOME` — protects the user's primary system Chrome profile from being used as a session store. **UiAutomationTransport (UI Mimicry)**: diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index 2fe574be..970e7ef0 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -179,17 +179,37 @@ and the profile keeps the name `default`. | Value | Browser used | When to use | |---|---|---| | `auto` (default) | Real Chrome if installed; falls back to internal | First choice for most users | -| `chrome` | System Google Chrome (**Passive Capture**) | Required to bypass "G12" blocks | +| `chrome` | System Google Chrome, driven by Playwright (auto-closes) | Required to bypass "G12" blocks | | `internal` | Playwright's bundled Chromium | Fallback when Chrome isn't installed | Override with the env var: `GFLOW_CLI_AUTH_BROWSER=chrome gflow auth login` -**Why `chrome` bypasses bot detection:** Playwright's default automation mode exposes -`navigator.webdriver = true` as a non-configurable native property. Google detects this -and redirects to `/v3/signin/rejected` (the "G12 block"). The `chrome` strategy -implements **Passive Capture**: it launches your real system Chrome as a 100% standard -process without any automation flags or debugging ports. You log in manually, close -the window, and `gflow` extracts the verified session from the profile. +`internal` now launches with the same anti-automation flags as `chrome` (it previously did +not, which was the configuration Google rejects). It stays a fallback rather than a +recommendation: a profile created by `internal` carries no `chrome` strategy marker, so +generation later opens it with bundled Chromium instead of your real Chrome. + +**Why `chrome` bypasses bot detection:** what Google rejects is a browser that *advertises* +automation. Blink sets `navigator.webdriver = true` as a non-configurable native property +unless `--disable-blink-features=AutomationControlled` is passed, and Google redirects that +browser to `/v3/signin/rejected` (the "G12 block"). The `chrome` strategy launches your real +system Chrome through Playwright with that flag plus +`ignore_default_args=["--enable-automation"]`, `chromium_sandbox=True`, and +`no_viewport=True`, so `navigator.webdriver` is `false` and the sign-in proceeds normally. +The block itself is still live — re-measured 2026-09-08; see the G12 entry in +[KNOWN_ISSUES.md](../KNOWN_ISSUES.md) for the numbers and their N=1 caveat. + +**You don't close the browser — gflow does.** Because gflow owns that Chrome window, it +watches for the completed Flow sign-in and closes the window itself, then prints the +verified account. If you close the window yourself it still works: gflow verifies the +profile exactly the same way and does not treat a manual close as an error. + +**Automatic fallback, with nothing to choose.** If Playwright can't resolve a Chrome channel +on this machine (a Chromium-only Linux box, for instance), or Google rejects the browser +anyway, `gflow auth login` falls back to the earlier **Passive Capture** flow: Chrome +launched as a plain process with no automation flags and no debugging port, where you close +the window once the Flow editor has loaded and `gflow` extracts the verified session from the +profile. There is no flag and no prompt for this — the fallback simply happens. **Privacy guard:** The `chrome` strategy strictly refuses to use any profile directory outside `GFLOW_CLI_HOME`. This protects your primary system Chrome profile from diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 26e1fc9b..32b9a333 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -71,7 +71,7 @@ This is a ~150 MB download. It happens once per user. gflow auth login ``` -A Chromium window opens. Sign in to the Google account you use for Flow. **Solve any captchas Google shows you** — `gflow-cli` cannot solve them; that's intentional (anti-bot detection). When the Flow dashboard loads, return to your terminal and confirm. +A browser window opens (real Chrome where it's installed). Sign in to the Google account you use for Flow. **Solve any captchas Google shows you** — `gflow-cli` cannot solve them; that's intentional (anti-bot detection). Keep going until the Flow dashboard loads; **gflow detects the completed sign-in and closes the window for you**, then prints the verified account in your terminal. Closing the window yourself works too. Your session is saved under (one of): - Windows: `%LOCALAPPDATA%\gflow-cli\profile_default\` @@ -750,7 +750,11 @@ gflow auth login --profile --browser chrome 1. Chrome opens to `https://labs.google/fx/tools/flow?hl=en`. 2. Sign in to the Google account you use for Flow. -3. When the Flow editor loads, **close Chrome**. +3. Keep going until the Flow editor loads — **gflow closes Chrome for you** once it sees the + completed Flow sign-in. (Closing the window yourself also works and verifies the same + way. On a machine where Playwright can't resolve a Chrome channel, login falls back + automatically to the older flow, where you close the window; nothing to configure either + way.) 4. `gflow auth login` probes the profile with `channel="chrome"`, verifies SAPISID is present, and writes `.gflow_browser_strategy = "chrome"` to the profile directory. 5. Subsequent `gflow image` / `gflow video` calls will use Chrome to open the profile and diff --git a/docs/superpowers/memory/real-browser-auth-mandatory.md b/docs/superpowers/memory/real-browser-auth-mandatory.md index 58c41857..144d2a36 100644 --- a/docs/superpowers/memory/real-browser-auth-mandatory.md +++ b/docs/superpowers/memory/real-browser-auth-mandatory.md @@ -5,6 +5,28 @@ description: Real-browser (Chrome-strategy) auth is mandatory for gflow-cli UI a Directive (2026-05-18): **real-browser authentication is mandatory** for UI-automation paths — "we harden on that until further notice." -**Why:** Google's `accounts.google.com` sign-in/OAuth flow rejects automated and Playwright-bundled-Chromium browsers (the "G12 block" — `/v3/signin/rejected`, "this browser may not be secure"). Only the user's real installed Google Chrome is accepted. A profile authenticated via `gflow auth login --profile --browser chrome` (`RealChromeStrategy`, passive capture — launches real Chrome, no automation) gets a `.gflow_browser_strategy=chrome` marker; `channel_for_profile()` in `src/gflow_cli/browser_manager.py` returns `"chrome"` only when that marker is present, so Playwright drives real Chrome instead of bundled Chromium. A marker-less profile silently launches bundled Chromium → blocked. +> **Correction (2026-09-08): one sentence of this file was wrong. The directive is not.** +> This file used to say *"Never write a flow that expects interactive Google sign-in inside a +> Playwright-driven browser — it cannot work."* It can. +> [`2026-09-08-g12-blocks-webdriver-not-playwright`](../spikes/2026-09-08-g12-blocks-webdriver-not-playwright.md) +> drove three arms, each a human signing in by hand on a throwaway *unauthenticated* profile. +> Real Chrome with **no** stealth flags reported `navigator.webdriver === true` and was +> rejected at `/v3/signin/rejected` after 17.5 s. The same real Chrome **with** +> `--disable-blink-features=AutomationControlled` and +> `ignore_default_args=["--enable-automation"]` reported `false`, never saw the rejection, and +> reached a Flow session cookie at 59.4 s. Playwright's bundled Chromium, with the flags, +> passed too. **The discriminator is `navigator.webdriver` — not the Playwright connection, +> and not the binary.** `gflow auth login` now drives real Chrome through Playwright by +> default and closes the browser itself when the sign-in completes. +> +> **What the correction does NOT reach.** It retires the *sign-in-gate* claim only, and on +> **N=1** evidence: one account, one Windows host, one residential IP, one Chrome build, one +> day, every arm headed. It says nothing about headless in either direction, and nothing +> about generation — that is reCAPTCHA-Enterprise-gated, a different machine entirely +> ([[flow-google-com-batchexecute-headless-proven]]). Everything below still binds. -**How to apply:** Any UI-automation script/transport that drives the Flow UI must require a Chrome-strategy profile and **fail fast** with a clear error pointing to `gflow auth login --browser chrome` when `channel_for_profile()` returns `None`. Never write a flow that expects interactive Google sign-in inside a Playwright-driven browser — it cannot work. The Phase 0 spike `scripts/smoke_video_editor.py` was hardened with exactly this guard in `main()` (commit a04b9b7). See [[video-generation-spec]]. +**Why:** a profile authenticated via `gflow auth login --profile --browser chrome` gets a `.gflow_browser_strategy=chrome` marker; `channel_for_profile()` in `src/gflow_cli/browser_manager.py` returns `"chrome"` only when that marker is present, so Playwright drives the user's real installed Google Chrome instead of bundled Chromium. **A marker-less profile silently downgrades to bundled Chromium** — no error, just a different browser than the one the profile was built for. That downgrade, not the sign-in gate, is why `channel="chrome"` is load-bearing on every generation path. (Historically this file also named the G12 block — `/v3/signin/rejected`, "this browser may not be secure" — as the reason; see the correction above for what that block actually keys on.) + +**How to apply:** Any UI-automation script/transport that drives the Flow UI must require a Chrome-strategy profile and **fail fast** with a clear error pointing to `gflow auth login --browser chrome` when `channel_for_profile()` returns `None`. The Phase 0 spike `scripts/smoke_video_editor.py` was hardened with exactly this guard in `main()` (commit a04b9b7). See [[video-generation-spec]]. + +**Interactive sign-in inside a Playwright-driven browser is now allowed — with flags.** If you write one, it must carry `--disable-blink-features=AutomationControlled`, `ignore_default_args=["--enable-automation"]`, `chromium_sandbox=True` (Playwright otherwise injects `--no-sandbox`, which is both an automation signal and Chrome's "unsupported command-line flag" banner) and `no_viewport=True` (an explicit `viewport=` emulates a size independent of the OS window and pushes Google's sign-in form off-screen on scaled displays). Both stealth flags were only ever measured together; keep both. diff --git a/docs/superpowers/plans/2026-09-08-auth-autoclose/PLAN.md b/docs/superpowers/plans/2026-09-08-auth-autoclose/PLAN.md new file mode 100644 index 00000000..b6e3c404 --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-auth-autoclose/PLAN.md @@ -0,0 +1,146 @@ +# Auto-closing browser sign-in for `gflow auth login` + +**Phase 0 evidence:** [`docs/superpowers/spikes/2026-09-08-g12-blocks-webdriver-not-playwright.md`](../../spikes/2026-09-08-g12-blocks-webdriver-not-playwright.md) +**Phase 2 verdict:** STOP as originally scoped → **GO on the scope below**, confidence 7/10. +Five personas: Architect CAUTION 7 · Security CAUTION 7 · Performance CAUTION 7 · +CLI/MCP UX GO 7 · Devil's Advocate STOP 7. + +## Goal + +`gflow auth login` closes the browser itself once the Flow sign-in completes, instead of +instructing the user to close the window. + +## Scope decisions (and why they differ from the original proposal) + +1. **The Playwright driver is the DEFAULT, and the subprocess path is retained as an + automatic fallback — not as an opt-in toggle.** + + Security and Devil's Advocate both argued for opt-in-for-one-release on N=1 evidence + (one account, one Windows box, one IP, one Chrome build, one day). That objection was + put to the maintainer, who decided auto-close ships as the default. Recorded here so + the trade-off is visible rather than silently lost: **shipping this as default is a + deliberate decision made against two personas' advice.** + + The risk those personas named is nonetheless mitigated, because fallback and opt-in + are separable. There is **no user-facing switch**: the Playwright path is tried first, + and the subprocess path runs automatically when Playwright cannot launch (no resolvable + Chrome channel) or when Google rejects the browser. A Chromium-only Linux user is + never locked out of onboarding; they simply get the old flow without being asked to + choose. This makes the default flip safe without making the user opt in. +2. **`internal_chromium.py` is fixed first, separately.** It already auto-closes + (`_poll_session_until_authenticated` → `finally: ctx.close()`) but ships with **no + stealth flags** — which is the exact configuration the spike measured as BLOCKED. That + is a likely-live G12 block, and the fix is ~4 lines. +3. **Two strategy classes are kept**, not collapsed. Performance argued YAGNI-collapse; + Architect showed four contracts hang off the split (`name` → `source=` label + constrained at `verification.py:86`; only chrome writes `.gflow_browser_strategy`; + `factory.py` is a name→type registry; both classes are public exports). Shared + launch+poll body is extracted instead. +4. **Close condition is `FlowSessionOutcome.AUTHENTICATED`**, polled from the owned + context — never a cookie-name match. The spike used a cookie name; Security and + Performance both showed that is the wrong oracle and that "close flushes to disk" is + unverified by the spike's own evidence. + +## Tasks + +### T1 — `internal_chromium` hardening (own PR, ships first) +- [ ] Failing test: launch kwargs include both stealth flags, `no_viewport=True`, + `chromium_sandbox=True`, and `--window-size=1920,1080`; assert **no** explicit + `viewport=` key. +- [ ] Add `--disable-blink-features=AutomationControlled`, + `ignore_default_args=["--enable-automation"]`, `chromium_sandbox=True`, + `no_viewport=True`; replace `viewport={1920,1080}` with `--window-size=1920,1080` + in `args` (keeps the #315 geometry rationale, drops the emulation that pushes + Google's sign-in form off-screen). +- [ ] Keep `--password-store=basic` (cross-module load-bearing, `client.py:552-560`). + +### T2 — Factory gate (blocking precondition for T3) +- [ ] Failing test: Chromium-only Linux + `--browser auto` → `InternalChromiumStrategy`; + `--browser chrome` → `ConfigurationError` exit 11 (matches `auth_login.feature:26-31`). +- [ ] Promote `_is_playwright_chrome_channel_available` to public API; gate + `factory.py` on it when the Playwright driver is selected. +- [ ] Fix its `CHROME_BINARY` false positive (`browser_manager.py:148-150`): Playwright's + `channel="chrome"` ignores `CHROME_BINARY`; only `executable_path=` honours it. + +### T3 — Playwright auth driver (default, with automatic fallback) +- [ ] Failing test: with a resolvable Chrome channel, `gflow auth login --browser chrome` + takes the Playwright path and closes the browser itself. +- [ ] Failing test: with **no** resolvable Chrome channel, the same command silently + falls back to the subprocess path and still completes — no user-facing choice, no + new flag, no error. +- [ ] Failing test: `AuthBrowserRejectedError` on the Playwright path triggers one + automatic subprocess retry rather than surfacing exit 14 to the user. +- [ ] Extract shared launch-kwargs + session-poll helper used by both strategies. +- [ ] Detection: poll `SESSION_API_URL` from the owned context until + `FlowSessionOutcome.AUTHENTICATED`; monotonic deadline; break on browser-closed. +- [ ] **Manual close routes to `verify_flow_profile`, never to exit 12.** A user who + closes the window themselves must not see a red error on a successful login. +- [ ] Port `_is_google_rejected_browser_page` → `AuthBrowserRejectedError` (exit 14) to + this path, and assert `navigator.webdriver is False` once at launch, so a future + Chrome that ignores the flag fails loudly instead of as a 600 s timeout. +- [ ] Teardown via `close_context_bounded` + `run_teardown_step` (`api/_engine.py`), in + `pw → lease` unwind order. Not a bare `finally`. +- [ ] No new CLI option and no new env var — so no MCP parity work is owed + (`auth login` is an explicit exemption, `tests/mcp/test_cli_parity.py:91`, and the + reason still holds). Record that rather than re-deriving it. + +### T4 — Preserve the two regression guards +- [ ] `CancelledError` during detection closes the context, stops the driver, and + releases the lease **in that order** (assert order via a shared list, as + `tests/api/test_concurrency.py:284-299` does). +- [ ] Timeout raises `AuthLoginTimeoutError` after the same three steps. + These replace `test_await_chrome_close_cancellation_terminates_and_reaps` and + `test_login_cancellation_releases_lease_and_reaps_chrome`, whose subject code the + Playwright path bypasses. The guarantees must survive even though the code does not. + +### T5 — Copy and observability +- [ ] Rewrite `_print_login_instructions` step 4 and the `PASSIVE AUTHENTICATION` header. +- [ ] `_UNVERIFIED_HINT[GOOGLE_SESSION_ONLY]` — drop "before closing Chrome". +- [ ] Timeout message: "not detected within Ns", not "you timed out". +- [ ] `auth_passive_capture_started` → `auth_login_started`; add + `auth_login_session_detected(strategy, elapsed_s)`, + `auth_login_browser_closed_by_user(strategy)`, `auth_login_launch_failed(strategy, error)`. +- [ ] **Never log `page.url`** — OAuth `state`/`code_challenge` live there and + `data/redaction.py` matches neither. Test: no `accounts.google.com` substring in + any emitted log event. + +### T6 — Docs (own PR for the pure-doc part) +- [ ] `KNOWN_ISSUES.md` G12 entry: it currently describes a Playwright `channel="chrome"` + `RealChromeStrategy` that has never existed, and its "unsupported command-line flag" + note becomes false once `chromium_sandbox=True` removes `--no-sandbox`. +- [ ] `docs/ARCHITECTURE.md` (Passive Capture / `proc.wait()`), `docs/AUTHENTICATION.md`, + `docs/USER_GUIDE.md`. +- [ ] **Website-only, hand-edit, the mirror gate cannot see these:** + `website/docs/onboarding.md`, `website/docs/onboarding-mockup.html`. +- [ ] Supersede `docs/superpowers/memory/real-browser-auth-mandatory.md` — it asserts + "Never write a flow that expects interactive Google sign-in inside a + Playwright-driven browser — it cannot work", which the spike refutes. +- [ ] Do **not** edit `docs/LIVE_VERIFICATION_v0.54.0.md` — a dated historical record. + +## Verification + +- **No e2e test can cover this**, and that is a named blocker, not an omission: login + requires a human typing a Google password. `tests/e2e/` has 39 tests, all consuming an + already-authenticated profile. The instrument that discharges it is + `scripts/dev/spike_playwright_chrome_login.py`, plus a `/gflow:live-verify` run. +- **Required live runs before merge:** (a) auto-close fires end to end; (b) manual close + mid-login still verifies and does **not** exit 12; (c) `--browser auto` on this machine + still selects and completes the default subprocess path unchanged. + +## Follow-up owed after this ships (not blocking, but do not lose it) + +Because the default flip ships on N=1 evidence against Security's advice, the evidence +gap it named stays open and should be closed after release, not forgotten: + +- Re-run the `stealth` arm on a second profile and one non-Windows host. +- Re-run the 20-generation WAF baseline (`docs/superpowers/spikes/2026-07-09-camoufox-waf-403.md`, + currently 0/20 403s) on a profile authenticated by the **new** path. That baseline was + measured on a subprocess-authenticated profile and does not transfer automatically. +- If either regresses, the automatic-fallback seam built in T3 is the rollback lever — + invert its preference order rather than reverting the release. + +## Explicitly out of scope + +- `headless=True` on the Playwright path — unmeasured; refuse it rather than ship it. +- `#480 --import-from-browser` — blocked on Windows by App-Bound Encryption (verified: + `app_bound_encrypted_key` present in this machine's Chrome `Local State`). diff --git a/src/gflow_cli/auth/internal_chromium.py b/src/gflow_cli/auth/internal_chromium.py index f2fc337d..369537be 100644 --- a/src/gflow_cli/auth/internal_chromium.py +++ b/src/gflow_cli/auth/internal_chromium.py @@ -24,18 +24,70 @@ GOOGLE_REJECTED_BROWSER_ROUTE = "accounts.google.com/v3/signin/rejected" -async def _poll_session_until_authenticated( +def login_launch_kwargs( + profile_dir: Path, + headless: bool, + *, + channel: str | None = None, +) -> dict[str, Any]: + """Launch kwargs for a browser a HUMAN signs into — shared by both strategies. + + Defined once because the stealth set is measured, not chosen: 2026-09-08 + (docs/superpowers/spikes/2026-09-08-g12-blocks-webdriver-not-playwright.md) + real Chrome WITHOUT these flags reported ``navigator.webdriver == True`` and + Google routed the sign-in to ``/v3/signin/rejected`` in 17.5 s, while both + real Chrome and bundled Chromium WITH them signed in normally. Whether + either flag alone suffices is untested — keep both, on both strategies. + + ``channel="chrome"`` selects the system Chrome binary. It is not what gets + past the sign-in gate (the bundled arm passed too) — it is what makes the + resulting profile a chrome-strategy profile, without which + ``channel_for_profile()`` returns None and generation silently downgrades. + """ + return { + "user_data_dir": str(profile_dir), + "channel": channel, + "headless": headless, + # A human signs into this window, so let it be a REAL window: an + # explicit viewport makes Playwright emulate that size independently of + # the OS window and pushes Google's sign-in form off-screen on + # smaller/scaled displays. The #315 "log in at the size you generate at" + # rationale is preserved by --window-size below, on the real window. + "no_viewport": True, + # Playwright defaults chromium_sandbox=False, which injects + # --no-sandbox: an extra automation signal plus Chrome's "unsupported + # command-line flag" banner. + "chromium_sandbox": True, + "ignore_default_args": ["--enable-automation"], + "args": [ + "--disable-blink-features=AutomationControlled", + "--window-size=1920,1080", + # Load-bearing beyond auth: keeps the profile off the macOS + # keychain, which api/client.py also depends on (#222). + "--password-store=basic", + ], + } + + +async def poll_session_until_authenticated( ctx: Any, page: Any, timeout_seconds: int, strategy_name: str, + *, + raise_on_close: bool = True, ) -> str | None: """Poll the Flow NextAuth session endpoint until the sign-in completes. Returns the verified user email, or None if it could not be extracted. Raises ``AuthBrowserRejectedError`` if Google rejects the browser. - Raises ``AuthLoginTimeoutError`` if the timeout elapses or the browser - closes before authentication is verified. + Raises ``AuthLoginTimeoutError`` if the timeout elapses, and — when + ``raise_on_close`` — also when the browser closes before authentication is + verified. Callers that own a *fallback* oracle (``RealChromeStrategy`` + re-checks the on-disk store with ``verify_flow_profile``) pass + ``raise_on_close=False`` and get ``None`` instead: three releases told users + to close the window themselves, so doing so must not turn a successful + login red. """ timeout_at = asyncio.get_running_loop().time() + timeout_seconds success = False @@ -93,6 +145,9 @@ async def _poll_session_until_authenticated( ) if not success: + if not raise_on_close: + logger.info("auth_login_browser_closed_by_user", strategy=strategy_name) + return None msg = "Browser closed before the Flow editor sign-in was verified." raise AuthLoginTimeoutError( msg, @@ -149,13 +204,10 @@ async def login(self, profile_dir: Path, headless: bool) -> None: # ProfileLockedError before Chromium launches. async with ProfileLease(profile_dir), async_playwright() as pw: # We use launch_persistent_context to ensure cookies are saved to profile_dir + # Bundled Chromium: no channel. Everything else — the stealth set, + # the real-window geometry — is the shared, measured configuration. ctx = await pw.chromium.launch_persistent_context( - user_data_dir=str(profile_dir), - headless=headless, - # Match the generation viewport (#315) so a profile logs in at the - # same size it later generates with; login-window only, not selector-bound. - viewport={"width": 1920, "height": 1080}, - args=["--password-store=basic"], + **login_launch_kwargs(profile_dir, headless), ) try: page = ctx.pages[0] if ctx.pages else await ctx.new_page() @@ -169,7 +221,7 @@ async def login(self, profile_dir: Path, headless: bool) -> None: ) # Poll until the Flow app sign-in completes; raises on timeout/rejection. - user_email = await _poll_session_until_authenticated( + user_email = await poll_session_until_authenticated( ctx, page, self._timeout_seconds, diff --git a/src/gflow_cli/auth/real_chrome.py b/src/gflow_cli/auth/real_chrome.py index b27f4ede..15ea4b01 100644 --- a/src/gflow_cli/auth/real_chrome.py +++ b/src/gflow_cli/auth/real_chrome.py @@ -9,15 +9,23 @@ import structlog from rich.console import Console +from gflow_cli.browser_manager import is_playwright_chrome_channel_available from gflow_cli.config import Settings, get_settings -from gflow_cli.errors import AuthLoginTimeoutError, AuthMissingError, SecurityError +from gflow_cli.errors import ( + AuthBrowserRejectedError, + AuthLoginTimeoutError, + AuthMissingError, + SecurityError, +) from gflow_cli.profile_lease import ProfileLease from .base import AuthStrategy +from .internal_chromium import login_launch_kwargs, poll_session_until_authenticated from .verification import FlowSessionOutcome, verify_flow_profile if TYPE_CHECKING: from pathlib import Path + from typing import Any logger = structlog.get_logger(__name__) _console = Console() @@ -37,7 +45,7 @@ _UNVERIFIED_HINT: dict[FlowSessionOutcome, str] = { FlowSessionOutcome.GOOGLE_SESSION_ONLY: ( "Re-run `gflow auth login` and continue until the Flow editor " - "(the prompt box / your projects) loads before closing Chrome." + "(the prompt box / your projects) loads." ), FlowSessionOutcome.NO_SESSION: ( "Re-run `gflow auth login`, sign in to Google, and continue until the Flow editor loads." @@ -81,9 +89,9 @@ def _build_chrome_args(chrome_exe: str, profile_dir: Path, headless: bool) -> li def _print_login_instructions() -> None: - """Print the passive-capture login steps to the console.""" + """Print the browser sign-in steps to the console.""" _console.print("\n" + "=" * 60) - _console.print("[bold cyan]PASSIVE AUTHENTICATION[/bold cyan]") + _console.print("[bold cyan]BROWSER SIGN-IN[/bold cyan]") _console.print("=" * 60) _console.print("1. A Google Chrome window opens at the Flow sign-in page.") _console.print("2. Sign in with your Google account.") @@ -95,16 +103,61 @@ def _print_login_instructions() -> None: " Signing in to Google is NOT enough; gflow needs a completed Flow app sign-in.", ) _console.print( - "4. When you're finished, simply [bold]close the Chrome window[/bold] — " - "that's how you let gflow know you're done.", + "4. That's it — gflow detects the sign-in and [bold]closes Chrome for you[/bold], " + "then verifies the session.", ) _console.print( - " gflow then verifies your Flow session automatically; there's nothing else to do.", + " Closing the window yourself still works; gflow verifies what's on disk either way.", ) _console.print("-" * 60) _console.print("Launching Chrome...") +def _login_timeout_error(timeout_seconds: int) -> AuthLoginTimeoutError: + """The timeout every login path raises when the window stays unauthenticated. + + Worded as a detection failure, not a user failure: the most likely cause is + a Chrome that advertises automation (see ``_warn_if_webdriver_exposed``), + and telling that user to "sign in faster" is the wrong advice. + """ + msg = f"Flow sign-in not detected within {timeout_seconds}s; Chrome was stopped." + return AuthLoginTimeoutError( + msg, + remediation_hint=( + "Run `gflow auth login` again and complete sign-in before the time limit. " + f"Set GFLOW_CLI_AUTH_LOGIN_TIMEOUT to raise the limit " + f"(current: {timeout_seconds}s)." + ), + ) + + +async def _warn_if_webdriver_exposed(page: Any, strategy_name: str) -> None: + """Log loudly if this Chrome still advertises automation despite the flags. + + ``--disable-blink-features=AutomationControlled`` + + ``ignore_default_args=["--enable-automation"]`` is what keeps + ``navigator.webdriver`` false today. If a future Chrome ignores them, + Google's G12 block returns and the only user-visible symptom is a silent + 600 s timeout — so make the real cause observable at launch instead. + """ + try: + exposed = bool(await page.evaluate("() => navigator.webdriver")) + except Exception as exc: + # Never fail a login over a diagnostic probe. + logger.warning( + "auth_login_webdriver_probe_failed", + strategy=strategy_name, + error=type(exc).__name__, + ) + return + if exposed: + logger.warning("auth_login_webdriver_exposed", strategy=strategy_name) + _console.print( + "[yellow]Warning: this Chrome still reports navigator.webdriver — " + "Google may reject the sign-in.[/yellow]", + ) + + async def _terminate_and_reap(proc: asyncio.subprocess.Process) -> None: """Terminate the child, then kill+reap it if it doesn't exit promptly. @@ -137,15 +190,7 @@ async def _await_chrome_close(proc: asyncio.subprocess.Process, timeout_seconds: raise except TimeoutError: await _terminate_and_reap(proc) - msg = f"Sign-in timed out after {timeout_seconds}s; Chrome was stopped." - raise AuthLoginTimeoutError( - msg, - remediation_hint=( - "Run `gflow auth login` again and complete sign-in before the time limit. " - f"Set GFLOW_CLI_AUTH_LOGIN_TIMEOUT to raise the limit " - f"(current: {timeout_seconds}s)." - ), - ) from None + raise _login_timeout_error(timeout_seconds) from None def find_chrome_executable() -> str | None: @@ -173,31 +218,159 @@ def find_chrome_executable() -> str | None: class RealChromeStrategy(AuthStrategy): - """Bypass strategy using system Chrome with 'Passive Capture' pattern. - - Launches real Chrome WITHOUT any automation-triggering flags or debugging - ports — a 100% standard browser process that Google's G12 block cannot - detect. The user signs in manually, then closes Chrome. gflow then runs - a fast headless Playwright probe to verify the persisted cookies. - - Stealth properties: - - No --remote-debugging-port, no --enable-automation. - - navigator.webdriver is naturally absent (no Playwright injection). - - Chrome launches exactly as a normal user process. + """Login strategy that drives the system's real Google Chrome. + + Two paths, no user-facing switch: + + * **Owned browser (default).** Playwright launches Chrome with + ``channel="chrome"``, gflow watches the Flow session endpoint from the + context it owns, and closes the window itself once sign-in completes. + There IS an automation surface here, and that is fine: the spike of + 2026-09-08 measured that Google rejects a browser which *advertises* + automation (``navigator.webdriver == True`` -> ``/v3/signin/rejected`` in + 17.5 s), not one that is merely driven. The stealth flags in + :func:`login_launch_kwargs` are what keep that flag false, so they are + load-bearing — see ``_warn_if_webdriver_exposed`` for the alarm. + * **Bare subprocess (automatic fallback).** When Playwright cannot resolve + a ``channel="chrome"`` binary, or Google rejects the owned browser + anyway, Chrome is spawned as a plain child process with no debugging port + and no automation flags, and the user closes it by hand. Nothing asks the + user to choose; the fallback is silent apart from a log event. + + Either way the profile is verified afterwards by ``verify_flow_profile``, + outside the lease, so the on-disk store is proven readable by the reader + generation shares. """ name = "chrome" def __init__(self, *, timeout_seconds: int = 600) -> None: - # Maximum seconds to wait for the user to close Chrome. + # Maximum seconds to wait for the Flow sign-in to be detected. self._timeout_seconds = timeout_seconds async def login(self, profile_dir: Path, headless: bool) -> None: - """Execute the login flow using Passive Capture on Real Chrome.""" + """Sign in to Flow in real Chrome, then verify what landed on disk.""" settings = get_settings() _validate_profile_dir(profile_dir, settings) profile_dir.mkdir(parents=True, exist_ok=True) + logger.info("auth_login_started", profile_dir=str(profile_dir), strategy=self.name) + if not headless: + _print_login_instructions() + + # `headless` never reaches the owned-browser path. The 2026-09-08 spike measured + # three arms and every one was headed, so a headless Playwright sign-in is + # unmeasured against Google's gate — and ACCOUNT_SAFETY.md records that headless + # is rejected outright by reCAPTCHA Enterprise. The subprocess path already has a + # `--headless=new` branch that predates this change, so routing there is both the + # measured option and the smaller one. Not reachable from the CLI today + # (`auth login` exposes no --headless); this guards library callers. + if headless or not is_playwright_chrome_channel_available(): + fallback_reason = "headless" if headless else "channel_unavailable" + else: + fallback_reason = await self._login_owned_browser(profile_dir, headless) + if fallback_reason is not None: + logger.info( + "auth_login_subprocess_fallback", + strategy=self.name, + reason=fallback_reason, + ) + await self._login_subprocess(profile_dir, headless) + + await self._verify_and_record(profile_dir) + + async def _login_owned_browser(self, profile_dir: Path, headless: bool) -> str | None: + """Drive the sign-in in a Chrome gflow owns, and close it when done. + + Returns ``None`` when this path handled the login, or the reason the + caller must fall back to the subprocess path. + """ + # Deferred imports: a top-level `from .strategies import ...` recreates + # the strategies -> real_chrome cycle, and `gflow_cli.api` pulls the + # whole transport stack (which imports auth.verification) at import time. + from gflow_cli.api._engine import CONTEXT_TEARDOWN_TIMEOUT_S, close_context_bounded + from gflow_cli.api._engine import run_teardown_step as _teardown_step + + from .strategies import async_playwright + + # Unwind order is pw -> lease, so the driver is stopped (and Chrome with + # it) before the profile is freed for the next holder (D3). + async with ProfileLease(profile_dir), async_playwright() as pw: + try: + ctx = await pw.chromium.launch_persistent_context( + **login_launch_kwargs(profile_dir, headless, channel="chrome"), + ) + except Exception as exc: + logger.warning( + "auth_login_launch_failed", + strategy=self.name, + error=type(exc).__name__, + ) + return "launch_failed" + + rejected = False + try: + await self._await_flow_session(ctx) + except AuthBrowserRejectedError: + # Not the user's problem to solve: retry on the path that has + # no automation surface at all, rather than surfacing exit 14. + rejected = True + finally: + # Bounded + shielded (not a bare `await ctx.close()`): a + # CancelledError landing inside the close must not skip the + # driver stop or the lease release below, and a secondary + # TargetClosedError must not mask the original exception. + cancelled = await _teardown_step( + close_context_bounded(ctx, owner="auth_login"), + timeout=CONTEXT_TEARDOWN_TIMEOUT_S, + owner="auth_login", + step="context_close", + ) + if cancelled is not None: + # Re-raised inside the `async with`, so the driver still stops + # and the lease still releases on the way out. + raise cancelled + return "browser_rejected" if rejected else None + + async def _await_flow_session(self, ctx: Any) -> None: + """Wait for the Flow app sign-in on an owned context. + + Returns normally on success AND when the user closed the window first — + ``verify_flow_profile`` is the authority in both cases, and three + releases of docs told users to close the window themselves. Only a + genuine timeout (window still open, still signed out) raises. + """ + page = ctx.pages[0] if ctx.pages else await ctx.new_page() + await page.goto(GEMINI_URL, wait_until="domcontentloaded", timeout=60_000) + await _warn_if_webdriver_exposed(page, self.name) + + started = asyncio.get_running_loop().time() + try: + # NEVER a cookie-name match: the cookie can be present while the + # endpoint still rejects. The session endpoint is the oracle. + email = await poll_session_until_authenticated( + ctx, + page, + self._timeout_seconds, + self.name, + raise_on_close=False, + ) + except AuthLoginTimeoutError: + raise _login_timeout_error(self._timeout_seconds) from None + if email is None: + return + logger.info( + "auth_login_session_detected", + strategy=self.name, + elapsed_s=round(asyncio.get_running_loop().time() - started, 1), + ) + _console.print("\n[bold green]Signed in.[/bold green] Closing Chrome...") + # Let Chrome flush the cookie store to disk before the close — the + # durability check that follows reads that store, not this context. + await asyncio.sleep(1) + + async def _login_subprocess(self, profile_dir: Path, headless: bool) -> None: + """Spawn Chrome as a plain child process and wait for the user to close it.""" chrome_exe = find_chrome_executable() if not chrome_exe: msg = ( @@ -210,14 +383,6 @@ async def login(self, profile_dir: Path, headless: bool) -> None: chrome_args = _build_chrome_args(chrome_exe, profile_dir, headless) - logger.info( - "auth_passive_capture_started", - profile_dir=str(profile_dir), - strategy=self.name, - ) - if not headless: - _print_login_instructions() - # Own the profile while passive-capture Chrome runs (D3). The lease # scope is ONLY the running browser: it is released before # verify_flow_profile below, which momentarily owns its own probe context @@ -238,6 +403,8 @@ async def login(self, profile_dir: Path, headless: bool) -> None: await _await_chrome_close(proc, self._timeout_seconds) _console.print("\n[bold green]Browser closed.[/bold green] Verifying Flow session...") + async def _verify_and_record(self, profile_dir: Path) -> None: + """Prove the on-disk profile carries a usable Flow session, and record it.""" # Pre-write the Chrome marker so the verification fallback can use the # same channel gate if browser-cookie3 decryption fails. The marker must # exist DURING verification (the cookie-decrypt fallback reads it), so we diff --git a/src/gflow_cli/browser_manager.py b/src/gflow_cli/browser_manager.py index 5cf1cc12..13bca02d 100644 --- a/src/gflow_cli/browser_manager.py +++ b/src/gflow_cli/browser_manager.py @@ -32,6 +32,11 @@ resolved_chrome_binary() -> str | None The resolved Chrome binary path, or None. Never raises. +is_playwright_chrome_channel_available() -> bool + True only if Google Chrome proper sits at one of the paths Playwright's + ``channel="chrome"`` hard-codes. Stricter than ``is_chrome_available()``, + which accepts Chromium; ``CHROME_BINARY`` does not satisfy it. + channel_for_profile(profile_dir) -> str | None ``"chrome"`` if the profile's strategy marker requests it AND Google Chrome proper is available at Playwright's expected paths; else None. @@ -47,7 +52,6 @@ Internal helpers (exported for tests) -------------------------------------- _find_chrome_binary() -> str -_is_playwright_chrome_channel_available() -> bool """ from __future__ import annotations @@ -82,7 +86,7 @@ def _find_chrome_binary() -> str: .. note:: This function accepts Chromium as a fallback for the auth use-case. It must NOT be used to decide whether Playwright's ``channel="chrome"`` - is available — use :func:`_is_playwright_chrome_channel_available` for + is available — use :func:`is_playwright_chrome_channel_available` for that, which checks only the exact paths Playwright hard-codes. Raises ``ConfigurationError`` if nothing found. @@ -131,24 +135,22 @@ def _find_chrome_binary() -> str: ) -def _is_playwright_chrome_channel_available() -> bool: +def is_playwright_chrome_channel_available() -> bool: """Return True only when Playwright's ``channel="chrome"`` can find Chrome. Playwright's ``launch_persistent_context(channel="chrome")`` looks for **Google Chrome proper** at platform-specific hardcoded paths — it does NOT accept a plain Chromium binary. This function replicates those paths so - :func:`channel_for_profile` can gate the ``channel="chrome"`` argument on a - binary that Playwright will actually find, avoiding the misleading + callers can gate the ``channel="chrome"`` argument on a binary that Playwright + will actually find, avoiding the misleading ``Chromium distribution 'chrome' is not found at /opt/google/chrome/chrome`` error that occurs when only system Chromium is present. - The ``CHROME_BINARY`` env var override is honoured for parity with - :func:`_find_chrome_binary`. + ``CHROME_BINARY`` is deliberately **ignored** here, unlike in + :func:`_find_chrome_binary`: Playwright honours a custom binary only via + ``executable_path=``, never via ``channel=``. Treating the env var as proof of + a resolvable channel passed this gate and then failed at launch. """ - env_override = os.environ.get("CHROME_BINARY") - if env_override: - return True - # Playwright's own resolution paths for channel="chrome". Derived from # playwright/_impl/_browser_type.py executables(). channel="chrome" resolves # ONLY to these exact Google-Chrome paths — a system Chromium does NOT @@ -179,7 +181,7 @@ def is_chrome_available() -> bool: This is used for the auth login flow. It intentionally accepts Chromium as a fallback so the auth browser can open even when only Chromium is installed. For deciding whether Playwright's ``channel="chrome"`` can be - used, call :func:`_is_playwright_chrome_channel_available` instead. + used, call :func:`is_playwright_chrome_channel_available` instead. """ try: _find_chrome_binary() @@ -216,7 +218,7 @@ def channel_for_profile(profile_dir: Path) -> str | None: exit-33 that occurs when Playwright's bundled Chromium opens a profile created by Chrome 130+. - Critically, this gate uses :func:`_is_playwright_chrome_channel_available` + Critically, this gate uses :func:`is_playwright_chrome_channel_available` (not :func:`is_chrome_available`) so that a system with only Chromium installed does NOT request ``channel="chrome"`` — Playwright's ``channel="chrome"`` resolves to hardcoded Google-Chrome paths and would @@ -236,7 +238,7 @@ def channel_for_profile(profile_dir: Path) -> str | None: strategy = marker.read_text(encoding="utf-8").strip() if strategy != "chrome": return None - if _is_playwright_chrome_channel_available(): + if is_playwright_chrome_channel_available(): return "chrome" _log.warning( "browser_manager.chrome_marker_but_unavailable", diff --git a/tests/auth/strategies/test_strategies.py b/tests/auth/strategies/test_strategies.py index 3224ce4c..6576826e 100644 --- a/tests/auth/strategies/test_strategies.py +++ b/tests/auth/strategies/test_strategies.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -35,6 +36,18 @@ def _build_mock_proc() -> MagicMock: return mock_proc +def _force_subprocess_path() -> Any: + """Pin RealChromeStrategy to its RETAINED subprocess path. + + The default is now the owned-Playwright browser; without this pin these + tests would launch a real Chrome on any machine that has one. + """ + return patch( + "gflow_cli.auth.real_chrome.is_playwright_chrome_channel_available", + return_value=False, + ) + + def _record_lease_events(monkeypatch: pytest.MonkeyPatch, events: list[str]) -> None: """Patch ProfileLease.acquire/release to append to ``events`` — no real locks.""" from gflow_cli.profile_lease import ProfileLease @@ -71,6 +84,7 @@ async def test_real_chrome_launch_flags(self, tmp_path: Path) -> None: with ( patch("gflow_cli.auth.real_chrome.get_settings") as mock_settings, + _force_subprocess_path(), patch("gflow_cli.auth.real_chrome.find_chrome_executable", return_value=fake_chrome), patch("gflow_cli.auth.real_chrome.asyncio.create_subprocess_exec", mock_create), patch( @@ -104,6 +118,7 @@ async def test_real_chrome_success_writes_marker(self, tmp_path: Path) -> None: with ( patch("gflow_cli.auth.real_chrome.get_settings") as mock_settings, + _force_subprocess_path(), patch( "gflow_cli.auth.real_chrome.find_chrome_executable", return_value=r"C:\fake\chrome.exe", @@ -149,6 +164,7 @@ async def _verify(*_a: object, **_k: object) -> FlowSessionStatus: with ( patch("gflow_cli.auth.real_chrome.get_settings") as mock_settings, + _force_subprocess_path(), patch( "gflow_cli.auth.real_chrome.find_chrome_executable", return_value=r"C:\fake\chrome.exe", @@ -187,6 +203,7 @@ async def test_real_chrome_unverified_raises_auth_missing( with ( patch("gflow_cli.auth.real_chrome.get_settings") as mock_settings, + _force_subprocess_path(), patch( "gflow_cli.auth.real_chrome.find_chrome_executable", return_value=r"C:\fake\chrome.exe", @@ -228,6 +245,7 @@ async def test_real_chrome_preserves_preexisting_marker_on_transient_failure( with ( patch("gflow_cli.auth.real_chrome.get_settings") as mock_settings, + _force_subprocess_path(), patch( "gflow_cli.auth.real_chrome.find_chrome_executable", return_value=r"C:\fake\chrome.exe", @@ -265,6 +283,7 @@ async def test_real_chrome_marker_rollback_is_logged(self, tmp_path: Path) -> No with ( patch("gflow_cli.auth.real_chrome.get_settings") as mock_settings, + _force_subprocess_path(), patch( "gflow_cli.auth.real_chrome.find_chrome_executable", return_value=r"C:\fake\chrome.exe", @@ -301,6 +320,7 @@ async def test_real_chrome_no_rollback_event_when_marker_survives(self, tmp_path with ( patch("gflow_cli.auth.real_chrome.get_settings") as mock_settings, + _force_subprocess_path(), patch( "gflow_cli.auth.real_chrome.find_chrome_executable", return_value=r"C:\fake\chrome.exe", @@ -361,6 +381,7 @@ async def _raise_timeout(awaitable: object, *_a: object, **_kw: object) -> None: with ( patch("gflow_cli.auth.real_chrome.get_settings") as mock_settings, + _force_subprocess_path(), patch( "gflow_cli.auth.real_chrome.find_chrome_executable", return_value=r"C:\fake\chrome.exe", @@ -426,9 +447,24 @@ async def test_internal_chromium_standard_behavior(self, tmp_path: Path) -> None _, kwargs = mock_launch_pctx.call_args assert "channel" not in kwargs or kwargs["channel"] != "chrome" - assert "--disable-blink-features=AutomationControlled" not in kwargs.get("args", []) - # Login viewport matches the generation viewport (#315 consistency). - assert kwargs.get("viewport") == {"width": 1920, "height": 1080} + launch_args = kwargs.get("args", []) + # G12 stealth flags. Measured 2026-09-08 (docs/superpowers/spikes/ + # 2026-09-08-g12-blocks-webdriver-not-playwright.md): without them + # navigator.webdriver is True and Google routes to /v3/signin/rejected + # in 17.5s; with them both real Chrome and bundled Chromium signed in. + assert "--disable-blink-features=AutomationControlled" in launch_args + assert kwargs.get("ignore_default_args") == ["--enable-automation"] + # Playwright defaults chromium_sandbox=False, injecting --no-sandbox — + # an extra automation signal plus Chrome's unsupported-flag banner. + assert kwargs.get("chromium_sandbox") is True + # #315: log in at the size generation runs at — through the REAL OS + # window. An explicit viewport makes Playwright emulate that size and + # pushes Google's sign-in form off-screen on smaller/scaled displays. + assert "--window-size=1920,1080" in launch_args + assert kwargs.get("no_viewport") is True + assert "viewport" not in kwargs + # Load-bearing beyond auth: macOS keychain prompt on the profile (#222). + assert "--password-store=basic" in launch_args mock_page.request.get.assert_awaited() account_file = profile_dir / ".gflow_account" assert account_file.exists(), ".gflow_account must be written on successful login" diff --git a/tests/auth/test_real_chrome.py b/tests/auth/test_real_chrome.py index ae46d413..16605e19 100644 --- a/tests/auth/test_real_chrome.py +++ b/tests/auth/test_real_chrome.py @@ -6,22 +6,113 @@ * the duplicate Flow-URL positional is gone (Chrome opens ONE Flow tab), and * a cancellation while waiting for the user to close Chrome terminates + reaps the child and releases the profile lease (nothing orphaned). + +...plus the auto-close Playwright driver (default path) and the two teardown +guarantees it must reproduce, since it bypasses the subprocess code the two +original guards cover. """ from __future__ import annotations import asyncio from pathlib import Path +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest +from playwright.async_api import Error as PlaywrightError +from structlog.testing import capture_logs +from gflow_cli.auth.internal_chromium import GOOGLE_REJECTED_BROWSER_ROUTE from gflow_cli.auth.real_chrome import ( + _UNVERIFIED_HINT, GEMINI_URL, RealChromeStrategy, _await_chrome_close, _build_chrome_args, + _print_login_instructions, ) +from gflow_cli.auth.verification import FlowSessionOutcome, FlowSessionStatus +from gflow_cli.errors import AuthLoginTimeoutError + +AUTHENTICATED_BODY = '{"user": {"email": "test@example.com"}}' + + +def _authenticated_status() -> FlowSessionStatus: + return FlowSessionStatus( + outcome=FlowSessionOutcome.AUTHENTICATED, + user_email="test@example.com", + source="chrome", + ) + + +def _build_fake_playwright( + *, + session_body: str = AUTHENTICATED_BODY, + page_url: str = "https://labs.google/fx/tools/flow", + webdriver: bool = False, + launch_error: Exception | None = None, + poll_error: Exception | None = None, + order: list[str] | None = None, + on_poll: Any = None, +) -> tuple[MagicMock, MagicMock, MagicMock]: + """Build (async_playwright_factory, pw, ctx) doubles for the owned-browser path.""" + resp = MagicMock(name="resp") + resp.status = 200 + resp.text = AsyncMock(return_value=session_body) + + page = MagicMock(name="page") + page.url = page_url + page.goto = AsyncMock() + page.evaluate = AsyncMock(return_value=webdriver) + page.request.get = AsyncMock(return_value=resp, side_effect=poll_error) + + async def _cookies() -> list[dict[str, str]]: + if on_poll is not None: + await on_poll() + return [{"name": "SAPISID", "value": "x"}] + + ctx = MagicMock(name="ctx") + ctx.pages = [page] + ctx.cookies = AsyncMock(side_effect=_cookies) + ctx.new_page = AsyncMock(return_value=page) + + async def _close() -> None: + if order is not None: + order.append("close_context") + + ctx.close = AsyncMock(side_effect=_close) + + pw = MagicMock(name="pw") + pw.chromium.launch_persistent_context = AsyncMock( + return_value=ctx, + side_effect=launch_error, + ) + + async def _aexit(*_a: object) -> bool: + if order is not None: + order.append("stop_driver") + return False + + cm = MagicMock(name="cm") + cm.__aenter__ = AsyncMock(return_value=pw) + cm.__aexit__ = AsyncMock(side_effect=_aexit) + return MagicMock(name="async_playwright", return_value=cm), pw, ctx + + +def _record_lease_events(monkeypatch: pytest.MonkeyPatch, events: list[str]) -> None: + from gflow_cli.profile_lease import ProfileLease + + def acq(self: ProfileLease) -> ProfileLease: + events.append("acquire") + return self + + def rel(self: ProfileLease) -> None: + events.append("release") + + monkeypatch.setattr(ProfileLease, "acquire", acq) + monkeypatch.setattr(ProfileLease, "release", rel) + # --------------------------------------------------------------------------- # _build_chrome_args — Flow URL appears exactly once (D4: dup positional gone) @@ -119,6 +210,11 @@ async def _wait() -> int: with ( patch("gflow_cli.auth.real_chrome.get_settings") as mock_settings, + # This guard covers the RETAINED subprocess path — force it. + patch( + "gflow_cli.auth.real_chrome.is_playwright_chrome_channel_available", + return_value=False, + ), patch( "gflow_cli.auth.real_chrome.find_chrome_executable", return_value=r"C:\fake\chrome.exe", @@ -143,3 +239,428 @@ async def _wait() -> int: proc.terminate.assert_called_once() # Lease acquired around Chrome, then released on the cancellation path. assert events == ["acquire", "release"] + + +# --------------------------------------------------------------------------- +# Playwright auto-close driver (default path) +# --------------------------------------------------------------------------- + + +class TestPlaywrightAutoClose: + """The owned-browser path: gflow closes Chrome itself once Flow signs in.""" + + @staticmethod + def _home(tmp_path: Path) -> tuple[Path, Path]: + gflow_home = tmp_path / "gflow_home" + gflow_home.mkdir() + return gflow_home, gflow_home / "profile_default" + + @pytest.mark.asyncio + async def test_default_path_owns_and_closes_the_browser(self, tmp_path: Path) -> None: + """Chrome channel resolvable -> Playwright drives the login and closes it.""" + gflow_home, profile_dir = self._home(tmp_path) + ap, pw, ctx = _build_fake_playwright() + subprocess_exec = AsyncMock() + + with ( + patch("gflow_cli.auth.real_chrome.get_settings") as mock_settings, + patch( + "gflow_cli.auth.real_chrome.is_playwright_chrome_channel_available", + return_value=True, + ), + patch("gflow_cli.auth.strategies.async_playwright", ap), + patch("gflow_cli.auth.real_chrome.asyncio.create_subprocess_exec", subprocess_exec), + patch( + "gflow_cli.auth.real_chrome.verify_flow_profile", + AsyncMock(return_value=_authenticated_status()), + ), + patch("gflow_cli.auth.real_chrome.asyncio.sleep", AsyncMock()), + ): + mock_settings.return_value.home = gflow_home + await RealChromeStrategy().login(profile_dir, headless=False) + + subprocess_exec.assert_not_awaited() + ctx.close.assert_awaited() + kwargs = pw.chromium.launch_persistent_context.call_args.kwargs + assert kwargs["user_data_dir"] == str(profile_dir) + assert kwargs["channel"] == "chrome" + assert kwargs["headless"] is False + assert kwargs["no_viewport"] is True + assert kwargs["chromium_sandbox"] is True + assert kwargs["ignore_default_args"] == ["--enable-automation"] + assert "viewport" not in kwargs + launch_args = kwargs["args"] + assert "--disable-blink-features=AutomationControlled" in launch_args + assert "--window-size=1920,1080" in launch_args + assert "--password-store=basic" in launch_args + # The durability check still runs, outside the lease, exactly as before. + assert (profile_dir / ".gflow_browser_strategy").read_text(encoding="utf-8") == "chrome" + assert (profile_dir / ".gflow_account").read_text(encoding="utf-8") == "test@example.com" + + @pytest.mark.asyncio + async def test_no_chrome_channel_falls_back_to_subprocess(self, tmp_path: Path) -> None: + """No resolvable channel -> the old subprocess flow runs, silently.""" + gflow_home, profile_dir = self._home(tmp_path) + ap, pw, _ctx = _build_fake_playwright() + proc = MagicMock(name="proc") + proc.wait = AsyncMock(return_value=0) + proc.terminate = MagicMock() + proc.kill = MagicMock() + + with ( + patch("gflow_cli.auth.real_chrome.get_settings") as mock_settings, + patch( + "gflow_cli.auth.real_chrome.is_playwright_chrome_channel_available", + return_value=False, + ), + patch("gflow_cli.auth.strategies.async_playwright", ap), + patch( + "gflow_cli.auth.real_chrome.find_chrome_executable", + return_value=r"C:\fake\chrome.exe", + ), + patch( + "gflow_cli.auth.real_chrome.asyncio.create_subprocess_exec", + AsyncMock(return_value=proc), + ) as subprocess_exec, + patch( + "gflow_cli.auth.real_chrome.verify_flow_profile", + AsyncMock(return_value=_authenticated_status()), + ), + capture_logs() as logs, + ): + mock_settings.return_value.home = gflow_home + await RealChromeStrategy().login(profile_dir, headless=False) + + subprocess_exec.assert_awaited_once() + pw.chromium.launch_persistent_context.assert_not_awaited() + fallbacks = [e for e in logs if e.get("event") == "auth_login_subprocess_fallback"] + assert len(fallbacks) == 1 + assert fallbacks[0]["reason"] == "channel_unavailable" + + @pytest.mark.asyncio + async def test_headless_never_reaches_the_owned_browser(self, tmp_path: Path) -> None: + """headless=True takes the subprocess path even when the channel resolves. + + Every arm of the 2026-09-08 spike was headed, so a headless Playwright sign-in is + unmeasured against Google's gate. The subprocess path's ``--headless=new`` branch + predates this change and is the measured option. + """ + gflow_home, profile_dir = self._home(tmp_path) + ap, pw, _ctx = _build_fake_playwright() + proc = MagicMock(name="proc") + proc.wait = AsyncMock(return_value=0) + proc.terminate = MagicMock() + proc.kill = MagicMock() + + with ( + patch("gflow_cli.auth.real_chrome.get_settings") as mock_settings, + patch( + "gflow_cli.auth.real_chrome.is_playwright_chrome_channel_available", + return_value=True, + ), + patch("gflow_cli.auth.strategies.async_playwright", ap), + patch( + "gflow_cli.auth.real_chrome.find_chrome_executable", + return_value=r"C:\fake\chrome.exe", + ), + patch( + "gflow_cli.auth.real_chrome.asyncio.create_subprocess_exec", + AsyncMock(return_value=proc), + ) as subprocess_exec, + patch( + "gflow_cli.auth.real_chrome.verify_flow_profile", + AsyncMock(return_value=_authenticated_status()), + ), + capture_logs() as logs, + ): + mock_settings.return_value.home = gflow_home + await RealChromeStrategy().login(profile_dir, headless=True) + + subprocess_exec.assert_awaited_once() + pw.chromium.launch_persistent_context.assert_not_awaited() + fallbacks = [e for e in logs if e.get("event") == "auth_login_subprocess_fallback"] + assert len(fallbacks) == 1 + assert fallbacks[0]["reason"] == "headless" + + @pytest.mark.asyncio + async def test_google_rejection_falls_back_to_subprocess_once(self, tmp_path: Path) -> None: + """Google's rejected-browser page must not surface exit 14 to the user.""" + gflow_home, profile_dir = self._home(tmp_path) + ap, pw, ctx = _build_fake_playwright( + page_url=f"https://{GOOGLE_REJECTED_BROWSER_ROUTE}?continue=flow", + ) + proc = MagicMock(name="proc") + proc.wait = AsyncMock(return_value=0) + proc.terminate = MagicMock() + proc.kill = MagicMock() + + with ( + patch("gflow_cli.auth.real_chrome.get_settings") as mock_settings, + patch( + "gflow_cli.auth.real_chrome.is_playwright_chrome_channel_available", + return_value=True, + ), + patch("gflow_cli.auth.strategies.async_playwright", ap), + patch( + "gflow_cli.auth.real_chrome.find_chrome_executable", + return_value=r"C:\fake\chrome.exe", + ), + patch( + "gflow_cli.auth.real_chrome.asyncio.create_subprocess_exec", + AsyncMock(return_value=proc), + ) as subprocess_exec, + patch( + "gflow_cli.auth.real_chrome.verify_flow_profile", + AsyncMock(return_value=_authenticated_status()), + ), + capture_logs() as logs, + ): + mock_settings.return_value.home = gflow_home + await RealChromeStrategy().login(profile_dir, headless=False) + + pw.chromium.launch_persistent_context.assert_awaited_once() + ctx.close.assert_awaited() # the rejected window is closed, not left open + subprocess_exec.assert_awaited_once() + fallbacks = [e for e in logs if e.get("event") == "auth_login_subprocess_fallback"] + assert [e["reason"] for e in fallbacks] == ["browser_rejected"] + + @pytest.mark.asyncio + async def test_launch_failure_falls_back_to_subprocess(self, tmp_path: Path) -> None: + gflow_home, profile_dir = self._home(tmp_path) + ap, _pw, _ctx = _build_fake_playwright(launch_error=PlaywrightError("no chrome")) + proc = MagicMock(name="proc") + proc.wait = AsyncMock(return_value=0) + proc.terminate = MagicMock() + proc.kill = MagicMock() + + with ( + patch("gflow_cli.auth.real_chrome.get_settings") as mock_settings, + patch( + "gflow_cli.auth.real_chrome.is_playwright_chrome_channel_available", + return_value=True, + ), + patch("gflow_cli.auth.strategies.async_playwright", ap), + patch( + "gflow_cli.auth.real_chrome.find_chrome_executable", + return_value=r"C:\fake\chrome.exe", + ), + patch( + "gflow_cli.auth.real_chrome.asyncio.create_subprocess_exec", + AsyncMock(return_value=proc), + ) as subprocess_exec, + patch( + "gflow_cli.auth.real_chrome.verify_flow_profile", + AsyncMock(return_value=_authenticated_status()), + ), + capture_logs() as logs, + ): + mock_settings.return_value.home = gflow_home + await RealChromeStrategy().login(profile_dir, headless=False) + + subprocess_exec.assert_awaited_once() + failures = [e for e in logs if e.get("event") == "auth_login_launch_failed"] + assert len(failures) == 1 + assert failures[0]["error"] == "Error" + fallbacks = [e for e in logs if e.get("event") == "auth_login_subprocess_fallback"] + assert [e["reason"] for e in fallbacks] == ["launch_failed"] + + @pytest.mark.asyncio + async def test_manual_close_is_not_an_error(self, tmp_path: Path) -> None: + """Three releases told users to close the window themselves. Doing so on a + login that actually succeeded must NOT produce a red error — it falls + through to verify_flow_profile, which is the authority either way.""" + gflow_home, profile_dir = self._home(tmp_path) + ap, _pw, _ctx = _build_fake_playwright(poll_error=PlaywrightError("Target closed")) + + with ( + patch("gflow_cli.auth.real_chrome.get_settings") as mock_settings, + patch( + "gflow_cli.auth.real_chrome.is_playwright_chrome_channel_available", + return_value=True, + ), + patch("gflow_cli.auth.strategies.async_playwright", ap), + patch( + "gflow_cli.auth.real_chrome.verify_flow_profile", + AsyncMock(return_value=_authenticated_status()), + ), + patch("gflow_cli.auth.real_chrome.asyncio.sleep", AsyncMock()), + capture_logs() as logs, + ): + mock_settings.return_value.home = gflow_home + await RealChromeStrategy().login(profile_dir, headless=False) + + assert (profile_dir / ".gflow_account").read_text(encoding="utf-8") == "test@example.com" + assert [e for e in logs if e.get("event") == "auth_login_browser_closed_by_user"] + + @pytest.mark.asyncio + async def test_timeout_with_window_open_raises(self, tmp_path: Path) -> None: + gflow_home, profile_dir = self._home(tmp_path) + ap, _pw, ctx = _build_fake_playwright(session_body="{}") + + with ( + patch("gflow_cli.auth.real_chrome.get_settings") as mock_settings, + patch( + "gflow_cli.auth.real_chrome.is_playwright_chrome_channel_available", + return_value=True, + ), + patch("gflow_cli.auth.strategies.async_playwright", ap), + patch( + "gflow_cli.auth.real_chrome.verify_flow_profile", + AsyncMock(side_effect=AssertionError("verification must not run on timeout")), + ), + ): + mock_settings.return_value.home = gflow_home + with pytest.raises(AuthLoginTimeoutError) as excinfo: + await RealChromeStrategy(timeout_seconds=0).login(profile_dir, headless=False) + + assert "not detected within 0s" in str(excinfo.value) + assert "GFLOW_CLI_AUTH_LOGIN_TIMEOUT" in (excinfo.value.remediation_hint or "") + ctx.close.assert_awaited() + + @pytest.mark.asyncio + async def test_timeout_teardown_order( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Timeout closes the context, stops the driver, then releases the lease.""" + gflow_home, profile_dir = self._home(tmp_path) + order: list[str] = [] + _record_lease_events(monkeypatch, order) + ap, _pw, _ctx = _build_fake_playwright(session_body="{}", order=order) + + with ( + patch("gflow_cli.auth.real_chrome.get_settings") as mock_settings, + patch( + "gflow_cli.auth.real_chrome.is_playwright_chrome_channel_available", + return_value=True, + ), + patch("gflow_cli.auth.strategies.async_playwright", ap), + ): + mock_settings.return_value.home = gflow_home + with pytest.raises(AuthLoginTimeoutError): + await RealChromeStrategy(timeout_seconds=0).login(profile_dir, headless=False) + + assert order == ["acquire", "close_context", "stop_driver", "release"] + + @pytest.mark.asyncio + async def test_cancellation_teardown_order( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Ctrl-C mid-login: close context -> stop driver -> release lease, in that + order. Ported from the subprocess guard — an orphaned browser keeps the + profile's SQLite lock and therefore the ProfileLease (fixed twice).""" + gflow_home, profile_dir = self._home(tmp_path) + order: list[str] = [] + _record_lease_events(monkeypatch, order) + polling = asyncio.Event() + forever = asyncio.Event() + + async def _block() -> None: + polling.set() + await forever.wait() + + ap, _pw, _ctx = _build_fake_playwright(order=order, on_poll=_block) + + with ( + patch("gflow_cli.auth.real_chrome.get_settings") as mock_settings, + patch( + "gflow_cli.auth.real_chrome.is_playwright_chrome_channel_available", + return_value=True, + ), + patch("gflow_cli.auth.strategies.async_playwright", ap), + patch( + "gflow_cli.auth.real_chrome.verify_flow_profile", + AsyncMock(side_effect=AssertionError("verification must not run after cancel")), + ), + ): + mock_settings.return_value.home = gflow_home + task = asyncio.create_task(RealChromeStrategy().login(profile_dir, headless=False)) + await asyncio.wait_for(polling.wait(), timeout=1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert order == ["acquire", "close_context", "stop_driver", "release"] + + @pytest.mark.asyncio + async def test_webdriver_exposed_is_logged(self, tmp_path: Path) -> None: + """A future Chrome that ignores the stealth flag must fail loudly, not as + a 600s timeout telling the user to sign in faster.""" + gflow_home, profile_dir = self._home(tmp_path) + ap, _pw, _ctx = _build_fake_playwright(webdriver=True) + + with ( + patch("gflow_cli.auth.real_chrome.get_settings") as mock_settings, + patch( + "gflow_cli.auth.real_chrome.is_playwright_chrome_channel_available", + return_value=True, + ), + patch("gflow_cli.auth.strategies.async_playwright", ap), + patch( + "gflow_cli.auth.real_chrome.verify_flow_profile", + AsyncMock(return_value=_authenticated_status()), + ), + patch("gflow_cli.auth.real_chrome.asyncio.sleep", AsyncMock()), + capture_logs() as logs, + ): + mock_settings.return_value.home = gflow_home + await RealChromeStrategy().login(profile_dir, headless=False) + + assert [e for e in logs if e.get("event") == "auth_login_webdriver_exposed"] + + @pytest.mark.asyncio + async def test_never_logs_a_google_url(self, tmp_path: Path) -> None: + """OAuth `state` / `code_challenge` live in these URLs and data/redaction.py + matches neither — so no page URL may ever reach a log event.""" + gflow_home, profile_dir = self._home(tmp_path) + ap, _pw, _ctx = _build_fake_playwright( + page_url=( + "https://accounts.google.com/v3/signin/identifier" + "?state=SECRETSTATE&code_challenge=SECRETCHALLENGE" + ), + ) + + with ( + patch("gflow_cli.auth.real_chrome.get_settings") as mock_settings, + patch( + "gflow_cli.auth.real_chrome.is_playwright_chrome_channel_available", + return_value=True, + ), + patch("gflow_cli.auth.strategies.async_playwright", ap), + patch( + "gflow_cli.auth.real_chrome.verify_flow_profile", + AsyncMock(return_value=_authenticated_status()), + ), + patch("gflow_cli.auth.real_chrome.asyncio.sleep", AsyncMock()), + capture_logs() as logs, + ): + mock_settings.return_value.home = gflow_home + await RealChromeStrategy().login(profile_dir, headless=False) + + blob = repr(logs) + assert "accounts.google.com" not in blob + assert "SECRETSTATE" not in blob + assert "SECRETCHALLENGE" not in blob + # The rename must be observable: the old event name is gone. + assert [e for e in logs if e.get("event") == "auth_login_started"] + assert not [e for e in logs if e.get("event") == "auth_passive_capture_started"] + detected = [e for e in logs if e.get("event") == "auth_login_session_detected"] + assert len(detected) == 1 + assert detected[0]["strategy"] == "chrome" + assert "elapsed_s" in detected[0] + + +# --------------------------------------------------------------------------- +# T5 — copy +# --------------------------------------------------------------------------- + + +def test_login_instructions_say_gflow_closes_chrome(capsys: pytest.CaptureFixture[str]) -> None: + _print_login_instructions() + out = capsys.readouterr().out + assert "BROWSER SIGN-IN" in out + assert "PASSIVE AUTHENTICATION" not in out + assert "closes" in out.lower() + + +def test_google_session_only_hint_drops_close_chrome() -> None: + assert "before closing Chrome" not in _UNVERIFIED_HINT[FlowSessionOutcome.GOOGLE_SESSION_ONLY] diff --git a/tests/test_browser_manager.py b/tests/test_browser_manager.py index 4685307c..1192f9b7 100644 --- a/tests/test_browser_manager.py +++ b/tests/test_browser_manager.py @@ -124,7 +124,7 @@ class TestPlaywrightChromeChannelAvailable: def test_chromium_only_host_returns_false(self) -> None: """Chromium on PATH but no Google Chrome at Playwright's paths → False.""" - from gflow_cli.browser_manager import _is_playwright_chrome_channel_available + from gflow_cli.browser_manager import is_playwright_chrome_channel_available env_without = {k: v for k, v in os.environ.items() if k != "CHROME_BINARY"} with ( @@ -134,11 +134,11 @@ def test_chromium_only_host_returns_false(self) -> None: patch("gflow_cli.browser_manager.shutil.which", return_value="/usr/bin/chromium"), patch.object(Path, "exists", return_value=False), ): - assert _is_playwright_chrome_channel_available() is False + assert is_playwright_chrome_channel_available() is False def test_returns_true_when_google_chrome_present(self) -> None: """Google Chrome at Playwright's expected path → True.""" - from gflow_cli.browser_manager import _is_playwright_chrome_channel_available + from gflow_cli.browser_manager import is_playwright_chrome_channel_available env_without = {k: v for k, v in os.environ.items() if k != "CHROME_BINARY"} with ( @@ -146,18 +146,38 @@ def test_returns_true_when_google_chrome_present(self) -> None: patch("sys.platform", "linux"), patch.object(Path, "exists", return_value=True), ): - assert _is_playwright_chrome_channel_available() is True + assert is_playwright_chrome_channel_available() is True - def test_env_override_returns_true(self, tmp_path: Path) -> None: - """CHROME_BINARY override is honoured for parity with _find_chrome_binary.""" - from gflow_cli.browser_manager import _is_playwright_chrome_channel_available + def test_env_override_alone_returns_false(self, tmp_path: Path) -> None: + """CHROME_BINARY set but no Google Chrome at Playwright's paths → False. - with patch.dict(os.environ, {"CHROME_BINARY": str(tmp_path / "chrome")}): - assert _is_playwright_chrome_channel_available() is True + Playwright's ``channel="chrome"`` ignores ``CHROME_BINARY`` (only + ``executable_path=`` honours a custom binary), so treating the env var as + proof of a resolvable channel passed the gate and then failed at launch. + """ + from gflow_cli.browser_manager import is_playwright_chrome_channel_available + + with ( + patch.dict(os.environ, {"CHROME_BINARY": str(tmp_path / "chrome")}), + patch("sys.platform", "linux"), + patch.object(Path, "exists", return_value=False), + ): + assert is_playwright_chrome_channel_available() is False + + def test_env_override_does_not_mask_a_real_chrome(self, tmp_path: Path) -> None: + """CHROME_BINARY is ignored, not inverted: real Chrome present still → True.""" + from gflow_cli.browser_manager import is_playwright_chrome_channel_available + + with ( + patch.dict(os.environ, {"CHROME_BINARY": str(tmp_path / "chrome")}), + patch("sys.platform", "linux"), + patch.object(Path, "exists", return_value=True), + ): + assert is_playwright_chrome_channel_available() is True def test_win32_probes_program_files_chrome(self) -> None: """On win32, the Program Files Google-Chrome path is probed → True when present.""" - from gflow_cli.browser_manager import _is_playwright_chrome_channel_available + from gflow_cli.browser_manager import is_playwright_chrome_channel_available expected = "C:/Program Files/Google/Chrome/Application/chrome.exe" env_without = { @@ -173,11 +193,11 @@ def path_exists_mock(self: Path) -> bool: patch("sys.platform", "win32"), patch.object(Path, "exists", path_exists_mock), ): - assert _is_playwright_chrome_channel_available() is True + assert is_playwright_chrome_channel_available() is True def test_darwin_probes_app_bundle(self) -> None: """On darwin, the /Applications Google Chrome.app path is probed.""" - from gflow_cli.browser_manager import _is_playwright_chrome_channel_available + from gflow_cli.browser_manager import is_playwright_chrome_channel_available env_without = {k: v for k, v in os.environ.items() if k != "CHROME_BINARY"} with ( @@ -185,7 +205,23 @@ def test_darwin_probes_app_bundle(self) -> None: patch("sys.platform", "darwin"), patch.object(Path, "exists", return_value=True), ): - assert _is_playwright_chrome_channel_available() is True + assert is_playwright_chrome_channel_available() is True + + def test_public_name_is_what_channel_for_profile_gates_on(self, tmp_path: Path) -> None: + """The predicate is public API, and ``channel_for_profile`` routes through it. + + ``factory.py`` needs to gate strategy selection on the same predicate, so it + must be importable without reaching for a private name. + """ + import gflow_cli.browser_manager as bm + + assert callable(bm.is_playwright_chrome_channel_available) + (tmp_path / ".gflow_browser_strategy").write_text("chrome", encoding="utf-8") + + with patch.object(bm, "is_playwright_chrome_channel_available", return_value=True): + assert bm.channel_for_profile(tmp_path) == "chrome" + with patch.object(bm, "is_playwright_chrome_channel_available", return_value=False): + assert bm.channel_for_profile(tmp_path) is None # --------------------------------------------------------------------------- diff --git a/website/docs/ARCHITECTURE.md b/website/docs/ARCHITECTURE.md index 8dbbcabc..113358ec 100644 --- a/website/docs/ARCHITECTURE.md +++ b/website/docs/ARCHITECTURE.md @@ -94,10 +94,10 @@ class AuthStrategy(Protocol): - `mode="internal"` — explicit `InternalChromiumStrategy`. **RealChromeStrategy stealth design** (`real_chrome.py`): -- Uses a **Passive Capture** pattern: launches system Chrome via `subprocess.Popen` without any automation flags or remote-debugging ports. -- Provides a 100% clean browser process that Google's G12 block cannot detect. -- The CLI blocks on `proc.wait()`, prompting the user to complete the sign-in and **close the browser completely**. -- Post-close: performs a fast, headless `launch_persistent_context` probe to verify the `SAPISID` cookie was successfully captured. +- **Default — Playwright-owned Chrome.** Launches the system's real Google Chrome via Playwright's `channel="chrome"` with `chromium_sandbox=True`, `no_viewport=True`, `--disable-blink-features=AutomationControlled`, and `ignore_default_args=["--enable-automation"]`. What Google's G12 block keys on is a browser that *advertises* automation (`navigator.webdriver`), not the Playwright connection itself; with these flags the property is `false` and sign-in proceeds normally. See the G12 entry in [KNOWN_ISSUES.md](../KNOWN_ISSUES.md) for the 2026-09-08 measurement and its N=1 caveat. +- Because gflow owns that context, it polls the Flow session endpoint from it until the outcome is `AUTHENTICATED` and then **closes the browser itself** — the user is not asked to close anything. A user who closes the window anyway is routed to the same `verify_flow_profile` check, never to an error. +- **Automatic fallback — Passive Capture.** When Playwright cannot resolve a Chrome channel (`browser_manager.is_playwright_chrome_channel_available()`), or Google rejects the browser anyway, the strategy silently falls back to the older shape: system Chrome via `subprocess.Popen` with no automation flags and no remote-debugging port, the CLI blocking on `proc.wait()` until the user closes the window. There is **no user-facing flag and no choice to make** — a Chromium-only host is never locked out of onboarding. +- Verification: both paths end in the same `verify_flow_profile` call after the browser is gone. That probe is **httpx-first** — it reads the profile's cookie store directly via `browser_cookie3` and only falls back to a headless `launch_persistent_context` when cookie decryption fails (DPAPI on Windows, keychain on macOS, libsecret on Linux). The default path additionally polls the same session contract *from the browser it owns*, which is what tells it when to close. Both write the `.gflow_browser_strategy = "chrome"` marker that `channel_for_profile()` later reads. - Privacy guard: raises `SecurityError` if the resolved `profile_dir` is outside `GFLOW_CLI_HOME` — protects the user's primary system Chrome profile from being used as a session store. **UiAutomationTransport (UI Mimicry)**: diff --git a/website/docs/AUTHENTICATION.md b/website/docs/AUTHENTICATION.md index cca93a16..1178ff2d 100644 --- a/website/docs/AUTHENTICATION.md +++ b/website/docs/AUTHENTICATION.md @@ -179,17 +179,37 @@ and the profile keeps the name `default`. | Value | Browser used | When to use | |---|---|---| | `auto` (default) | Real Chrome if installed; falls back to internal | First choice for most users | -| `chrome` | System Google Chrome (**Passive Capture**) | Required to bypass "G12" blocks | +| `chrome` | System Google Chrome, driven by Playwright (auto-closes) | Required to bypass "G12" blocks | | `internal` | Playwright's bundled Chromium | Fallback when Chrome isn't installed | Override with the env var: `GFLOW_CLI_AUTH_BROWSER=chrome gflow auth login` -**Why `chrome` bypasses bot detection:** Playwright's default automation mode exposes -`navigator.webdriver = true` as a non-configurable native property. Google detects this -and redirects to `/v3/signin/rejected` (the "G12 block"). The `chrome` strategy -implements **Passive Capture**: it launches your real system Chrome as a 100% standard -process without any automation flags or debugging ports. You log in manually, close -the window, and `gflow` extracts the verified session from the profile. +`internal` now launches with the same anti-automation flags as `chrome` (it previously did +not, which was the configuration Google rejects). It stays a fallback rather than a +recommendation: a profile created by `internal` carries no `chrome` strategy marker, so +generation later opens it with bundled Chromium instead of your real Chrome. + +**Why `chrome` bypasses bot detection:** what Google rejects is a browser that *advertises* +automation. Blink sets `navigator.webdriver = true` as a non-configurable native property +unless `--disable-blink-features=AutomationControlled` is passed, and Google redirects that +browser to `/v3/signin/rejected` (the "G12 block"). The `chrome` strategy launches your real +system Chrome through Playwright with that flag plus +`ignore_default_args=["--enable-automation"]`, `chromium_sandbox=True`, and +`no_viewport=True`, so `navigator.webdriver` is `false` and the sign-in proceeds normally. +The block itself is still live — re-measured 2026-09-08; see the G12 entry in +[KNOWN_ISSUES.md](../KNOWN_ISSUES.md) for the numbers and their N=1 caveat. + +**You don't close the browser — gflow does.** Because gflow owns that Chrome window, it +watches for the completed Flow sign-in and closes the window itself, then prints the +verified account. If you close the window yourself it still works: gflow verifies the +profile exactly the same way and does not treat a manual close as an error. + +**Automatic fallback, with nothing to choose.** If Playwright can't resolve a Chrome channel +on this machine (a Chromium-only Linux box, for instance), or Google rejects the browser +anyway, `gflow auth login` falls back to the earlier **Passive Capture** flow: Chrome +launched as a plain process with no automation flags and no debugging port, where you close +the window once the Flow editor has loaded and `gflow` extracts the verified session from the +profile. There is no flag and no prompt for this — the fallback simply happens. **Privacy guard:** The `chrome` strategy strictly refuses to use any profile directory outside `GFLOW_CLI_HOME`. This protects your primary system Chrome profile from diff --git a/website/docs/KNOWN_ISSUES.md b/website/docs/KNOWN_ISSUES.md index d96f35d2..d5433ba6 100644 --- a/website/docs/KNOWN_ISSUES.md +++ b/website/docs/KNOWN_ISSUES.md @@ -1523,9 +1523,10 @@ End-to-end live-verified on the `ffroliva` profile across `9:16`, `16:9`, `1:1`, ### G12 "browser not secure" block — Google rejects automated sign-in -- **Status:** Resolved · **Severity:** Critical (blocked `gflow auth login`) · **Fixed in:** v0.6.0a2 +- **Status:** Resolved · **Severity:** Critical (blocked `gflow auth login`) · **Fixed in:** v0.6.0a2 · **Mitigation reimplemented + re-measured:** 2026-09-08 -Google's sign-in flow (`accounts.google.com/v3/signin/rejected`) detected Playwright's bundled Chromium as an automated browser and refused the login with no user-facing error. +Google's sign-in flow (`accounts.google.com/v3/signin/rejected`) rejects a browser that +advertises itself as automated, and refuses the login with no user-facing error. **Root cause (timing race):** Without `--disable-blink-features=AutomationControlled`, Blink's C++ engine sets `navigator.webdriver = true` as a non-configurable, non-writable @@ -1533,20 +1534,52 @@ native property at Chrome startup — before any JavaScript (including `add_init can run. The `Object.defineProperty` override silently fails. With the flag, the property is never set; the JS override then works as belt-and-suspenders. -**Resolution:** `v0.6.0a2` adds `RealChromeStrategy` — a new auth strategy that launches -the system's real Google Chrome via Playwright's `channel="chrome"` with stealth flags. +**Resolution:** `gflow auth login` launches the system's real Google Chrome through +Playwright's `channel="chrome"` with `chromium_sandbox=True`, `no_viewport=True`, and both +stealth flags — `--disable-blink-features=AutomationControlled` and +`ignore_default_args=["--enable-automation"]`. Because gflow owns that browser it also +detects the completed Flow sign-in and closes the window itself; see +[docs/AUTHENTICATION.md](AUTHENTICATION.md). When no Chrome channel resolves, or +Google rejects the browser anyway, login falls back automatically to launching Chrome as a +plain subprocess and waiting for you to close the window. There is no flag and no choice to +make, and closing the window yourself works on either path. + +> **This entry described that Playwright implementation long before it existed.** +> It read *"`v0.6.0a2` adds `RealChromeStrategy` — launches the system's real Google Chrome +> via Playwright's `channel="chrome"` with stealth flags."* `src/gflow_cli/auth/real_chrome.py` +> was created at `eb0de133` (2026-07-19) as a bare `subprocess.Popen` passive capture, and +> `git log -S'channel="chrome"' -- src/gflow_cli/auth/` returned **zero** commits until the +> auto-close change. The paragraph above is the same shape restated deliberately as current +> fact, not the same accident left standing. ```bash -# Bypass G12 block explicitly: +# Ask for real Chrome explicitly: gflow auth login --browser chrome # Or rely on auto-detection (default behaviour; picks real Chrome if installed): gflow auth login ``` -A cosmetic "You are using an unsupported command-line flag" notice may appear briefly in -the Chrome window — this is harmless and can be dismissed. It is the accepted trade-off -for bypassing G12. +**The block is current Google behaviour — "Resolved" means the mitigation holds, not that +Google stopped.** Re-measured 2026-09-08 across three throwaway *unauthenticated* profiles, +each signed into by hand +([spike](https://github.com/ffroliva/gflow-cli/blob/main/docs/superpowers/spikes/2026-09-08-g12-blocks-webdriver-not-playwright.md)): a +browser advertising `navigator.webdriver === true` — real Chrome, no stealth flags — was +rejected at `/v3/signin/rejected` **17.5 s** into the flow, while the same real Chrome +*with* the flags reported `false`, never saw the rejection, and reached a Flow session +cookie at 59.4 s. Playwright's bundled Chromium with the flags passed too, so the binary is +not the discriminator; `navigator.webdriver` tracked the outcome in all three arms. + +> **This is N=1 — do not read it as a capability claim.** One account, one Windows host, one +> residential IP, one Chrome build (`Chrome/149.0.0.0`), one day. Google's sign-in risk +> scoring varies with account age and IP reputation, so it does not predict CI, a VPS, or a +> fresh account. Every arm ran headed, so it says nothing about headless in either +> direction. Sign-in is also a different gate from generation's reCAPTCHA Enterprise check; +> a result on one does not move the other. + +The Chrome window no longer shows the "You are using an unsupported command-line flag" +notice this entry used to warn about: that banner came from the `--no-sandbox` Playwright +injects by default, and `chromium_sandbox=True` stops the injection. --- diff --git a/website/docs/USER_GUIDE.md b/website/docs/USER_GUIDE.md index 26e1fc9b..32b9a333 100644 --- a/website/docs/USER_GUIDE.md +++ b/website/docs/USER_GUIDE.md @@ -71,7 +71,7 @@ This is a ~150 MB download. It happens once per user. gflow auth login ``` -A Chromium window opens. Sign in to the Google account you use for Flow. **Solve any captchas Google shows you** — `gflow-cli` cannot solve them; that's intentional (anti-bot detection). When the Flow dashboard loads, return to your terminal and confirm. +A browser window opens (real Chrome where it's installed). Sign in to the Google account you use for Flow. **Solve any captchas Google shows you** — `gflow-cli` cannot solve them; that's intentional (anti-bot detection). Keep going until the Flow dashboard loads; **gflow detects the completed sign-in and closes the window for you**, then prints the verified account in your terminal. Closing the window yourself works too. Your session is saved under (one of): - Windows: `%LOCALAPPDATA%\gflow-cli\profile_default\` @@ -750,7 +750,11 @@ gflow auth login --profile --browser chrome 1. Chrome opens to `https://labs.google/fx/tools/flow?hl=en`. 2. Sign in to the Google account you use for Flow. -3. When the Flow editor loads, **close Chrome**. +3. Keep going until the Flow editor loads — **gflow closes Chrome for you** once it sees the + completed Flow sign-in. (Closing the window yourself also works and verifies the same + way. On a machine where Playwright can't resolve a Chrome channel, login falls back + automatically to the older flow, where you close the window; nothing to configure either + way.) 4. `gflow auth login` probes the profile with `channel="chrome"`, verifies SAPISID is present, and writes `.gflow_browser_strategy = "chrome"` to the profile directory. 5. Subsequent `gflow image` / `gflow video` calls will use Chrome to open the profile and diff --git a/website/docs/onboarding-mockup.html b/website/docs/onboarding-mockup.html index ddcd62a3..51d06894 100644 --- a/website/docs/onboarding-mockup.html +++ b/website/docs/onboarding-mockup.html @@ -513,7 +513,7 @@

Prerequisites

Authenticate
-

A one-time login opens a real Chrome window. Sign in with your Google account, close the window when the Flow editor loads — gflow verifies automatically.

+

A one-time login opens a real Chrome window. Sign in with your Google account and keep going until the Flow editor loads — gflow closes the window for you and verifies the session automatically.

The --browser chrome flag is mandatory — Google rejects Playwright's bundled Chromium.

@@ -533,7 +533,7 @@

Prerequisites

A Chrome window opens at the Flow sign-in page.
Sign in with your Google account (2FA included).
Keep going until the Flow editor loads.
-
CLOSE THE BROWSER — gflow verifies automatically.
+
gflow CLOSES THE WINDOW for you and verifies automatically.
Session saved.
Profile dir: ~/.local/share/gflow-cli/profile_default
diff --git a/website/docs/onboarding.md b/website/docs/onboarding.md index e2ddd29d..2abc243e 100644 --- a/website/docs/onboarding.md +++ b/website/docs/onboarding.md @@ -48,7 +48,7 @@ The `--browser chrome` flag is mandatory — Google rejects Playwright's bundled gflow auth login --browser chrome ``` -A Chrome window opens at the Flow sign-in page. Sign in with your Google account (2FA included), keep going until the Flow editor loads, then **close the browser** — gflow verifies automatically. +A Chrome window opens at the Flow sign-in page. Sign in with your Google account (2FA included) and keep going until the Flow editor loads — **gflow closes the window for you** and verifies the session automatically. Closing it yourself also works. ``` Session saved. From aed82a93d119f08479576cb6253268f2dac1f624 Mon Sep 17 00:00:00 2001 From: Flavio Oliva Date: Tue, 8 Sep 2026 23:54:24 +0100 Subject: [PATCH 03/12] fix(auth): a network blip is not the user closing the window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Council review of 8172fb0c across five dimensions. Security (D3/D10) and over-engineering (D14) came back green; correctness found one real bug and docs found one blocker. **The bug.** `poll_session_until_authenticated` treated every `PlaywrightError` as "the browser is gone" and stopped polling. But `playwright.async_api.TimeoutError` subclasses `Error`, so a 15 s request timeout, a DNS hiccup or a Wi-Fi reassociation arrived on the same arm. On the owned-browser path that meant gflow closed Chrome out from under a user still on Google's password screen, logged `auth_login_browser_closed_by_user`, and reported exit 8 "No sign-in detected" for a sign-in that had not failed. It now asks the page whether it is actually closed; everything else retries until the deadline. HTTP-level failures never reached this arm at all — they come back as VERIFICATION_ERROR and keep polling — so only transport errors were ever conflated. `test_manual_close_is_not_an_error` had to change with it: it described a browser that raised on every request while insisting it was still open, which is a state Chrome cannot be in. The fake now closes the page, because that is what a closed browser does. **The doc blocker.** `docs/ARCHITECTURE.md` said the auth path bypasses detection with "a JS `add_init_script` that overrides `navigator.webdriver`". That code has never existed in `auth/` — only in the two generation paths. It sat fifteen lines above the paragraph 8172fb0c rewrote, which means that commit fixed one instance of "documentation describing an implementation nobody built" while leaving another untouched in the same file. Also corrected, all same class: - `AuthBrowserRejectedError`'s remediation told users to install Chrome and rerun with `--browser chrome`. The spike disproved exactly that: bundled Chromium signed in fine with the flags, real Chrome was rejected without them. Exit 14 is now reachable only from the internal strategy, so the advice was both wrong and aimed at the one path it cannot help. A test asserted the old string, so the wrong guidance could not be fixed without a test failing to defend it. - `README.md`, `AGENTS.md`, `docs/DEBUGGING.md`, `docs/AUTHENTICATION.md` and two `website/docs/` pages still stated the retired "Google rejects bundled Chromium" claim; two of those website pages contradicted, in an adjacent unedited sentence, what 8172fb0c had just written above them. - `client.py`'s Chrome-not-found hint still offered `CHROME_BINARY`, which no longer helps: Playwright honours a custom binary via `executable_path=`, never `channel=`. - The CHANGELOG attributed the 17.5 s rejection to bundled Chromium. That number is the `bare` arm — real Chrome with the flags removed. Bundled-without-flags was never run, and the entry now says so. **Tests.** `raise_on_close`'s default was unpinned: flipping it left the suite green while turning a failed internal login into a reported success with no `.gflow_account`. Both sides are now pinned, and the guard was A/B-verified by mutation, as was the transient-failure fix and the headless routing guard. 224 passed; ruff, mirror, doc-links, PII, hygiene and council-memory gates green; pyright 86 = 86 against an untouched-checkout control. --- AGENTS.md | 2 +- CHANGELOG.md | 11 ++-- README.md | 2 +- docs/ARCHITECTURE.md | 6 +-- docs/AUTHENTICATION.md | 2 +- docs/DEBUGGING.md | 2 +- docs/USER_GUIDE.md | 6 ++- src/gflow_cli/api/client.py | 2 +- src/gflow_cli/auth/internal_chromium.py | 19 +++++-- src/gflow_cli/errors.py | 7 +-- tests/auth/strategies/test_strategies.py | 63 +++++++++++++++++++++- tests/auth/test_real_chrome.py | 69 +++++++++++++++++++++++- website/docs/ARCHITECTURE.md | 6 +-- website/docs/AUTHENTICATION.md | 2 +- website/docs/DEBUGGING.md | 2 +- website/docs/USER_GUIDE.md | 6 ++- website/docs/onboarding-mockup.html | 4 +- website/docs/onboarding.md | 4 +- 18 files changed, 180 insertions(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a70fa806..a188aa2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,7 @@ by construction, so Codex / Cursor / Aider / `agy` read exactly what Claude Code ## Headed-browser dependency (architectural reality) -gflow-cli currently drives Flow via a **real Chrome session managed by Playwright** — `ui_automation` transport. Google's auth + reCAPTCHA stack rejects Playwright's bundled Chromium and most headless approaches. This is the project's defining trade-off: +gflow-cli currently drives Flow via a **real Chrome session managed by Playwright** — `ui_automation` transport. Google's auth + reCAPTCHA stack rejects browsers that advertise automation, and most headless approaches. This is the project's defining trade-off: - ✅ Works end-to-end against live Google accounts. - ❌ Requires a saved Chrome profile, a display server for one-time login, and ~150 MB for Chromium. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e69444d..ebcdbb88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,10 +59,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 driving only t2v and local-frame i2v, so an image refusal printed a `detail` saying t2i/i2i are driven directly above a remediation saying they are not. Both it and the class docstring now name the full ported matrix. -- **`gflow auth login --browser internal` launched the exact browser configuration Google - rejects.** The bundled-Chromium path shipped with no anti-automation flags, so - `navigator.webdriver` was `true` — measured 2026-09-08 as rejected at - `/v3/signin/rejected` 17.5 s into the flow. It now passes +- **`gflow auth login --browser internal` launched a browser configuration measured as + rejected.** The bundled-Chromium path shipped with no anti-automation flags, which leaves + `navigator.webdriver` set. On 2026-09-08 a browser in that state — real Chrome with the + flags removed — was rejected at `/v3/signin/rejected` 17.5 s into the flow, while the same + browser *with* the flags signed in normally. Bundled Chromium was measured only in the + flagged configuration, so its unflagged rejection is inferred from the shared signal, not + observed directly. It now passes `--disable-blink-features=AutomationControlled`, `ignore_default_args=["--enable-automation"]` and `chromium_sandbox=True` — the last of which also removes Chrome's cosmetic *"You are using an unsupported command-line flag"* banner — and signs in on the real OS window diff --git a/README.md b/README.md index e293e1d6..24e7b447 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ gflow character create --project --name "Aria" --face-prompt "..." --body-p Outputs land under `$GFLOW_CLI_OUTPUT_DIR`, or you can route them to S3, MinIO, or Google Cloud Storage with [`GFLOW_CLI_STORAGE_URI`](docs/EXTERNAL_STORAGE.md). The first call takes 30 to 90 seconds while Chromium warms up; later calls reuse the warm session. -> **Why `--browser chrome`?** Google rejects Playwright's bundled Chromium. The CLI fails fast with a friendly error (`AuthBrowserRejectedError`, exit code 14) if you pick anything else. +> **Why `--browser chrome`?** It is the only strategy that marks the profile as a real-Chrome profile, which is what later generation runs open it with. The default `auto` picks it whenever Chrome is installed — see [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md). > **Installing from a local checkout?** `uv tool install ` **ignores `uv.lock`** and resolves dependencies from the `pyproject.toml` ranges, so it can hand you a Playwright build this project has never tested. Playwright ships the browser driver, and an untested minor can wedge a generation silently. Carry the locked version explicitly: > diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 113358ec..853b513a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -79,7 +79,7 @@ src/gflow_cli/auth/ └── strategies.py # (internal) shared Playwright helpers ``` -**Why:** Google's bot-detection ("G12 block") rejects Playwright's bundled Chromium during `gflow auth login`. Launching the user's installed Google Chrome with `--disable-blink-features=AutomationControlled` plus a JS `add_init_script` that overrides `navigator.webdriver` bypasses detection. Two strategies are needed because the setup (persistent-context flags, Chrome binary path, stealth init) differs fundamentally between them. +**Why two strategies:** Google's "G12 block" keys on a browser that *advertises* automation (`navigator.webdriver`), not on which binary runs. Both strategies therefore build their launch options from the same `login_launch_kwargs()` helper — `--disable-blink-features=AutomationControlled` plus `ignore_default_args=["--enable-automation"]` — and differ only by `channel="chrome"`. They stay separate classes for what those options do not carry: `name` supplies the `source=` label on the session probe (a caller-supplied `"chrome"`/`"internal"` log value, never read back from a response), only the `chrome` strategy writes the `.gflow_browser_strategy` marker that `channel_for_profile()` reads later, and `factory.py` is a name→type registry that `--browser auto|chrome|internal` routes through. **AuthStrategy Protocol** (`base.py`): ```python @@ -96,7 +96,7 @@ class AuthStrategy(Protocol): **RealChromeStrategy stealth design** (`real_chrome.py`): - **Default — Playwright-owned Chrome.** Launches the system's real Google Chrome via Playwright's `channel="chrome"` with `chromium_sandbox=True`, `no_viewport=True`, `--disable-blink-features=AutomationControlled`, and `ignore_default_args=["--enable-automation"]`. What Google's G12 block keys on is a browser that *advertises* automation (`navigator.webdriver`), not the Playwright connection itself; with these flags the property is `false` and sign-in proceeds normally. See the G12 entry in [KNOWN_ISSUES.md](../KNOWN_ISSUES.md) for the 2026-09-08 measurement and its N=1 caveat. - Because gflow owns that context, it polls the Flow session endpoint from it until the outcome is `AUTHENTICATED` and then **closes the browser itself** — the user is not asked to close anything. A user who closes the window anyway is routed to the same `verify_flow_profile` check, never to an error. -- **Automatic fallback — Passive Capture.** When Playwright cannot resolve a Chrome channel (`browser_manager.is_playwright_chrome_channel_available()`), or Google rejects the browser anyway, the strategy silently falls back to the older shape: system Chrome via `subprocess.Popen` with no automation flags and no remote-debugging port, the CLI blocking on `proc.wait()` until the user closes the window. There is **no user-facing flag and no choice to make** — a Chromium-only host is never locked out of onboarding. +- **Automatic fallback — Passive Capture.** `login()` falls back on four conditions — a `headless=True` caller (the CLI exposes no such flag; this guards library callers), Playwright cannot resolve a Chrome channel (`browser_manager.is_playwright_chrome_channel_available()`), `launch_persistent_context` raises, or Google rejects the owned browser (`AuthBrowserRejectedError`, swallowed here rather than surfaced as exit 14) — and then silently falls back to the older shape: system Chrome via `subprocess.Popen` with no automation flags and no remote-debugging port, the CLI blocking on `proc.wait()` until the user closes the window. There is **no user-facing flag and no choice to make** — a Chromium-only host is never locked out of onboarding. - Verification: both paths end in the same `verify_flow_profile` call after the browser is gone. That probe is **httpx-first** — it reads the profile's cookie store directly via `browser_cookie3` and only falls back to a headless `launch_persistent_context` when cookie decryption fails (DPAPI on Windows, keychain on macOS, libsecret on Linux). The default path additionally polls the same session contract *from the browser it owns*, which is what tells it when to close. Both write the `.gflow_browser_strategy = "chrome"` marker that `channel_for_profile()` later reads. - Privacy guard: raises `SecurityError` if the resolved `profile_dir` is outside `GFLOW_CLI_HOME` — protects the user's primary system Chrome profile from being used as a session store. @@ -424,7 +424,7 @@ This is the project's defining trade-off and the most valuable place an external Google's auth + reCAPTCHA stack on `aisandbox-pa.googleapis.com` rejects: -1. **Playwright's bundled Chromium** — flagged by Google's bot detection on first request. The CLI fails fast with `AuthBrowserRejectedError` (exit code 14) when this happens. +1. **Browsers that advertise automation** — `navigator.webdriver = true` is what Google's bot detection flags, not the bundled binary itself; every launch path passes `--disable-blink-features=AutomationControlled` to keep it `false`. A sign-in that still lands on Google's rejection page raises `AuthBrowserRejectedError` (exit code 14) from the `internal` strategy; the `chrome` strategy retries on its no-automation subprocess path instead. 2. **Headless browsers** — same fingerprinting trips during the OAuth-consent flow. 3. **Bare HTTP clients** without the cookies + tokens minted by a real Chrome session — most endpoints return HTTP 401 or a reCAPTCHA challenge that can only be solved interactively. diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index 970e7ef0..8771cc69 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -179,7 +179,7 @@ and the profile keeps the name `default`. | Value | Browser used | When to use | |---|---|---| | `auto` (default) | Real Chrome if installed; falls back to internal | First choice for most users | -| `chrome` | System Google Chrome, driven by Playwright (auto-closes) | Required to bypass "G12" blocks | +| `chrome` | System Google Chrome, driven by Playwright (auto-closes) | Required for a chrome-strategy profile | | `internal` | Playwright's bundled Chromium | Fallback when Chrome isn't installed | Override with the env var: `GFLOW_CLI_AUTH_BROWSER=chrome gflow auth login` diff --git a/docs/DEBUGGING.md b/docs/DEBUGGING.md index 5bc50f6d..88d1fd2b 100644 --- a/docs/DEBUGGING.md +++ b/docs/DEBUGGING.md @@ -224,7 +224,7 @@ First visible-and-clickable wins. Log: |---|---|---|---| | `BrowserSessionClosedError` | 15 | Playwright page/context/browser was closed mid-call (translated from `TargetClosedError`) | Recreate `FlowApiClient` via `async with` | | `AuthExpiredError` | 3 | Session cookies no longer valid | `gflow auth login --profile ` | -| `AuthBrowserRejectedError` | 14 | Google rejected Playwright's bundled Chromium | Re-login with `--browser chrome` | +| `AuthBrowserRejectedError` | 14 | Google's sign-in rejected the browser for advertising automation; only the `internal` strategy surfaces it | Re-run `gflow auth login` (default `auto` picks the `chrome` strategy, which retries on a no-automation path) | | `AuthLoginTimeoutError` | 12 | User did not finish the OAuth flow in time | Run `gflow auth login` again; raise `GFLOW_CLI_AUTH_LOGIN_TIMEOUT` | | `TransportTimeoutError` | 9 | A single API call exceeded its timeout | Retry; check Flow status | | `WafRejectionError` | 10 | reCAPTCHA / WAF blocked the request | Wait + retry; verify session is healthy | diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 32b9a333..8f816f0a 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -755,8 +755,10 @@ gflow auth login --profile --browser chrome way. On a machine where Playwright can't resolve a Chrome channel, login falls back automatically to the older flow, where you close the window; nothing to configure either way.) -4. `gflow auth login` probes the profile with `channel="chrome"`, verifies SAPISID is - present, and writes `.gflow_browser_strategy = "chrome"` to the profile directory. +4. `gflow auth login` verifies the saved session — httpx-first, reading the profile's cookie + store directly with `browser_cookie3` and only falling back to a Playwright launch if + that decryption fails — checks SAPISID is present, and keeps + `.gflow_browser_strategy = "chrome"` in the profile directory. 5. Subsequent `gflow image` / `gflow video` calls will use Chrome to open the profile and can decrypt the cookies. diff --git a/src/gflow_cli/api/client.py b/src/gflow_cli/api/client.py index 09a25865..c70c1329 100644 --- a/src/gflow_cli/api/client.py +++ b/src/gflow_cli/api/client.py @@ -590,7 +590,7 @@ def _log_and_guard_launch(self, kwargs: dict[str, Any]) -> None: "Playwright's bundled Chromium. On macOS the bundled Chromium cannot " "decrypt cookies written by real Chrome (Keychain 'Chrome Safe Storage'), " "yielding a logged-out session and an HTTP 401 at project.createProject. " - "Install Google Chrome in its default location (or set CHROME_BINARY), " + "Install Google Chrome in its default location, " "then retry; or re-run `gflow auth login` to re-capture the session." ) if sys.platform == "darwin": diff --git a/src/gflow_cli/auth/internal_chromium.py b/src/gflow_cli/auth/internal_chromium.py index 369537be..d7758ff5 100644 --- a/src/gflow_cli/auth/internal_chromium.py +++ b/src/gflow_cli/auth/internal_chromium.py @@ -121,9 +121,22 @@ async def poll_session_until_authenticated( raise except AuthBrowserRejectedError: raise - except PlaywrightError: - # Browser / page / context closed — stop polling. - break + except PlaywrightError as exc: + # NOT every PlaywrightError means the window is gone. `TimeoutError` + # subclasses `Error`, so a 15 s request timeout, a DNS hiccup or a Wi-Fi + # reassociation lands here too — and breaking on those made gflow close + # Chrome out from under a user still on Google's password screen, then + # report exit 8 "No sign-in detected" for a sign-in that had not failed. + # Ask the page whether it is actually closed; anything else is transient + # and retries until the deadline. (HTTP-level failures never reached this + # arm at all: they come back as VERIFICATION_ERROR and keep polling.) + if page.is_closed(): + break + logger.warning( + "auth_flow_session_poll_error", + strategy=strategy_name, + error=type(exc).__name__, + ) except Exception as exc: logger.warning( "auth_flow_session_poll_error", diff --git a/src/gflow_cli/errors.py b/src/gflow_cli/errors.py index d2deb7a2..0989a5ae 100644 --- a/src/gflow_cli/errors.py +++ b/src/gflow_cli/errors.py @@ -966,9 +966,10 @@ class AuthBrowserRejectedError(GFlowError): problem_type = "https://gflow-cli.dev/errors/auth-browser-rejected" title = "Login browser rejected" _default_remediation = ( - "Google rejected Playwright's bundled Chromium as an insecure browser. " - "Install Google Chrome and rerun `gflow auth login --browser chrome`, " - "or set GFLOW_CLI_AUTH_BROWSER=chrome so future logins use real Chrome." + "Google's sign-in rejected this browser for advertising automation " + "(navigator.webdriver), not for being Chromium. Re-run `gflow auth login`; " + "with Google Chrome installed, the `chrome` strategy retries automatically " + "on a path with no automation surface." ) diff --git a/tests/auth/strategies/test_strategies.py b/tests/auth/strategies/test_strategies.py index 6576826e..551a3e5b 100644 --- a/tests/auth/strategies/test_strategies.py +++ b/tests/auth/strategies/test_strategies.py @@ -601,6 +601,65 @@ async def test_internal_chromium_rejected_browser_raises_guidance( with pytest.raises(AuthBrowserRejectedError) as excinfo: await strategy.login(profile_dir, headless=False) - assert "--browser chrome" in excinfo.value.remediation_hint - assert "GFLOW_CLI_AUTH_BROWSER=chrome" in excinfo.value.remediation_hint + # This used to assert the hint said "--browser chrome" / "GFLOW_CLI_AUTH_BROWSER=chrome", + # i.e. "you picked the wrong binary, pick Chrome". The 2026-09-08 spike disproved + # that: bundled Chromium signed in fine WITH the anti-automation flags, and real + # Chrome was rejected WITHOUT them. Pinning the old advice would have kept a + # now-wrong remediation on the one exit code whose whole job is to explain this. + # Assert the cause, which is what stays true. + hint = excinfo.value.remediation_hint + assert hint is not None + assert "navigator.webdriver" in hint + assert "gflow auth login" in hint mock_ctx.close.assert_called_once() + + +class TestRaiseOnCloseDefault: + """`raise_on_close` defaults to True, and that default is load-bearing. + + The keyword was added so the chrome strategy could treat a hand-closed window as + "fall through to the on-disk probe" rather than an error. `InternalChromiumStrategy` + keeps the opposite contract: it has no second probe to fall through to, so a browser + closed before the Flow sign-in completes must raise. Nothing pinned that default — + flipping it to False left the whole auth suite green while silently turning a failed + login into a reported success with no `.gflow_account` written. + """ + + @pytest.mark.asyncio + async def test_closed_before_auth_raises_by_default(self) -> None: + from playwright.async_api import Error as PlaywrightError + + from gflow_cli.auth.internal_chromium import poll_session_until_authenticated + + page = MagicMock(name="page") + page.url = "https://labs.google/fx/tools/flow" + page.is_closed = MagicMock(return_value=True) + page.request.get = AsyncMock(side_effect=PlaywrightError("Target closed")) + + ctx = MagicMock(name="ctx") + ctx.cookies = AsyncMock(return_value=[]) + + with pytest.raises(AuthLoginTimeoutError) as excinfo: + # No `raise_on_close=` — the default is the thing under test. + await poll_session_until_authenticated(ctx, page, 600, "internal") + + assert "closed" in str(excinfo.value).lower() + + @pytest.mark.asyncio + async def test_closed_before_auth_returns_none_when_opted_out(self) -> None: + from playwright.async_api import Error as PlaywrightError + + from gflow_cli.auth.internal_chromium import poll_session_until_authenticated + + page = MagicMock(name="page") + page.url = "https://labs.google/fx/tools/flow" + page.is_closed = MagicMock(return_value=True) + page.request.get = AsyncMock(side_effect=PlaywrightError("Target closed")) + + ctx = MagicMock(name="ctx") + ctx.cookies = AsyncMock(return_value=[]) + + assert ( + await poll_session_until_authenticated(ctx, page, 600, "chrome", raise_on_close=False) + is None + ) diff --git a/tests/auth/test_real_chrome.py b/tests/auth/test_real_chrome.py index 16605e19..17a2415e 100644 --- a/tests/auth/test_real_chrome.py +++ b/tests/auth/test_real_chrome.py @@ -52,7 +52,8 @@ def _build_fake_playwright( page_url: str = "https://labs.google/fx/tools/flow", webdriver: bool = False, launch_error: Exception | None = None, - poll_error: Exception | None = None, + poll_error: Any = None, + page_closed: bool = False, order: list[str] | None = None, on_poll: Any = None, ) -> tuple[MagicMock, MagicMock, MagicMock]: @@ -65,6 +66,10 @@ def _build_fake_playwright( page.url = page_url page.goto = AsyncMock() page.evaluate = AsyncMock(return_value=webdriver) + # Explicit, because a bare MagicMock attribute is TRUTHY: left to autospec, + # `page.is_closed()` would report "closed" on every poll and the guard under + # test would pass for the wrong reason. + page.is_closed = MagicMock(return_value=page_closed) page.request.get = AsyncMock(return_value=resp, side_effect=poll_error) async def _cookies() -> list[dict[str, str]]: @@ -337,6 +342,58 @@ async def test_no_chrome_channel_falls_back_to_subprocess(self, tmp_path: Path) assert len(fallbacks) == 1 assert fallbacks[0]["reason"] == "channel_unavailable" + @pytest.mark.asyncio + async def test_transient_request_failure_does_not_close_the_window( + self, tmp_path: Path + ) -> None: + """A network blip mid-sign-in must not be read as "the user closed it". + + `playwright.async_api.TimeoutError` subclasses `Error`, so a 15 s request + timeout, a DNS hiccup or a Wi-Fi reassociation arrives on the same except + arm as a genuinely closed target. Treating them alike closed Chrome out + from under a user still on Google's password screen and reported exit 8, + "No sign-in detected", on a sign-in that had not failed. + """ + from playwright.async_api import TimeoutError as PlaywrightTimeoutError + + gflow_home, profile_dir = self._home(tmp_path) + resp = MagicMock(name="resp") + resp.status = 200 + resp.text = AsyncMock(return_value=AUTHENTICATED_BODY) + # Blip on the first poll, real answer on the second. The page stays open + # throughout — nobody closed anything. + ap, _pw, ctx = _build_fake_playwright( + poll_error=[PlaywrightTimeoutError("Request timed out after 15000ms"), resp], + page_closed=False, + ) + + with ( + patch("gflow_cli.auth.real_chrome.get_settings") as mock_settings, + patch( + "gflow_cli.auth.real_chrome.is_playwright_chrome_channel_available", + return_value=True, + ), + patch("gflow_cli.auth.strategies.async_playwright", ap), + patch("gflow_cli.auth.real_chrome.asyncio.sleep", AsyncMock()), + patch( + "gflow_cli.auth.internal_chromium.asyncio.sleep", + AsyncMock(), + ), + patch( + "gflow_cli.auth.real_chrome.verify_flow_profile", + AsyncMock(return_value=_authenticated_status()), + ), + capture_logs() as logs, + ): + mock_settings.return_value.home = gflow_home + await RealChromeStrategy().login(profile_dir, headless=False) + + # The poll retried instead of giving up, so the session was detected... + assert any(e.get("event") == "auth_login_session_detected" for e in logs) + # ...and the run was never mislabelled as a user-initiated close. + assert not any(e.get("event") == "auth_login_browser_closed_by_user" for e in logs) + ctx.close.assert_awaited() + @pytest.mark.asyncio async def test_headless_never_reaches_the_owned_browser(self, tmp_path: Path) -> None: """headless=True takes the subprocess path even when the channel resolves. @@ -470,7 +527,15 @@ async def test_manual_close_is_not_an_error(self, tmp_path: Path) -> None: login that actually succeeded must NOT produce a red error — it falls through to verify_flow_profile, which is the authority either way.""" gflow_home, profile_dir = self._home(tmp_path) - ap, _pw, _ctx = _build_fake_playwright(poll_error=PlaywrightError("Target closed")) + # `page_closed=True` is the point, not scaffolding: a closed browser really + # does leave a closed page behind, and that is now the only thing that ends + # the poll. Injecting the error alone described a browser that raised on + # every request while insisting it was still open — a state Chrome cannot + # actually be in, and one that would now spin to the deadline. + ap, _pw, _ctx = _build_fake_playwright( + poll_error=PlaywrightError("Target closed"), + page_closed=True, + ) with ( patch("gflow_cli.auth.real_chrome.get_settings") as mock_settings, diff --git a/website/docs/ARCHITECTURE.md b/website/docs/ARCHITECTURE.md index 113358ec..853b513a 100644 --- a/website/docs/ARCHITECTURE.md +++ b/website/docs/ARCHITECTURE.md @@ -79,7 +79,7 @@ src/gflow_cli/auth/ └── strategies.py # (internal) shared Playwright helpers ``` -**Why:** Google's bot-detection ("G12 block") rejects Playwright's bundled Chromium during `gflow auth login`. Launching the user's installed Google Chrome with `--disable-blink-features=AutomationControlled` plus a JS `add_init_script` that overrides `navigator.webdriver` bypasses detection. Two strategies are needed because the setup (persistent-context flags, Chrome binary path, stealth init) differs fundamentally between them. +**Why two strategies:** Google's "G12 block" keys on a browser that *advertises* automation (`navigator.webdriver`), not on which binary runs. Both strategies therefore build their launch options from the same `login_launch_kwargs()` helper — `--disable-blink-features=AutomationControlled` plus `ignore_default_args=["--enable-automation"]` — and differ only by `channel="chrome"`. They stay separate classes for what those options do not carry: `name` supplies the `source=` label on the session probe (a caller-supplied `"chrome"`/`"internal"` log value, never read back from a response), only the `chrome` strategy writes the `.gflow_browser_strategy` marker that `channel_for_profile()` reads later, and `factory.py` is a name→type registry that `--browser auto|chrome|internal` routes through. **AuthStrategy Protocol** (`base.py`): ```python @@ -96,7 +96,7 @@ class AuthStrategy(Protocol): **RealChromeStrategy stealth design** (`real_chrome.py`): - **Default — Playwright-owned Chrome.** Launches the system's real Google Chrome via Playwright's `channel="chrome"` with `chromium_sandbox=True`, `no_viewport=True`, `--disable-blink-features=AutomationControlled`, and `ignore_default_args=["--enable-automation"]`. What Google's G12 block keys on is a browser that *advertises* automation (`navigator.webdriver`), not the Playwright connection itself; with these flags the property is `false` and sign-in proceeds normally. See the G12 entry in [KNOWN_ISSUES.md](../KNOWN_ISSUES.md) for the 2026-09-08 measurement and its N=1 caveat. - Because gflow owns that context, it polls the Flow session endpoint from it until the outcome is `AUTHENTICATED` and then **closes the browser itself** — the user is not asked to close anything. A user who closes the window anyway is routed to the same `verify_flow_profile` check, never to an error. -- **Automatic fallback — Passive Capture.** When Playwright cannot resolve a Chrome channel (`browser_manager.is_playwright_chrome_channel_available()`), or Google rejects the browser anyway, the strategy silently falls back to the older shape: system Chrome via `subprocess.Popen` with no automation flags and no remote-debugging port, the CLI blocking on `proc.wait()` until the user closes the window. There is **no user-facing flag and no choice to make** — a Chromium-only host is never locked out of onboarding. +- **Automatic fallback — Passive Capture.** `login()` falls back on four conditions — a `headless=True` caller (the CLI exposes no such flag; this guards library callers), Playwright cannot resolve a Chrome channel (`browser_manager.is_playwright_chrome_channel_available()`), `launch_persistent_context` raises, or Google rejects the owned browser (`AuthBrowserRejectedError`, swallowed here rather than surfaced as exit 14) — and then silently falls back to the older shape: system Chrome via `subprocess.Popen` with no automation flags and no remote-debugging port, the CLI blocking on `proc.wait()` until the user closes the window. There is **no user-facing flag and no choice to make** — a Chromium-only host is never locked out of onboarding. - Verification: both paths end in the same `verify_flow_profile` call after the browser is gone. That probe is **httpx-first** — it reads the profile's cookie store directly via `browser_cookie3` and only falls back to a headless `launch_persistent_context` when cookie decryption fails (DPAPI on Windows, keychain on macOS, libsecret on Linux). The default path additionally polls the same session contract *from the browser it owns*, which is what tells it when to close. Both write the `.gflow_browser_strategy = "chrome"` marker that `channel_for_profile()` later reads. - Privacy guard: raises `SecurityError` if the resolved `profile_dir` is outside `GFLOW_CLI_HOME` — protects the user's primary system Chrome profile from being used as a session store. @@ -424,7 +424,7 @@ This is the project's defining trade-off and the most valuable place an external Google's auth + reCAPTCHA stack on `aisandbox-pa.googleapis.com` rejects: -1. **Playwright's bundled Chromium** — flagged by Google's bot detection on first request. The CLI fails fast with `AuthBrowserRejectedError` (exit code 14) when this happens. +1. **Browsers that advertise automation** — `navigator.webdriver = true` is what Google's bot detection flags, not the bundled binary itself; every launch path passes `--disable-blink-features=AutomationControlled` to keep it `false`. A sign-in that still lands on Google's rejection page raises `AuthBrowserRejectedError` (exit code 14) from the `internal` strategy; the `chrome` strategy retries on its no-automation subprocess path instead. 2. **Headless browsers** — same fingerprinting trips during the OAuth-consent flow. 3. **Bare HTTP clients** without the cookies + tokens minted by a real Chrome session — most endpoints return HTTP 401 or a reCAPTCHA challenge that can only be solved interactively. diff --git a/website/docs/AUTHENTICATION.md b/website/docs/AUTHENTICATION.md index 1178ff2d..95e4647a 100644 --- a/website/docs/AUTHENTICATION.md +++ b/website/docs/AUTHENTICATION.md @@ -179,7 +179,7 @@ and the profile keeps the name `default`. | Value | Browser used | When to use | |---|---|---| | `auto` (default) | Real Chrome if installed; falls back to internal | First choice for most users | -| `chrome` | System Google Chrome, driven by Playwright (auto-closes) | Required to bypass "G12" blocks | +| `chrome` | System Google Chrome, driven by Playwright (auto-closes) | Required for a chrome-strategy profile | | `internal` | Playwright's bundled Chromium | Fallback when Chrome isn't installed | Override with the env var: `GFLOW_CLI_AUTH_BROWSER=chrome gflow auth login` diff --git a/website/docs/DEBUGGING.md b/website/docs/DEBUGGING.md index 5bc50f6d..88d1fd2b 100644 --- a/website/docs/DEBUGGING.md +++ b/website/docs/DEBUGGING.md @@ -224,7 +224,7 @@ First visible-and-clickable wins. Log: |---|---|---|---| | `BrowserSessionClosedError` | 15 | Playwright page/context/browser was closed mid-call (translated from `TargetClosedError`) | Recreate `FlowApiClient` via `async with` | | `AuthExpiredError` | 3 | Session cookies no longer valid | `gflow auth login --profile ` | -| `AuthBrowserRejectedError` | 14 | Google rejected Playwright's bundled Chromium | Re-login with `--browser chrome` | +| `AuthBrowserRejectedError` | 14 | Google's sign-in rejected the browser for advertising automation; only the `internal` strategy surfaces it | Re-run `gflow auth login` (default `auto` picks the `chrome` strategy, which retries on a no-automation path) | | `AuthLoginTimeoutError` | 12 | User did not finish the OAuth flow in time | Run `gflow auth login` again; raise `GFLOW_CLI_AUTH_LOGIN_TIMEOUT` | | `TransportTimeoutError` | 9 | A single API call exceeded its timeout | Retry; check Flow status | | `WafRejectionError` | 10 | reCAPTCHA / WAF blocked the request | Wait + retry; verify session is healthy | diff --git a/website/docs/USER_GUIDE.md b/website/docs/USER_GUIDE.md index 32b9a333..8f816f0a 100644 --- a/website/docs/USER_GUIDE.md +++ b/website/docs/USER_GUIDE.md @@ -755,8 +755,10 @@ gflow auth login --profile --browser chrome way. On a machine where Playwright can't resolve a Chrome channel, login falls back automatically to the older flow, where you close the window; nothing to configure either way.) -4. `gflow auth login` probes the profile with `channel="chrome"`, verifies SAPISID is - present, and writes `.gflow_browser_strategy = "chrome"` to the profile directory. +4. `gflow auth login` verifies the saved session — httpx-first, reading the profile's cookie + store directly with `browser_cookie3` and only falling back to a Playwright launch if + that decryption fails — checks SAPISID is present, and keeps + `.gflow_browser_strategy = "chrome"` in the profile directory. 5. Subsequent `gflow image` / `gflow video` calls will use Chrome to open the profile and can decrypt the cookies. diff --git a/website/docs/onboarding-mockup.html b/website/docs/onboarding-mockup.html index 51d06894..047ab026 100644 --- a/website/docs/onboarding-mockup.html +++ b/website/docs/onboarding-mockup.html @@ -515,7 +515,7 @@

Prerequisites

Authenticate

A one-time login opens a real Chrome window. Sign in with your Google account and keep going until the Flow editor loads — gflow closes the window for you and verifies the session automatically.

-

The --browser chrome flag is mandatory — Google rejects Playwright's bundled Chromium.

+

Use --browser chrome — the default auto picks the same real Chrome when it is installed, and only this strategy marks the profile as a real-Chrome profile.

@@ -625,7 +625,7 @@

Troubleshooting

AuthBrowserRejectedError (exit 14)
-
Google rejected Playwright's bundled Chromium as an insecure browser.
+
Google's sign-in rejected the login browser for advertising automation (navigator.webdriver).
Fix: Install Google Chrome from chrome.com, then restart your terminal and run:
gflow auth login --browser chrome diff --git a/website/docs/onboarding.md b/website/docs/onboarding.md index 2abc243e..f0b1f97c 100644 --- a/website/docs/onboarding.md +++ b/website/docs/onboarding.md @@ -42,7 +42,7 @@ gflow, version 0.41.0 ## Step 2 — Authenticate -The `--browser chrome` flag is mandatory — Google rejects Playwright's bundled Chromium. +Use `--browser chrome` — the default `auto` picks the same real Chrome when it is installed, and only this strategy marks the profile as a real-Chrome profile. ```bash gflow auth login --browser chrome @@ -119,7 +119,7 @@ If something goes wrong on this path, here are the most common errors and exactl ### AuthBrowserRejectedError (exit 14) -Google rejected Playwright's bundled Chromium as an insecure browser. +Google's sign-in rejected the login browser for advertising automation (`navigator.webdriver`). **Fix:** Install Google Chrome from [chrome.com](https://chrome.com), then restart your terminal and run: From a1aa143b8321d5e32c83a03c7049af28e8bce5ed Mon Sep 17 00:00:00 2001 From: Flavio Oliva Date: Wed, 9 Sep 2026 07:59:53 +0100 Subject: [PATCH 04/12] fix(auth): stop the session poll from breaking its own OAuth callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/fx/api/auth/session` is a NextAuth route that can rotate session cookies, and the poll was hitting it every 3 s for the whole login — including while Google held the page for the OAuth callback. Observed live 2026-09-08: the sign-in landed on `labs.google/fx/api/auth/signin?error=OAuthCallback` and then timed out at 600 s. The spike that signed in successfully twice never made this request at all: it read the cookie jar locally over CDP and issued no HTTP during sign-in. Waiting until the page is back on the Flow host restores that property. Also adds a `probe=` discriminator to `auth_login_session_detected`. RealChromeStrategy runs both oracles — the in-context live probe decides when to close, then verify_flow_profile re-checks what landed on disk — and the live run emitted two identical events, leaving "did the on-disk check pass?" unanswerable from the log. --- src/gflow_cli/auth/internal_chromium.py | 39 ++++++++++++++++ src/gflow_cli/auth/real_chrome.py | 1 + tests/auth/strategies/test_strategies.py | 58 ++++++++++++++++++++++++ 3 files changed, 98 insertions(+) diff --git a/src/gflow_cli/auth/internal_chromium.py b/src/gflow_cli/auth/internal_chromium.py index d7758ff5..334870f8 100644 --- a/src/gflow_cli/auth/internal_chromium.py +++ b/src/gflow_cli/auth/internal_chromium.py @@ -2,6 +2,7 @@ import asyncio from typing import TYPE_CHECKING, Any +from urllib.parse import urlparse import structlog from playwright.async_api import Error as PlaywrightError @@ -22,6 +23,11 @@ GEMINI_URL = "https://labs.google/fx/tools/flow?hl=en" GOOGLE_REJECTED_BROWSER_ROUTE = "accounts.google.com/v3/signin/rejected" +POLL_INTERVAL_SECONDS = 3 +# Host the Flow app itself is served from. The session poll only runs while the page +# is here — see the comment in poll_session_until_authenticated. Structural (a host, +# not display text), so it stays locale-invariant. +FLOW_APP_HOST = "labs.google" def login_launch_kwargs( @@ -98,6 +104,20 @@ async def poll_session_until_authenticated( if _is_google_rejected_browser_page(page): raise AuthBrowserRejectedError + # Do not touch the session endpoint while the browser is away on the + # OAuth handshake. `/fx/api/auth/session` is a NextAuth route that can + # rotate session cookies, and a poll landing mid-callback can clobber the + # `state`/PKCE cookies the callback needs — observed live 2026-09-08 as + # `labs.google/fx/api/auth/signin?error=OAuthCallback`, a sign-in that + # failed and then timed out at 600 s. The 2026-09-08 spike, which signed + # in successfully twice, never made this request: it read the jar locally + # over CDP and issued no HTTP at all during sign-in. Waiting until the + # page is back on the Flow host restores that property, and it is what + # notebooklm-py does (watch the URL first, read the session after). + if not _is_on_flow_host(page): + await asyncio.sleep(POLL_INTERVAL_SECONDS) + continue + cookies = await ctx.cookies() google_session = any(c.get("name") == "SAPISID" for c in cookies) resp = await page.request.get(SESSION_API_URL, timeout=15_000) @@ -113,6 +133,12 @@ async def poll_session_until_authenticated( strategy=strategy_name, source=status.source, user_email=status.user_email, + # Which oracle spoke. RealChromeStrategy runs BOTH — this live probe + # decides when to close, then verify_flow_profile re-checks what + # actually landed on disk — and a live run on 2026-09-08 emitted two + # identical events, leaving "did the on-disk check pass?" + # unanswerable from the log. + probe="in_context", ) success = True _email = status.user_email @@ -179,6 +205,19 @@ def _is_google_rejected_browser_page(page: object) -> bool: return isinstance(url, str) and GOOGLE_REJECTED_BROWSER_ROUTE in url +def _is_on_flow_host(page: object) -> bool: + """Return True when the page is on the Flow app host, not mid-OAuth on Google's. + + ``isinstance(url, str)`` is load-bearing, not defensive: a bare mock attribute is + truthy, so without it a test double would report "on the Flow host" and the guard + would pass for the wrong reason. + """ + url = getattr(page, "url", "") + if not isinstance(url, str): + return False + return (urlparse(url).hostname or "").lower() == FLOW_APP_HOST + + class InternalChromiumStrategy(AuthStrategy): """Legacy login strategy using bundled Playwright Chromium. diff --git a/src/gflow_cli/auth/real_chrome.py b/src/gflow_cli/auth/real_chrome.py index 15ea4b01..4e157946 100644 --- a/src/gflow_cli/auth/real_chrome.py +++ b/src/gflow_cli/auth/real_chrome.py @@ -451,6 +451,7 @@ async def _verify_and_record(self, profile_dir: Path) -> None: strategy=self.name, source=status.source, user_email=status.user_email, + probe="on_disk", ) # Marker read by browser_manager.channel_for_profile so FlowApiClient # selects the system Chrome channel. Load-bearing — must persist here. diff --git a/tests/auth/strategies/test_strategies.py b/tests/auth/strategies/test_strategies.py index 551a3e5b..36adea1a 100644 --- a/tests/auth/strategies/test_strategies.py +++ b/tests/auth/strategies/test_strategies.py @@ -663,3 +663,61 @@ async def test_closed_before_auth_returns_none_when_opted_out(self) -> None: await poll_session_until_authenticated(ctx, page, 600, "chrome", raise_on_close=False) is None ) + + +class TestSessionPollStaysOffTheOAuthHandshake: + """The session poll must not touch `/fx/api/auth/session` mid-OAuth. + + Observed live 2026-09-08: a sign-in driven through the owned browser landed on + `labs.google/fx/api/auth/signin?error=OAuthCallback` and then timed out at 600 s. + `/fx/api/auth/session` is a NextAuth route that can rotate session cookies, and the + poll was hitting it every 3 s for the whole login — including while Google held the + page for the callback. The spike that signed in successfully twice never made this + request at all: it read the cookie jar locally over CDP. This pins that property. + """ + + @pytest.mark.asyncio + async def test_no_session_request_while_on_google(self) -> None: + from gflow_cli.auth.internal_chromium import poll_session_until_authenticated + + page = MagicMock(name="page") + # Mid-handshake on Google's host, not Flow's. + page.url = "https://accounts.google.com/v3/signin/challenge/pwd?flow=1" + page.is_closed = MagicMock(return_value=False) + page.request.get = AsyncMock() + + ctx = MagicMock(name="ctx") + ctx.cookies = AsyncMock(return_value=[]) + + with patch("gflow_cli.auth.internal_chromium.asyncio.sleep", AsyncMock()): + # timeout_seconds=0 would skip the loop entirely; give it a real budget and + # let the patched sleep spin it, then assert on what it did NOT do. + with pytest.raises(AuthLoginTimeoutError): + await poll_session_until_authenticated(ctx, page, 1, "chrome") + + page.request.get.assert_not_awaited() + ctx.cookies.assert_not_awaited() + + @pytest.mark.asyncio + async def test_session_request_resumes_once_back_on_flow(self) -> None: + from gflow_cli.auth.internal_chromium import poll_session_until_authenticated + + resp = MagicMock(name="resp") + resp.status = 200 + resp.text = AsyncMock( + return_value='{"user":{"email":"test@example.com"},"expires":"2099-01-01"}' + ) + + page = MagicMock(name="page") + page.url = "https://labs.google/fx/tools/flow" + page.is_closed = MagicMock(return_value=False) + page.request.get = AsyncMock(return_value=resp) + + ctx = MagicMock(name="ctx") + ctx.cookies = AsyncMock(return_value=[{"name": "SAPISID", "value": "x"}]) + + with patch("gflow_cli.auth.internal_chromium.asyncio.sleep", AsyncMock()): + email = await poll_session_until_authenticated(ctx, page, 30, "chrome") + + assert email == "test@example.com" + page.request.get.assert_awaited() From 47bbb2808fd194dcaedc01eacdeca0e42367b46a Mon Sep 17 00:00:00 2001 From: Flavio Oliva Date: Wed, 9 Sep 2026 08:02:51 +0100 Subject: [PATCH 05/12] fix(auth): let the session poll run on the migrated host too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The poll guard added in the previous commit gated on `labs.google` alone. The labs app `location.replace`s a migrated account onto flow.google.com right after the callback returns — server-decided per account, one-way — so the guard would go False on the redirect and never come back, reproducing the exact 600 s timeout it was written to fix, on every migrated account. Both maintainer accounts are migrated, so every live-verify run would have hit this. Neither host is an OAuth handshake host, which is all the guard needs to exclude, so admitting both preserves its intent. A/B control: with the fix the class is 3 passed; gated on labs alone, test_migrated_host_still_polls fails with AuthLoginTimeoutError. --- src/gflow_cli/auth/internal_chromium.py | 19 ++++++++++----- tests/auth/strategies/test_strategies.py | 31 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/gflow_cli/auth/internal_chromium.py b/src/gflow_cli/auth/internal_chromium.py index 334870f8..bb994986 100644 --- a/src/gflow_cli/auth/internal_chromium.py +++ b/src/gflow_cli/auth/internal_chromium.py @@ -24,10 +24,17 @@ GEMINI_URL = "https://labs.google/fx/tools/flow?hl=en" GOOGLE_REJECTED_BROWSER_ROUTE = "accounts.google.com/v3/signin/rejected" POLL_INTERVAL_SECONDS = 3 -# Host the Flow app itself is served from. The session poll only runs while the page -# is here — see the comment in poll_session_until_authenticated. Structural (a host, -# not display text), so it stays locale-invariant. -FLOW_APP_HOST = "labs.google" +# Hosts the Flow app itself is served from. The session poll only runs while the page +# is on one of these — see the comment in poll_session_until_authenticated. Structural +# (a host, not display text), so it stays locale-invariant. +# +# BOTH hosts, not just labs: the labs app `location.replace`s a migrated account onto +# flow.google.com right after the callback returns, one-way and server-decided per +# account. Gating on labs alone would send every migrated account back into the exact +# 600 s timeout this guard was written to fix — the poll would go False on the redirect +# and never come back. Neither host is an OAuth handshake host, which is all the guard +# actually needs to exclude. +FLOW_APP_HOSTS = frozenset({"labs.google", "flow.google.com"}) def login_launch_kwargs( @@ -206,7 +213,7 @@ def _is_google_rejected_browser_page(page: object) -> bool: def _is_on_flow_host(page: object) -> bool: - """Return True when the page is on the Flow app host, not mid-OAuth on Google's. + """Return True when the page is on a Flow app host, not mid-OAuth on Google's. ``isinstance(url, str)`` is load-bearing, not defensive: a bare mock attribute is truthy, so without it a test double would report "on the Flow host" and the guard @@ -215,7 +222,7 @@ def _is_on_flow_host(page: object) -> bool: url = getattr(page, "url", "") if not isinstance(url, str): return False - return (urlparse(url).hostname or "").lower() == FLOW_APP_HOST + return (urlparse(url).hostname or "").lower() in FLOW_APP_HOSTS class InternalChromiumStrategy(AuthStrategy): diff --git a/tests/auth/strategies/test_strategies.py b/tests/auth/strategies/test_strategies.py index 36adea1a..e26c34c2 100644 --- a/tests/auth/strategies/test_strategies.py +++ b/tests/auth/strategies/test_strategies.py @@ -721,3 +721,34 @@ async def test_session_request_resumes_once_back_on_flow(self) -> None: assert email == "test@example.com" page.request.get.assert_awaited() + + @pytest.mark.asyncio + async def test_migrated_host_still_polls(self) -> None: + """A migrated account lands on flow.google.com and must still be detected. + + The labs app `location.replace`s a migrated account onto flow.google.com right + after the callback returns. Gating the poll on labs alone would go False there + and never come back, reproducing the 600 s timeout this guard exists to fix — + on every account the maintainer actually owns. + """ + from gflow_cli.auth.internal_chromium import poll_session_until_authenticated + + resp = MagicMock(name="resp") + resp.status = 200 + resp.text = AsyncMock( + return_value='{"user":{"email":"test@example.com"},"expires":"2099-01-01"}' + ) + + page = MagicMock(name="page") + page.url = "https://flow.google.com/project/abc123" + page.is_closed = MagicMock(return_value=False) + page.request.get = AsyncMock(return_value=resp) + + ctx = MagicMock(name="ctx") + ctx.cookies = AsyncMock(return_value=[{"name": "SAPISID", "value": "x"}]) + + with patch("gflow_cli.auth.internal_chromium.asyncio.sleep", AsyncMock()): + email = await poll_session_until_authenticated(ctx, page, 30, "chrome") + + assert email == "test@example.com" + page.request.get.assert_awaited() From aefff79e0a26109e4df3cfda349c8032669e2dcc Mon Sep 17 00:00:00 2001 From: Flavio Oliva Date: Wed, 9 Sep 2026 09:09:29 +0100 Subject: [PATCH 06/12] test(auth): let the no-URL-logging test reach the Flow host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test_never_logs_a_google_url` parks the page on a secret-bearing Google URL and asserts the poll logs no page URL. With the new host guard the page never left accounts.google.com, so the poll skipped every iteration and the test hung until the 600 s timeout — it was the last test in the file and took the whole suite with it. The page now arrives on the Flow host while the poll is waiting, which is what a real sign-in does. The assertion is unchanged and its intent is stronger: the secret-bearing URL was genuinely visited, and still reaches no log event. 18 passed in 0.32 s, down from a 600 s hang. --- tests/auth/test_real_chrome.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/auth/test_real_chrome.py b/tests/auth/test_real_chrome.py index 17a2415e..1aa2e009 100644 --- a/tests/auth/test_real_chrome.py +++ b/tests/auth/test_real_chrome.py @@ -677,12 +677,21 @@ async def test_never_logs_a_google_url(self, tmp_path: Path) -> None: """OAuth `state` / `code_challenge` live in these URLs and data/redaction.py matches neither — so no page URL may ever reach a log event.""" gflow_home, profile_dir = self._home(tmp_path) - ap, _pw, _ctx = _build_fake_playwright( + ap, _pw, ctx = _build_fake_playwright( page_url=( "https://accounts.google.com/v3/signin/identifier" "?state=SECRETSTATE&code_challenge=SECRETCHALLENGE" ), ) + page = ctx.pages[0] + + # The page starts mid-handshake on Google and arrives on the Flow host while + # the poll is waiting — which is what a real sign-in does, and what the poll's + # host guard requires before it will touch the session endpoint. Without this + # the page never leaves accounts.google.com, the guard skips every iteration + # and the test hangs until the 600 s timeout instead of asserting anything. + async def _navigate_while_we_wait(_delay: float) -> None: + page.url = "https://labs.google/fx/tools/flow" with ( patch("gflow_cli.auth.real_chrome.get_settings") as mock_settings, @@ -696,6 +705,10 @@ async def test_never_logs_a_google_url(self, tmp_path: Path) -> None: AsyncMock(return_value=_authenticated_status()), ), patch("gflow_cli.auth.real_chrome.asyncio.sleep", AsyncMock()), + patch( + "gflow_cli.auth.internal_chromium.asyncio.sleep", + AsyncMock(side_effect=_navigate_while_we_wait), + ), capture_logs() as logs, ): mock_settings.return_value.home = gflow_home From df9b51671df60ee00ab3f1eb5d2159bd0ca727f3 Mon Sep 17 00:00:00 2001 From: Flavio Oliva Date: Wed, 9 Sep 2026 09:10:07 +0100 Subject: [PATCH 07/12] test(auth): give the strategy doubles a Flow-host URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three strategy tests built a page with `goto` and `request.get` mocked but no `url`, so the attribute stayed a bare MagicMock. That is not a str, the poll's new host guard read it as "still mid-OAuth", and the loop spun against a patched `asyncio.sleep` — which records every call, so the run climbed to 14.7 GB before the OS killed it. The doubles now say what the runtime does: the strategy has just navigated to GEMINI_URL, so the page is on the Flow host. 20 passed in 2.11 s. --- tests/auth/strategies/test_strategies.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/auth/strategies/test_strategies.py b/tests/auth/strategies/test_strategies.py index e26c34c2..32a269cf 100644 --- a/tests/auth/strategies/test_strategies.py +++ b/tests/auth/strategies/test_strategies.py @@ -419,6 +419,11 @@ async def test_internal_chromium_standard_behavior(self, tmp_path: Path) -> None mock_resp.text = AsyncMock(return_value='{"user": {"email": "test@example.com"}}') mock_page = MagicMock(name="page") + # Mirror the runtime contract: the strategy has just navigated to GEMINI_URL, + # so the page IS on the Flow host. Left as a bare MagicMock attribute this is + # not a str, the poll's host guard reads "still mid-OAuth" and the loop spins + # until the 600 s timeout instead of polling once. + mock_page.url = "https://labs.google/fx/tools/flow" mock_page.goto = AsyncMock() mock_page.request.get = AsyncMock(return_value=mock_resp) @@ -488,6 +493,11 @@ async def test_internal_chromium_wraps_launch_in_profile_lease( mock_resp.status = 200 mock_resp.text = AsyncMock(return_value='{"user": {"email": "test@example.com"}}') mock_page = MagicMock(name="page") + # Mirror the runtime contract: the strategy has just navigated to GEMINI_URL, + # so the page IS on the Flow host. Left as a bare MagicMock attribute this is + # not a str, the poll's host guard reads "still mid-OAuth" and the loop spins + # until the 600 s timeout instead of polling once. + mock_page.url = "https://labs.google/fx/tools/flow" mock_page.goto = AsyncMock() mock_page.request.get = AsyncMock(return_value=mock_resp) mock_ctx = MagicMock(name="ctx") @@ -530,6 +540,11 @@ async def test_internal_chromium_timeout_raises(self, tmp_path: Path) -> None: mock_resp.text = AsyncMock(return_value="{}") mock_page = MagicMock(name="page") + # Mirror the runtime contract: the strategy has just navigated to GEMINI_URL, + # so the page IS on the Flow host. Left as a bare MagicMock attribute this is + # not a str, the poll's host guard reads "still mid-OAuth" and the loop spins + # until the 600 s timeout instead of polling once. + mock_page.url = "https://labs.google/fx/tools/flow" mock_page.goto = AsyncMock() mock_page.request.get = AsyncMock(return_value=mock_resp) From 449a72c47f0b85d600db2d6133c9cf9d8af723d7 Mon Sep 17 00:00:00 2001 From: Flavio Oliva Date: Wed, 9 Sep 2026 10:39:52 +0100 Subject: [PATCH 08/12] fix(auth): notice a window closed during 2FA instead of waiting out the deadline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host guard `continue`s without touching Playwright, so while the page sits on accounts.google.com nothing ever raises — and `page.is_closed()` was only consulted inside `except PlaywrightError`. A user who abandons a 2FA challenge closes the window on Google's host, which is exactly where that reactive detection cannot fire. Measured before this change: a full run to the deadline with the session endpoint touched 0 times. On the default 600 s timeout that is a ten-minute wait ending in exit 12, for someone who closed the window after thirty seconds. Checking liveness first costs one call per poll and ends the run immediately with `auth_login_browser_closed_by_user`, which routes to the on-disk verify like any other manual close. Introduced by the host guard two commits back, so it never shipped. Found by asking what happens when the two timing layers — how long the user takes, and which host they are on while taking it — interact. Three strategy doubles needed an explicit `is_closed = False`: a bare MagicMock attribute is truthy, so the new check read "already closed" and broke before doing anything under test. --- src/gflow_cli/auth/internal_chromium.py | 10 +++++ tests/auth/strategies/test_strategies.py | 48 ++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/gflow_cli/auth/internal_chromium.py b/src/gflow_cli/auth/internal_chromium.py index bb994986..ad7c4a51 100644 --- a/src/gflow_cli/auth/internal_chromium.py +++ b/src/gflow_cli/auth/internal_chromium.py @@ -108,6 +108,16 @@ async def poll_session_until_authenticated( while asyncio.get_running_loop().time() < timeout_at: try: + # Liveness FIRST, because the host guard below can `continue` without + # touching Playwright at all. A user who abandons a 2FA challenge closes + # the window while still on accounts.google.com, so the guard short-circuits + # every iteration, nothing raises, and the close is never noticed: measured + # as a full run to the deadline with the session endpoint touched 0 times. + # Reactive detection via `except PlaywrightError` only works once some + # operation actually runs, which on the Google host it never does. + if page.is_closed(): + break + if _is_google_rejected_browser_page(page): raise AuthBrowserRejectedError diff --git a/tests/auth/strategies/test_strategies.py b/tests/auth/strategies/test_strategies.py index 32a269cf..e76a8111 100644 --- a/tests/auth/strategies/test_strategies.py +++ b/tests/auth/strategies/test_strategies.py @@ -425,6 +425,10 @@ async def test_internal_chromium_standard_behavior(self, tmp_path: Path) -> None # until the 600 s timeout instead of polling once. mock_page.url = "https://labs.google/fx/tools/flow" mock_page.goto = AsyncMock() + # Explicit, because a bare MagicMock attribute is TRUTHY: left unset, + # `page.is_closed()` reports "the user already closed the window" on the + # first poll and the loop breaks before doing anything under test. + mock_page.is_closed = MagicMock(return_value=False) mock_page.request.get = AsyncMock(return_value=mock_resp) mock_ctx = MagicMock(name="ctx") @@ -499,6 +503,10 @@ async def test_internal_chromium_wraps_launch_in_profile_lease( # until the 600 s timeout instead of polling once. mock_page.url = "https://labs.google/fx/tools/flow" mock_page.goto = AsyncMock() + # Explicit, because a bare MagicMock attribute is TRUTHY: left unset, + # `page.is_closed()` reports "the user already closed the window" on the + # first poll and the loop breaks before doing anything under test. + mock_page.is_closed = MagicMock(return_value=False) mock_page.request.get = AsyncMock(return_value=mock_resp) mock_ctx = MagicMock(name="ctx") mock_ctx.pages = [mock_page] @@ -546,6 +554,10 @@ async def test_internal_chromium_timeout_raises(self, tmp_path: Path) -> None: # until the 600 s timeout instead of polling once. mock_page.url = "https://labs.google/fx/tools/flow" mock_page.goto = AsyncMock() + # Explicit, because a bare MagicMock attribute is TRUTHY: left unset, + # `page.is_closed()` reports "the user already closed the window" on the + # first poll and the loop breaks before doing anything under test. + mock_page.is_closed = MagicMock(return_value=False) mock_page.request.get = AsyncMock(return_value=mock_resp) mock_ctx = MagicMock(name="ctx") @@ -591,6 +603,10 @@ async def test_internal_chromium_rejected_browser_raises_guidance( mock_page = MagicMock(name="page") mock_page.url = "https://accounts.google.com/v3/signin/rejected?continue=flow" mock_page.goto = AsyncMock() + # Explicit, because a bare MagicMock attribute is TRUTHY: left unset, + # `page.is_closed()` reports "the user already closed the window" on the + # first poll and the loop breaks before doing anything under test. + mock_page.is_closed = MagicMock(return_value=False) mock_page.get_by_text.return_value = mock_success_loc mock_ctx = MagicMock(name="ctx") @@ -737,6 +753,38 @@ async def test_session_request_resumes_once_back_on_flow(self) -> None: assert email == "test@example.com" page.request.get.assert_awaited() + @pytest.mark.asyncio + async def test_close_during_2fa_is_noticed_immediately(self) -> None: + """Closing the window mid-2FA must end the poll, not run to the deadline. + + The host guard `continue`s without touching Playwright, so on Google's host + nothing ever raises and the reactive `except PlaywrightError -> is_closed()` + detection never fires. Measured before the liveness check: a full run to the + deadline with the session endpoint touched 0 times — a user who abandoned a + 2FA challenge after 30 s would wait the whole 600 s for exit 12. + """ + from gflow_cli.auth.internal_chromium import poll_session_until_authenticated + + page = MagicMock(name="page") + # Abandoned mid-challenge: still on Google's host, window gone. + page.url = "https://accounts.google.com/v3/signin/challenge/totp?x=1" + page.is_closed = MagicMock(return_value=True) + page.request.get = AsyncMock() + + ctx = MagicMock(name="ctx") + ctx.cookies = AsyncMock(return_value=[]) + + with patch("gflow_cli.auth.internal_chromium.asyncio.sleep", AsyncMock()): + assert ( + await poll_session_until_authenticated( + ctx, page, 600, "chrome", raise_on_close=False + ) + is None + ) + + # Never reached the session endpoint, and never waited out the deadline. + page.request.get.assert_not_awaited() + @pytest.mark.asyncio async def test_migrated_host_still_polls(self) -> None: """A migrated account lands on flow.google.com and must still be detected. From 7c6aee787c5fcf1e41e56ad6498c786aff5f85e2 Mon Sep 17 00:00:00 2001 From: Flavio Oliva Date: Wed, 9 Sep 2026 10:44:25 +0100 Subject: [PATCH 09/12] test(auth): make the 2FA-close regression fail fast instead of hanging CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The passing path returns instantly, so the deadline in this test only matters when the liveness check regresses — and at 600 s that is a ten-minute CI hang rather than a red test. Verified by neutering the check: 5 s gives `1 failed in 5.30s`; 600 s gave a hang that had to be killed by hand. --- tests/auth/strategies/test_strategies.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/auth/strategies/test_strategies.py b/tests/auth/strategies/test_strategies.py index e76a8111..019371bf 100644 --- a/tests/auth/strategies/test_strategies.py +++ b/tests/auth/strategies/test_strategies.py @@ -774,10 +774,15 @@ async def test_close_during_2fa_is_noticed_immediately(self) -> None: ctx = MagicMock(name="ctx") ctx.cookies = AsyncMock(return_value=[]) + # A SMALL deadline on purpose. The passing path returns instantly, so the + # value only matters when this regresses — and then it decides whether CI + # fails in seconds or hangs for the full production timeout. Verified by + # neutering the check: the run spins to the deadline, so 600 here would be + # a ten-minute hang instead of a red test. with patch("gflow_cli.auth.internal_chromium.asyncio.sleep", AsyncMock()): assert ( await poll_session_until_authenticated( - ctx, page, 600, "chrome", raise_on_close=False + ctx, page, 5, "chrome", raise_on_close=False ) is None ) From 7ee912c012c78783258820fc4ab0434bcf78039f Mon Sep 17 00:00:00 2001 From: Flavio Oliva Date: Wed, 9 Sep 2026 10:44:51 +0100 Subject: [PATCH 10/12] style: ruff format the 2FA-close regression test --- tests/auth/strategies/test_strategies.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/auth/strategies/test_strategies.py b/tests/auth/strategies/test_strategies.py index 019371bf..80730ca0 100644 --- a/tests/auth/strategies/test_strategies.py +++ b/tests/auth/strategies/test_strategies.py @@ -781,9 +781,7 @@ async def test_close_during_2fa_is_noticed_immediately(self) -> None: # a ten-minute hang instead of a red test. with patch("gflow_cli.auth.internal_chromium.asyncio.sleep", AsyncMock()): assert ( - await poll_session_until_authenticated( - ctx, page, 5, "chrome", raise_on_close=False - ) + await poll_session_until_authenticated(ctx, page, 5, "chrome", raise_on_close=False) is None ) From 0462c73c2158df24a52b411620a483eecbf81c3b Mon Sep 17 00:00:00 2001 From: Flavio Oliva Date: Wed, 9 Sep 2026 11:31:33 +0100 Subject: [PATCH 11/12] test(auth): stop asserting the exit-14 guidance the spike disproved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This branch rewrote `AuthBrowserRejectedError`'s remediation because the 2026-09-08 spike refuted its premise: bundled Chromium signed in normally WITH the stealth flags, while real Chrome WITHOUT them was rejected at /v3/signin/rejected in 17.5 s. The discriminator is `navigator.webdriver`, not the browser binary. The test still asserted the old strings — "--browser chrome" and "GFLOW_CLI_AUTH_BROWSER=chrome" — so it was pinning advice that would send a user to swap browsers over a setting. A test protecting a defect, which is the fourth instance of that pattern on this branch. The assertions are inverted deliberately: the disproved advice must not come back, and the message must name the real discriminator. Missed locally because my runs were scoped to tests/auth/ and tests/test_browser_manager.py; CI runs the whole suite. It also took SonarCloud down with it — the coverage artifact is produced by the test job, so the gate had nothing to analyse. --- tests/cli/test_error_handling.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/tests/cli/test_error_handling.py b/tests/cli/test_error_handling.py index 888efd3f..ba618c4b 100644 --- a/tests/cli/test_error_handling.py +++ b/tests/cli/test_error_handling.py @@ -525,11 +525,26 @@ def test_configuration_error_exits_11(self) -> None: assert result.exit_code == 11, result.output assert "Session saved" not in result.output - def test_browser_rejected_exits_14_with_chrome_guidance(self) -> None: - """AuthBrowserRejectedError points users at real Chrome instead of another retry.""" + def test_browser_rejected_exits_14_names_the_real_discriminator(self) -> None: + """AuthBrowserRejectedError blames the automation signal, not the binary. + + This test previously asserted the guidance "rerun with `--browser chrome`" + and "set GFLOW_CLI_AUTH_BROWSER=chrome", on the premise that Google rejects + Playwright's bundled Chromium. The 2026-09-08 spike disproved that premise: + bundled Chromium signed in normally *with* the stealth flags, while real + Chrome *without* them was rejected at /v3/signin/rejected in 17.5 s. The + discriminator is `navigator.webdriver`, not the browser. + + So the old assertions were pinning advice that would send a user to swap + browsers over a setting — a test protecting a defect. They are inverted here + deliberately: the disproved advice must NOT come back. + """ result = self._invoke_auth_login(AuthBrowserRejectedError()) assert result.exit_code == 14, result.output assert "Login browser rejected" in result.output - assert "--browser chrome" in result.output - assert "GFLOW_CLI_AUTH_BROWSER=chrome" in result.output + assert "navigator.webdriver" in result.output + assert "retries automatically" in result.output + # The disproved guidance must stay gone. + assert "--browser chrome" not in result.output + assert "GFLOW_CLI_AUTH_BROWSER=chrome" not in result.output assert "Session saved" not in result.output From 54c57959a24ff35079711e9ef9e8d5191d061a04 Mon Sep 17 00:00:00 2001 From: Flavio Oliva Date: Wed, 9 Sep 2026 11:59:11 +0100 Subject: [PATCH 12/12] fix(auth): gate the session probe on the route, not just the host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Council review of the eight unreviewed commits on this branch returned RED, and the headline finding refutes what the previous commit's comment claimed. **The host guard never protected the OAuth callback.** NextAuth runs its callback on the app's OWN origin, so a labs.google host test passes straight through it. Verified: _is_on_flow_host("https://labs.google/fx/api/auth/callback/google?state=S&code=C") -> True _is_on_flow_host("https://labs.google/fx/api/auth/signin?error=OAuthCallback") -> True That second URL is the exact failure the comment cited as observed. Excluding only accounts.google.com excluded the one phase where the callback is NOT running. The gate now also excludes NextAuth's own auth routes, and the comment no longer claims more than it does. Three further fixes fall out of reusing the existing classifier: - `urlparse("https://[bad").hostname` raises ValueError. It escaped the helper into the loop's catch-all `except Exception: break`, which under the InternalChromium default reports "Browser closed before the Flow editor sign-in was verified" for a browser that is open. - The helper was a FOURTH copy of the Flow-host set. `flow_host_kind` (api/transports/_common.py) is strictly stronger: https required, exact host rather than substring, ValueError-safe, non-str-safe. - Its import must be deferred — a module-level one cycles through profile_store back into gflow_cli.auth. The review asserted there was no cycle; there is, and the import failed immediately. Also: `POLL_INTERVAL_SECONDS` governed only ONE of the loop's two sleeps, the other being a literal 3. That is why widening it during live-verify did not do what I said it did — the main poll path never changed cadence. Both sites now use the constant. Two tests polled with timeout_seconds=30, so a regression would spin 30 s and ~1.1 GB rather than failing fast. Now 5, matching the sibling test. Docs: three exit-14 remediation sites still prescribed `--browser chrome`, which the 2026-09-08 spike disproved and which contradicted the test added in 0462c73c. The reCAPTCHA-on-generation mention of `--browser chrome` in DEBUGGING.md is a different surface the spike says nothing about, and stays. New tests pin the route gate across both cohorts, both auth routes, Google's host, a malformed URL and a bare mock. Full suite with coverage, as CI runs it: 4146 passed. --- docs/DEBUGGING.md | 2 +- docs/USAGE.md | 4 +- src/gflow_cli/auth/internal_chromium.py | 76 ++++++++++++++---------- tests/auth/strategies/test_strategies.py | 35 ++++++++++- website/docs/DEBUGGING.md | 2 +- website/docs/USAGE.md | 4 +- 6 files changed, 85 insertions(+), 38 deletions(-) diff --git a/docs/DEBUGGING.md b/docs/DEBUGGING.md index 88d1fd2b..74490be1 100644 --- a/docs/DEBUGGING.md +++ b/docs/DEBUGGING.md @@ -13,7 +13,7 @@ | `gflow image t2i` hangs ≥ 3 min then fails with `TimeoutError` | Re-run with `--verbose` and grep for `batch_response_seen` | [Listener log keys](#listener--http-layer-debugging) | | `aspect_ratio_set_failed` warning then wrong-aspect output | The aspect-tab selector cascade missed; capture a DOM snapshot of the gen-settings panel | [Inspecting Flow's live UI](#inspecting-flows-live-ui) | | `UnicodeEncodeError: 'charmap' codec can't encode` on Windows | Set `PYTHONUTF8=1` (PowerShell: `$env:PYTHONUTF8="1"`) before any `gflow` invocation | [Windows console](#windows-console-encoding) | -| `AuthBrowserRejectedError` / exit 14 | Re-login with `--browser chrome` | [`AUTHENTICATION.md`](AUTHENTICATION.md), `/gflow:known-issues` | +| `AuthBrowserRejectedError` / exit 14 | Re-run `gflow auth login` (the `chrome` strategy retries automatically) | [`AUTHENTICATION.md`](AUTHENTICATION.md), `/gflow:known-issues` | | `BrowserSessionClosedError` / exit 15 in a long-lived worker | Recreate the `FlowApiClient` via its async context manager | [Lifecycle errors](#lifecycle--browser-state) | | Test suite OOMs / sandbox crashes | Run dirs separately (`tests/api`, `tests/auth tests/cli`, `tests/features`, then the rest with `--ignore`) | [Test suite memory](#test-suite-memory) | | New Flow UI label breaks a selector | Add a candidate to `_ASPECT_TAB_CANDIDATES` (or the relevant cascade) and live-verify | [Selector cascades](#selector-cascades) | diff --git a/docs/USAGE.md b/docs/USAGE.md index b4ad790a..07892b63 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -1763,7 +1763,7 @@ shell scripts can branch on the failure mode without parsing stderr. | `11` | `ConfigurationError` | Local configuration or browser mode is invalid — on the migrated `flow.google.com` host also a request the host cannot take as given (no `--project`, a model its menu does not offer, a `--duration` its settings pane renders no control for); includes `ProfileLockedError` (same-profile lease contention: another `gflow`/daemon/MCP call already owns this profile) and `ProfileEngineDowngradeError` (the profile was last written by a newer Chromium major than the bundled engine about to open it — see [AUTHENTICATION § Chromium downgrade guard](AUTHENTICATION.md#chromium-downgrade-guard)) | Fix the option/env var shown in the error; for lease contention wait, use a different `--profile`, or set `GFLOW_CLI_LEASE_WAIT_SECONDS=N` to wait bounded; upgrade gflow-cli/Playwright or re-run `gflow auth login` for a downgrade refusal | | `12` | `AuthLoginTimeoutError` | Browser sign-in was not completed in time | Re-run login or raise `GFLOW_CLI_AUTH_LOGIN_TIMEOUT` | | `13` | `SecurityError` | Unsafe local profile or secret handling blocked | Follow the error's safety guidance | -| `14` | `AuthBrowserRejectedError` | Google rejected the login browser | `gflow auth login --browser chrome` | +| `14` | `AuthBrowserRejectedError` | Sign-in rejected the browser for `navigator.webdriver` | Re-run `gflow auth login`; with Chrome installed the `chrome` strategy retries automatically | | `15` | `BrowserSessionClosedError` | The automation browser window was closed mid-operation | Re-run; keep the browser window open until the command finishes | | `16` | `DataStoreError` | Local database cannot be opened, a migration failed, or the DB schema is newer than the installed gflow-cli | See below | | `17` | `ModelModeIncompatibilityError` | The chosen video model can't do the requested mode — today that is `omni-flash` for `chain` (issues #125, #626) | Use a Veo 3.1 model (`veo-lite` / `veo-fast` / `veo-quality` / `veo-lite-lp`) for `chain`. Single-clip `i2v` with omni-flash, `--end-frame` included, is accepted | @@ -1825,7 +1825,7 @@ if [ "$rc" -ne 0 ]; then 10) echo "Flow rejected the request — adjust the prompt/request and retry"; exit 1 ;; 11) echo "Configuration error — fix the option or env var shown above"; exit 1 ;; 13) echo "Security guard blocked unsafe local state — follow the error guidance"; exit 1 ;; - 14) echo "Google rejected the login browser — run: gflow auth login --browser chrome"; exit 1 ;; + 14) echo "Sign-in rejected the browser (navigator.webdriver) — run: gflow auth login"; exit 1 ;; 16) echo "Database error — check permissions or upgrade gflow-cli"; exit 1 ;; 130) echo "Cancelled with Ctrl-C"; exit 130 ;; *) echo "Unknown failure (exit $rc)"; exit 1 ;; diff --git a/src/gflow_cli/auth/internal_chromium.py b/src/gflow_cli/auth/internal_chromium.py index ad7c4a51..4813fb8d 100644 --- a/src/gflow_cli/auth/internal_chromium.py +++ b/src/gflow_cli/auth/internal_chromium.py @@ -2,7 +2,7 @@ import asyncio from typing import TYPE_CHECKING, Any -from urllib.parse import urlparse +from urllib.parse import urlsplit import structlog from playwright.async_api import Error as PlaywrightError @@ -24,17 +24,12 @@ GEMINI_URL = "https://labs.google/fx/tools/flow?hl=en" GOOGLE_REJECTED_BROWSER_ROUTE = "accounts.google.com/v3/signin/rejected" POLL_INTERVAL_SECONDS = 3 -# Hosts the Flow app itself is served from. The session poll only runs while the page -# is on one of these — see the comment in poll_session_until_authenticated. Structural -# (a host, not display text), so it stays locale-invariant. -# -# BOTH hosts, not just labs: the labs app `location.replace`s a migrated account onto -# flow.google.com right after the callback returns, one-way and server-decided per -# account. Gating on labs alone would send every migrated account back into the exact -# 600 s timeout this guard was written to fix — the poll would go False on the redirect -# and never come back. Neither host is an OAuth handshake host, which is all the guard -# actually needs to exclude. -FLOW_APP_HOSTS = frozenset({"labs.google", "flow.google.com"}) +# 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( @@ -121,17 +116,19 @@ async def poll_session_until_authenticated( if _is_google_rejected_browser_page(page): raise AuthBrowserRejectedError - # Do not touch the session endpoint while the browser is away on the - # OAuth handshake. `/fx/api/auth/session` is a NextAuth route that can - # rotate session cookies, and a poll landing mid-callback can clobber the - # `state`/PKCE cookies the callback needs — observed live 2026-09-08 as + # Do not touch the session endpoint while a sign-in is in flight. + # `/fx/api/auth/session` is a NextAuth route that can rotate session + # cookies, and a poll landing mid-callback can clobber the `state`/PKCE + # cookies the callback needs — observed live 2026-09-08 as # `labs.google/fx/api/auth/signin?error=OAuthCallback`, a sign-in that - # failed and then timed out at 600 s. The 2026-09-08 spike, which signed - # in successfully twice, never made this request: it read the jar locally - # over CDP and issued no HTTP at all during sign-in. Waiting until the - # page is back on the Flow host restores that property, and it is what - # notebooklm-py does (watch the URL first, read the session after). - if not _is_on_flow_host(page): + # failed and then timed out at 600 s. The spike that signed in twice + # never made this request at all: it read the jar locally over CDP. + # + # A HOST check alone does not do this. NextAuth's callback runs on the + # app's own origin, so `/fx/api/auth/callback/google` passes any + # labs.google test — see `_is_safe_to_probe_session`, which excludes the + # auth routes as well as the host. + if not _is_safe_to_probe_session(page): await asyncio.sleep(POLL_INTERVAL_SECONDS) continue @@ -188,7 +185,7 @@ async def poll_session_until_authenticated( ) break - await asyncio.sleep(3) + await asyncio.sleep(POLL_INTERVAL_SECONDS) else: msg = f"Flow sign-in not completed within {timeout_seconds}s." raise AuthLoginTimeoutError( @@ -222,17 +219,36 @@ def _is_google_rejected_browser_page(page: object) -> bool: return isinstance(url, str) and GOOGLE_REJECTED_BROWSER_ROUTE in url -def _is_on_flow_host(page: object) -> bool: - """Return True when the page is on a Flow app host, not mid-OAuth on Google's. +def _is_safe_to_probe_session(page: object) -> bool: + """Return True when the session endpoint can be read without disturbing a sign-in. - ``isinstance(url, str)`` is load-bearing, not defensive: a bare mock attribute is - truthy, so without it a test double would report "on the Flow host" and the guard - would pass for the wrong reason. + Two conditions, and the second is the one a host check alone gets wrong. + + **On a Flow host.** ``flow_host_kind`` is the codebase's existing classifier + (``api/transports/_common.py``) rather than a fourth copy of the host set: it + requires https, matches the host exactly instead of by substring, and returns + ``None`` for a non-str or an unparseable URL. That last part is load-bearing — + ``urlparse("https://[bad").hostname`` raises ``ValueError``, which escaped an + earlier version of this helper into the loop's catch-all and reported "browser + closed" for a browser that was open. + + **Not on NextAuth's own auth routes.** NextAuth runs the OAuth callback on the + *app's* origin, so a host test passes straight through it — verified: + ``/fx/api/auth/callback/google?state=…&code=…`` and + ``/fx/api/auth/signin?error=OAuthCallback`` both satisfy a labs.google host check. + Reading ``/fx/api/auth/session`` while that callback is in flight is precisely the + cookie-rotation hazard this guard exists to avoid, so excluding only + ``accounts.google.com`` excluded the one phase where the callback is NOT running. """ + # 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 + url = getattr(page, "url", "") - if not isinstance(url, str): + if flow_host_kind(url) is None: return False - return (urlparse(url).hostname or "").lower() in FLOW_APP_HOSTS + return not urlsplit(str(url)).path.startswith(_NEXTAUTH_ROUTE_PREFIX) class InternalChromiumStrategy(AuthStrategy): diff --git a/tests/auth/strategies/test_strategies.py b/tests/auth/strategies/test_strategies.py index 80730ca0..3537c321 100644 --- a/tests/auth/strategies/test_strategies.py +++ b/tests/auth/strategies/test_strategies.py @@ -748,11 +748,42 @@ async def test_session_request_resumes_once_back_on_flow(self) -> None: ctx.cookies = AsyncMock(return_value=[{"name": "SAPISID", "value": "x"}]) with patch("gflow_cli.auth.internal_chromium.asyncio.sleep", AsyncMock()): - email = await poll_session_until_authenticated(ctx, page, 30, "chrome") + email = await poll_session_until_authenticated(ctx, page, 5, "chrome") assert email == "test@example.com" page.request.get.assert_awaited() + @pytest.mark.parametrize( + ("url", "safe"), + [ + ("https://labs.google/fx/tools/flow", True), + ("https://flow.google.com/project/abc", True), + # NextAuth runs the callback on the APP's origin, so a host check alone + # sails straight through the one phase this guard exists to protect. + ("https://labs.google/fx/api/auth/callback/google?state=S&code=C", False), + ("https://labs.google/fx/api/auth/signin?error=OAuthCallback", False), + ("https://accounts.google.com/v3/signin/identifier", False), + # `urlparse(...).hostname` raises ValueError here; an earlier version let + # that escape into the loop's catch-all, which reported "browser closed" + # for a browser that was open. + ("https://[bad", False), + ("about:blank", False), + ], + ) + def test_session_probe_is_gated_on_route_not_just_host(self, url: str, safe: bool) -> None: + """The probe gate must exclude NextAuth's own auth routes, not only Google's host.""" + from gflow_cli.auth.internal_chromium import _is_safe_to_probe_session + + page = MagicMock(name="page") + page.url = url + assert _is_safe_to_probe_session(page) is safe + + def test_session_probe_rejects_a_bare_mock_url(self) -> None: + """A bare MagicMock attribute is truthy — it must not read as a Flow host.""" + from gflow_cli.auth.internal_chromium import _is_safe_to_probe_session + + assert _is_safe_to_probe_session(MagicMock(name="page")) is False + @pytest.mark.asyncio async def test_close_during_2fa_is_noticed_immediately(self) -> None: """Closing the window mid-2FA must end the poll, not run to the deadline. @@ -814,7 +845,7 @@ async def test_migrated_host_still_polls(self) -> None: ctx.cookies = AsyncMock(return_value=[{"name": "SAPISID", "value": "x"}]) with patch("gflow_cli.auth.internal_chromium.asyncio.sleep", AsyncMock()): - email = await poll_session_until_authenticated(ctx, page, 30, "chrome") + email = await poll_session_until_authenticated(ctx, page, 5, "chrome") assert email == "test@example.com" page.request.get.assert_awaited() diff --git a/website/docs/DEBUGGING.md b/website/docs/DEBUGGING.md index 88d1fd2b..74490be1 100644 --- a/website/docs/DEBUGGING.md +++ b/website/docs/DEBUGGING.md @@ -13,7 +13,7 @@ | `gflow image t2i` hangs ≥ 3 min then fails with `TimeoutError` | Re-run with `--verbose` and grep for `batch_response_seen` | [Listener log keys](#listener--http-layer-debugging) | | `aspect_ratio_set_failed` warning then wrong-aspect output | The aspect-tab selector cascade missed; capture a DOM snapshot of the gen-settings panel | [Inspecting Flow's live UI](#inspecting-flows-live-ui) | | `UnicodeEncodeError: 'charmap' codec can't encode` on Windows | Set `PYTHONUTF8=1` (PowerShell: `$env:PYTHONUTF8="1"`) before any `gflow` invocation | [Windows console](#windows-console-encoding) | -| `AuthBrowserRejectedError` / exit 14 | Re-login with `--browser chrome` | [`AUTHENTICATION.md`](AUTHENTICATION.md), `/gflow:known-issues` | +| `AuthBrowserRejectedError` / exit 14 | Re-run `gflow auth login` (the `chrome` strategy retries automatically) | [`AUTHENTICATION.md`](AUTHENTICATION.md), `/gflow:known-issues` | | `BrowserSessionClosedError` / exit 15 in a long-lived worker | Recreate the `FlowApiClient` via its async context manager | [Lifecycle errors](#lifecycle--browser-state) | | Test suite OOMs / sandbox crashes | Run dirs separately (`tests/api`, `tests/auth tests/cli`, `tests/features`, then the rest with `--ignore`) | [Test suite memory](#test-suite-memory) | | New Flow UI label breaks a selector | Add a candidate to `_ASPECT_TAB_CANDIDATES` (or the relevant cascade) and live-verify | [Selector cascades](#selector-cascades) | diff --git a/website/docs/USAGE.md b/website/docs/USAGE.md index 15ebcc01..19fa9c27 100644 --- a/website/docs/USAGE.md +++ b/website/docs/USAGE.md @@ -1763,7 +1763,7 @@ shell scripts can branch on the failure mode without parsing stderr. | `11` | `ConfigurationError` | Local configuration or browser mode is invalid — on the migrated `flow.google.com` host also a request the host cannot take as given (no `--project`, a model its menu does not offer, a `--duration` its settings pane renders no control for); includes `ProfileLockedError` (same-profile lease contention: another `gflow`/daemon/MCP call already owns this profile) and `ProfileEngineDowngradeError` (the profile was last written by a newer Chromium major than the bundled engine about to open it — see [AUTHENTICATION § Chromium downgrade guard](AUTHENTICATION.md#chromium-downgrade-guard)) | Fix the option/env var shown in the error; for lease contention wait, use a different `--profile`, or set `GFLOW_CLI_LEASE_WAIT_SECONDS=N` to wait bounded; upgrade gflow-cli/Playwright or re-run `gflow auth login` for a downgrade refusal | | `12` | `AuthLoginTimeoutError` | Browser sign-in was not completed in time | Re-run login or raise `GFLOW_CLI_AUTH_LOGIN_TIMEOUT` | | `13` | `SecurityError` | Unsafe local profile or secret handling blocked | Follow the error's safety guidance | -| `14` | `AuthBrowserRejectedError` | Google rejected the login browser | `gflow auth login --browser chrome` | +| `14` | `AuthBrowserRejectedError` | Sign-in rejected the browser for `navigator.webdriver` | Re-run `gflow auth login`; with Chrome installed the `chrome` strategy retries automatically | | `15` | `BrowserSessionClosedError` | The automation browser window was closed mid-operation | Re-run; keep the browser window open until the command finishes | | `16` | `DataStoreError` | Local database cannot be opened, a migration failed, or the DB schema is newer than the installed gflow-cli | See below | | `17` | `ModelModeIncompatibilityError` | The chosen video model can't do the requested mode — today that is `omni-flash` for `chain` (issues #125, #626) | Use a Veo 3.1 model (`veo-lite` / `veo-fast` / `veo-quality` / `veo-lite-lp`) for `chain`. Single-clip `i2v` with omni-flash, `--end-frame` included, is accepted | @@ -1825,7 +1825,7 @@ if [ "$rc" -ne 0 ]; then 10) echo "Flow rejected the request — adjust the prompt/request and retry"; exit 1 ;; 11) echo "Configuration error — fix the option or env var shown above"; exit 1 ;; 13) echo "Security guard blocked unsafe local state — follow the error guidance"; exit 1 ;; - 14) echo "Google rejected the login browser — run: gflow auth login --browser chrome"; exit 1 ;; + 14) echo "Sign-in rejected the browser (navigator.webdriver) — run: gflow auth login"; exit 1 ;; 16) echo "Database error — check permissions or upgrade gflow-cli"; exit 1 ;; 130) echo "Cancelled with Ctrl-C"; exit 130 ;; *) echo "Unknown failure (exit $rc)"; exit 1 ;;