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 a8ee36e7..ebcdbb88 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,23 @@ 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 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 + 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/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 8dbbcabc..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 @@ -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.** `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. **UiAutomationTransport (UI Mimicry)**: @@ -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 2fe574be..8771cc69 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 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` -**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/DEBUGGING.md b/docs/DEBUGGING.md index 5bc50f6d..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) | @@ -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/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/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 26e1fc9b..8f816f0a 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,9 +750,15 @@ 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**. -4. `gflow auth login` probes the profile with `channel="chrome"`, verifies SAPISID is - present, and writes `.gflow_browser_strategy = "chrome"` to the profile directory. +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` 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/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/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()) 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 f2fc337d..4813fb8d 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 urlsplit import structlog from playwright.async_api import Error as PlaywrightError @@ -22,20 +23,79 @@ GEMINI_URL = "https://labs.google/fx/tools/flow?hl=en" GOOGLE_REJECTED_BROWSER_ROUTE = "accounts.google.com/v3/signin/rejected" +POLL_INTERVAL_SECONDS = 3 +# NextAuth mounts its OAuth routes here — callback, signin, and the session endpoint +# itself. The poll stays off the page while it is on one of them; see +# `_is_safe_to_probe_session`. Host classification reuses `flow_host_kind`, which already +# knows both cohorts (labs.google and the migrated flow.google.com), so there is no +# host set to keep in sync here. +_NEXTAUTH_ROUTE_PREFIX = "/fx/api/auth/" -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 @@ -43,9 +103,35 @@ 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 + # 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 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 + 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) @@ -61,6 +147,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 @@ -69,9 +161,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", @@ -80,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( @@ -93,6 +198,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, @@ -111,6 +219,38 @@ def _is_google_rejected_browser_page(page: object) -> bool: return isinstance(url, str) and GOOGLE_REJECTED_BROWSER_ROUTE in url +def _is_safe_to_probe_session(page: object) -> bool: + """Return True when the session endpoint can be read without disturbing a sign-in. + + 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 flow_host_kind(url) is None: + return False + return not urlsplit(str(url)).path.startswith(_NEXTAUTH_ROUTE_PREFIX) + + class InternalChromiumStrategy(AuthStrategy): """Legacy login strategy using bundled Playwright Chromium. @@ -149,13 +289,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 +306,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..4e157946 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 @@ -284,6 +451,7 @@ async def login(self, profile_dir: Path, headless: bool) -> 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/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/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 3224ce4c..3537c321 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", @@ -398,7 +419,16 @@ 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() + # 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") @@ -426,9 +456,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" @@ -452,7 +497,16 @@ 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() + # 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] @@ -494,7 +548,16 @@ 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() + # 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") @@ -540,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") @@ -565,6 +632,220 @@ 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 + ) + + +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, 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. + + 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=[]) + + # 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, 5, "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. + + 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, 5, "chrome") + + assert email == "test@example.com" + page.request.get.assert_awaited() diff --git a/tests/auth/test_real_chrome.py b/tests/auth/test_real_chrome.py index ae46d413..1aa2e009 100644 --- a/tests/auth/test_real_chrome.py +++ b/tests/auth/test_real_chrome.py @@ -6,22 +6,118 @@ * 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: Any = None, + page_closed: bool = False, + 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) + # 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]]: + 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 +215,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 +244,501 @@ 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_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. + + 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) + # `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, + 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" + ), + ) + 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, + 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()), + 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 + 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/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 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..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 @@ -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.** `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. **UiAutomationTransport (UI Mimicry)**: @@ -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 cca93a16..95e4647a 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 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` -**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/DEBUGGING.md b/website/docs/DEBUGGING.md index 5bc50f6d..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) | @@ -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/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/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 ;; diff --git a/website/docs/USER_GUIDE.md b/website/docs/USER_GUIDE.md index 26e1fc9b..8f816f0a 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,9 +750,15 @@ 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**. -4. `gflow auth login` probes the profile with `channel="chrome"`, verifies SAPISID is - present, and writes `.gflow_browser_strategy = "chrome"` to the profile directory. +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` 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 ddcd62a3..047ab026 100644 --- a/website/docs/onboarding-mockup.html +++ b/website/docs/onboarding-mockup.html @@ -513,9 +513,9 @@

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.

+

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.

@@ -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
@@ -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 e2ddd29d..f0b1f97c 100644 --- a/website/docs/onboarding.md +++ b/website/docs/onboarding.md @@ -42,13 +42,13 @@ 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 ``` -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. @@ -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: