Skip to content

feat(auth): close the sign-in browser when the sign-in is done - #767

Merged
ffroliva merged 12 commits into
developfrom
chore/spike-playwright-chrome-login
Sep 9, 2026
Merged

feat(auth): close the sign-in browser when the sign-in is done#767
ffroliva merged 12 commits into
developfrom
chore/spike-playwright-chrome-login

Conversation

@ffroliva

@ffroliva ffroliva commented Sep 8, 2026

Copy link
Copy Markdown
Owner

Summary

gflow auth login told you to close the Chrome window yourself, because RealChromeStrategy launched Chrome as a bare subprocess with no debugging port and had no completion signal. It now owns the browser through Playwright and closes it for you.

Evidence — measured 2026-09-08, three human-driven arms, fresh profiles, $0

arm browser stealth flags navigator.webdriver result
bare real Chrome no True BLOCKED at /v3/signin/rejected, 17.5 s
stealth real Chrome yes False signed in, closed itself, 59.4 s
bundled bundled Chromium yes False signed in, closed itself, 276.0 s

The discriminator is navigator.webdriver, not the browser binary. Google rejects a browser that advertises automation. The bare control is what makes this conclusive — without it, two passes would have read as "the G12 block is gone" and justified dropping the mitigations. It fires, in 17.5 s.

What changed

  • Playwright-owned real Chrome (channel="chrome") is the default; polls the Flow session endpoint from the owned context and closes on AUTHENTICATED — not on a cookie name, which can be present while the endpoint still rejects.
  • Closing the window yourself still works and routes to the same on-disk verification. It never errors.
  • The bare-subprocess flow is retained as an automatic fallback — no flag, nothing to choose — on headless, channel_unavailable, launch_failed, browser_rejected. Not optional: channel="chrome" resolves only to Google Chrome while the auth gate deliberately accepts plain Chromium, so without it Chromium-only Linux loses onboarding.
  • internal_chromium.py gains the same flags. It had none — the configuration measured as blocked — and a test asserted the flag must be absent.
  • CHROME_BINARY no longer makes the Playwright chrome channel look resolvable; Playwright honours a custom binary via executable_path=, never channel=.

Council review (5 predict personas + 4 review dimensions)

Security/auth 🟢 · over-engineering 🟢 · tests 🟢 (verdicts earned by runtime mutation, not assertion). Correctness found a real bug and docs found a blocker; both fixed in 7805201e:

  • playwright.async_api.TimeoutError subclasses Error, so except PlaywrightError: break treated a request timeout as "the user closed it" — closing Chrome mid-sign-in on a network blip and reporting exit 8.
  • docs/ARCHITECTURE.md described an add_init_script auth stealth mechanism that has never existed — the same defect class this PR fixes, in a file it edited.

Not verified — why this is a DRAFT

No live run has exercised the shipped path yet. The spike proved a spike script's poll-and-close; _login_owned_browser, the bounded teardown and the _verify_and_record handoff have not run against a real Chrome. One 600 s attempt timed out un-driven — it proved launch, copy and clean teardown (no orphaned Chrome), not detection.

Required before this leaves draft:

  1. gflow auth login → window closes itself
  2. same, closing the window by hand mid-sign-in → verifies normally, no exit 12

No e2e test can cover this: all 39 e2e tests consume an already-authenticated profile, and sign-in needs a human at a Google password prompt.

Evidence is N=1 — one account, one Windows host, one IP, one Chrome build, one day, all arms headed. Shipping as default rather than opt-in is a maintainer decision taken against two personas' advice, recorded in the plan with the follow-up measurements owed.

Merge order

After #764 (fixes #763, ready now). Only CHANGELOG.md collides.

Test plan

  • 224 passed scoped (auth, browser_manager, features, MCP parity, errors)
  • ruff check/format, hygiene, doc-links, PII, website mirror, council-memory — all green
  • pyright 86 = 86 against untouched-checkout control (0 new)
  • mutation A/B on the teardown-order, transient-failure, headless-routing and raise_on_close guards
  • live-verify run 1 — auto-close
  • live-verify run 2 — manual close is not an error

Refs #480

`gflow auth login --browser chrome` asks the user to close Chrome by hand because
`RealChromeStrategy` runs it as a bare subprocess with no debugging port, so there is
no signal channel. Sibling `notebooklm-py` auto-closes by owning the browser instead.
Whether gflow's zero-automation-surface constraint is still load-bearing at sign-in
had never been measured.

Three arms, human-driven, each on a fresh unauthenticated throwaway profile, $0:

  bare     real Chrome, no stealth flags   webdriver=True   BLOCKED at /v3/signin/rejected (17.5s)
  stealth  real Chrome + stealth flags     webdriver=False  PASS, cookie at 59.4s
  bundled  bundled Chromium + same flags   webdriver=False  PASS, cookie at 276.0s

`bare` is the control that makes this conclusive. Without it two passes would have read
as "the block no longer fires", which would have justified dropping the mitigations. It
fires. The discriminator is not the browser binary — bundled Chromium, the browser
KNOWN_ISSUES names as rejected, passed with flags, while real Chrome was blocked without
them. A Playwright connection is fine; an advertised one is not.

Both passing arms detected the session from the owned context and closed the window
themselves, so auto-close is reachable.

Also records a doc defect: KNOWN_ISSUES describes RealChromeStrategy as driving Chrome
via Playwright `channel="chrome"`, which has never been true — real_chrome.py was born
as passive capture at eb0de13 and `git log -S'channel="chrome"' -- src/gflow_cli/auth/`
returns zero commits.

Not measured: headless (every arm was headed), generation (reCAPTCHA-gated, separate),
which flag does the work, and anything beyond N=1 account/machine/IP/day.

Refs #480
`gflow auth login` told you to close the Chrome window yourself, because
`RealChromeStrategy` launched Chrome as a bare subprocess with no debugging port
and so had no way to know when you were finished. It now owns the browser through
Playwright and closes it for you.

Measured 2026-09-08 (docs/superpowers/spikes/2026-09-08-g12-blocks-webdriver-not-playwright.md),
three human-driven arms on fresh profiles:

  real Chrome, no stealth flags   webdriver=True   BLOCKED at /v3/signin/rejected, 17.5s
  real Chrome + stealth flags     webdriver=False  signed in, closed itself at 59.4s
  bundled Chromium + same flags   webdriver=False  signed in, closed itself at 276.0s

The discriminator is `navigator.webdriver`, not the browser binary. Google rejects a
browser that advertises automation, not an automated browser — so owning the context
is fine as long as the advertisement is suppressed.

What this changes:

- `real_chrome.py` drives Chrome via Playwright `channel="chrome"` by default, polls
  the Flow session endpoint from the context it owns, and closes when the session is
  AUTHENTICATED. Not on a cookie name: the cookie can be present while the endpoint
  still rejects, and `verify_flow_profile` reads the on-disk store, which Chrome
  batches. The on-disk probe still runs after close, unchanged, as the durability check.
- Closing the window yourself is still a first-class way to finish. It routes to the
  same verification and never raises — three releases of docs told users to do that.
- The bare-subprocess flow is retained and runs automatically when no Chrome channel
  resolves, when the launch fails, or when Google rejects the browser. There is no flag
  and nothing to choose. `channel="chrome"` resolves only to Google Chrome, while the
  auth gate deliberately accepts plain Chromium, so without this a Chromium-only Linux
  user who can onboard today would have been locked out.
- `headless=True` routes to the subprocess path: every spike arm was headed, so a
  headless Playwright sign-in is unmeasured against Google's gate.
- `internal_chromium.py` gains the same flags. It had none — which is the exact
  configuration measured as BLOCKED — and a test asserted the flag must be *absent*.
  Its emulated 1920x1080 viewport is replaced by a real window: an explicit viewport
  is emulated independently of the OS window and pushed Google's sign-in form
  off-screen on scaled displays, with no zoom recovery.
- `CHROME_BINARY` no longer makes the Playwright chrome channel look resolvable.
  Playwright honours a custom binary via `executable_path=`, never via `channel=`, so
  that path handed Playwright a channel it could not launch.

Teardown goes through `close_context_bounded` + `run_teardown_step` in pw -> lease
order, so a cancelled login still stops the driver and releases the lease. The two
cancellation guards that were fixed twice before are kept for the subprocess path and
mirrored for the Playwright path as order assertions.

Not verified: no live run of either path (sign-in needs a human at a Google password
prompt — no e2e test can cover it, and all 39 e2e tests consume an already
authenticated profile); the browser-rejected fallback is exercised by simulation only.
Evidence is N=1 — one account, one Windows host, one IP, one Chrome build, one day —
and shipping this as the default rather than opt-in is a maintainer decision recorded
in the plan, taken against two personas' advice. The follow-up measurements that would
close that gap are listed there.

Refs #480
Council review of 8172fb0 across five dimensions. Security (D3/D10) and
over-engineering (D14) came back green; correctness found one real bug and docs
found one blocker.

**The bug.** `poll_session_until_authenticated` treated every `PlaywrightError` as
"the browser is gone" and stopped polling. But `playwright.async_api.TimeoutError`
subclasses `Error`, so a 15 s request timeout, a DNS hiccup or a Wi-Fi
reassociation arrived on the same arm. On the owned-browser path that meant gflow
closed Chrome out from under a user still on Google's password screen, logged
`auth_login_browser_closed_by_user`, and reported exit 8 "No sign-in detected" for
a sign-in that had not failed. It now asks the page whether it is actually closed;
everything else retries until the deadline. HTTP-level failures never reached this
arm at all — they come back as VERIFICATION_ERROR and keep polling — so only
transport errors were ever conflated.

`test_manual_close_is_not_an_error` had to change with it: it described a browser
that raised on every request while insisting it was still open, which is a state
Chrome cannot be in. The fake now closes the page, because that is what a closed
browser does.

**The doc blocker.** `docs/ARCHITECTURE.md` said the auth path bypasses detection
with "a JS `add_init_script` that overrides `navigator.webdriver`". That code has
never existed in `auth/` — only in the two generation paths. It sat fifteen lines
above the paragraph 8172fb0 rewrote, which means that commit fixed one instance of
"documentation describing an implementation nobody built" while leaving another
untouched in the same file.

Also corrected, all same class:

- `AuthBrowserRejectedError`'s remediation told users to install Chrome and rerun
  with `--browser chrome`. The spike disproved exactly that: bundled Chromium
  signed in fine with the flags, real Chrome was rejected without them. Exit 14 is
  now reachable only from the internal strategy, so the advice was both wrong and
  aimed at the one path it cannot help. A test asserted the old string, so the
  wrong guidance could not be fixed without a test failing to defend it.
- `README.md`, `AGENTS.md`, `docs/DEBUGGING.md`, `docs/AUTHENTICATION.md` and two
  `website/docs/` pages still stated the retired "Google rejects bundled Chromium"
  claim; two of those website pages contradicted, in an adjacent unedited sentence,
  what 8172fb0 had just written above them.
- `client.py`'s Chrome-not-found hint still offered `CHROME_BINARY`, which no longer
  helps: Playwright honours a custom binary via `executable_path=`, never `channel=`.
- The CHANGELOG attributed the 17.5 s rejection to bundled Chromium. That number is
  the `bare` arm — real Chrome with the flags removed. Bundled-without-flags was
  never run, and the entry now says so.

**Tests.** `raise_on_close`'s default was unpinned: flipping it left the suite green
while turning a failed internal login into a reported success with no
`.gflow_account`. Both sides are now pinned, and the guard was A/B-verified by
mutation, as was the transient-failure fix and the headless routing guard.

224 passed; ruff, mirror, doc-links, PII, hygiene and council-memory gates green;
pyright 86 = 86 against an untouched-checkout control.
`/fx/api/auth/session` is a NextAuth route that can rotate session cookies,
and the poll was hitting it every 3 s for the whole login — including while
Google held the page for the OAuth callback. Observed live 2026-09-08: the
sign-in landed on `labs.google/fx/api/auth/signin?error=OAuthCallback` and
then timed out at 600 s.

The spike that signed in successfully twice never made this request at all:
it read the cookie jar locally over CDP and issued no HTTP during sign-in.
Waiting until the page is back on the Flow host restores that property.

Also adds a `probe=` discriminator to `auth_login_session_detected`.
RealChromeStrategy runs both oracles — the in-context live probe decides when
to close, then verify_flow_profile re-checks what landed on disk — and the
live run emitted two identical events, leaving "did the on-disk check pass?"
unanswerable from the log.
The poll guard added in the previous commit gated on `labs.google` alone. The
labs app `location.replace`s a migrated account onto flow.google.com right
after the callback returns — server-decided per account, one-way — so the
guard would go False on the redirect and never come back, reproducing the
exact 600 s timeout it was written to fix, on every migrated account.

Both maintainer accounts are migrated, so every live-verify run would have hit
this. Neither host is an OAuth handshake host, which is all the guard needs to
exclude, so admitting both preserves its intent.

A/B control: with the fix the class is 3 passed; gated on labs alone,
test_migrated_host_still_polls fails with AuthLoginTimeoutError.
`test_never_logs_a_google_url` parks the page on a secret-bearing Google URL
and asserts the poll logs no page URL. With the new host guard the page never
left accounts.google.com, so the poll skipped every iteration and the test hung
until the 600 s timeout — it was the last test in the file and took the whole
suite with it.

The page now arrives on the Flow host while the poll is waiting, which is what
a real sign-in does. The assertion is unchanged and its intent is stronger: the
secret-bearing URL was genuinely visited, and still reaches no log event.

18 passed in 0.32 s, down from a 600 s hang.
Three strategy tests built a page with `goto` and `request.get` mocked but no
`url`, so the attribute stayed a bare MagicMock. That is not a str, the poll's
new host guard read it as "still mid-OAuth", and the loop spun against a
patched `asyncio.sleep` — which records every call, so the run climbed to
14.7 GB before the OS killed it.

The doubles now say what the runtime does: the strategy has just navigated to
GEMINI_URL, so the page is on the Flow host. 20 passed in 2.11 s.
@ffroliva
ffroliva force-pushed the chore/spike-playwright-chrome-login branch from 7805201 to df9b516 Compare September 9, 2026 08:22
@ffroliva

ffroliva commented Sep 9, 2026

Copy link
Copy Markdown
Owner Author

Live-verified 2026-09-09 — all three required runs pass

Rebased onto develop (CHANGELOG was the only conflict) and live-verified. The three runs this PR was blocked on are done, plus two fixes the runs forced.

Host/profile named per [[flow-capabilities-are-cohort-dependent]]: Windows 11, real Chrome 149, throwaway profile autoclose-verify, account ffroliva@gmail.com (migrated, flow.google.com). Profile deleted afterwards — it held a real Google session.

(a) Auto-close fires end to end — PASS

auth_login_started                              08:12:03
auth_flow_session_verified  probe=in_context    08:13:30
auth_login_session_detected elapsed_s=84.8
"Signed in. Closing Chrome..."
auth_flow_session_verified  probe=on_disk       08:13:32
[OK] Flow session verified (ffroliva@gmail.com)   exit 0

elapsed_s=84.8 is a real human sign-in. The first attempt at this run was discarded and does not count: profile_autoclose-verify had survived the 2026-09-08 session, so it carried a live Google session and "verified" in 0.2 s with nobody signing in. It would have read as a pass and proved nothing. Deleted the profile, re-ran from empty.

Reproduced three times (84.8 s, 63.7 s, 51.6 s).

(b) Manual close verifies normally, never exit 12 — PASS

auth_login_browser_closed_by_user   strategy=chrome    08:21:24
auth_flow_session_verified          probe=on_disk      08:21:24
[OK] Flow session verified (ffroliva@gmail.com)          exit 0

Note the absence of probe=in_context — auto-close never ran, so this is genuinely the manual-close branch and not (a) wearing a disguise.

This took four attempts and an instrument to get honestly. Attempts 1–3 all came back exit 0 and all three were not (b): sign-in takes ~50 s, detection fires the moment Flow loads, and auto-close won every race — the logs were shape-identical to (a). Widening POLL_INTERVAL_SECONDS to 120 for one run makes it deterministic rather than a race: the only polls are at t=0 (before sign-in) and t=120 (after the window is already closed), so auto-close cannot fire. Only the sleep cadence changed; the close-detection and fallback-verify logic under test is untouched. Instrument reverted (POLL_INTERVAL_SECONDS = 3), tree clean, 121 auth tests green after the revert.

(c) --browser auto still selects and completes — PASS

--browser autostrategy=chrome, launched, detected, auto-closed, exit 0. Scope honestly: this ran against an already-authenticated profile (0.3 s) and took the Playwright path, so it did not exercise the subprocess fallback live. That fallback stays covered by unit tests only — test_no_chrome_channel_falls_back_to_subprocess, test_launch_failure_falls_back_to_subprocess, test_google_rejection_falls_back_to_subprocess_once.

Two defects the live runs forced — both fixed here

1. The poll was breaking its own OAuth callback. This is why the 2026-09-08 run timed out at 600 s. /fx/api/auth/session is a NextAuth route that can rotate session cookies, and the poll hit it every 3 s for the whole login — including while Google held the page for the callback — landing on signin?error=OAuthCallback. The spike that signed in successfully twice never made that request at all: it read the jar locally over CDP. The poll now waits until the page is back on a Flow host. Run (a) completing is the proof.

That was recorded in my handoff as "timed out un-driven" — i.e. blamed on nobody being at the keyboard. It was a real code defect.

2. The host guard gated on labs.google alone. The labs app location.replaces a migrated account onto flow.google.com right after the callback, so the guard would go False on the redirect and never come back — reproducing the exact 600 s timeout it was written to fix, on every migrated account. Both maintainer accounts are migrated, so every live run would have hit it. A/B: with the fix the class is 3 passed; gated on labs alone, test_migrated_host_still_polls fails with AuthLoginTimeoutError.

Two test defects the same guard exposed

  • Three strategy doubles built a page with no url, leaving it a bare MagicMock. Not a str → guard reads "still mid-OAuth" → the loop spun against a patched asyncio.sleep, which records every call. The run climbed to 14.7 GB before the OS killed it. Fixed by giving the doubles the URL the runtime actually has.
  • test_never_logs_a_google_url parks the page on a secret-bearing Google URL by design, so it hung until the 600 s timeout and took the whole file with it. The page now arrives on the Flow host while the poll waits, which is what a real sign-in does. Assertion unchanged, intent stronger: the secret-bearing URL is genuinely visited and still reaches no log event. 18 passed in 0.32 s, down from a 600 s hang.

Gates at df9b5167

ruff check · ruff format --check · check_repo_hygiene · check_doc_links · generate_website_docs --check · check_council_memory all green. pyright src at the develop baseline (86 pre-existing local env errors, unchanged by this branch — verified by running the same command on untouched develop). tests/auth/ + tests/test_browser_manager.py: 121 passed.

No page URL reached any log event in any of the six live runs.

…he deadline

The host guard `continue`s without touching Playwright, so while the page sits
on accounts.google.com nothing ever raises — and `page.is_closed()` was only
consulted inside `except PlaywrightError`. A user who abandons a 2FA challenge
closes the window on Google's host, which is exactly where that reactive
detection cannot fire.

Measured before this change: a full run to the deadline with the session
endpoint touched 0 times. On the default 600 s timeout that is a ten-minute
wait ending in exit 12, for someone who closed the window after thirty seconds.

Checking liveness first costs one call per poll and ends the run immediately
with `auth_login_browser_closed_by_user`, which routes to the on-disk verify
like any other manual close.

Introduced by the host guard two commits back, so it never shipped. Found by
asking what happens when the two timing layers — how long the user takes, and
which host they are on while taking it — interact.

Three strategy doubles needed an explicit `is_closed = False`: a bare MagicMock
attribute is truthy, so the new check read "already closed" and broke before
doing anything under test.
…g CI

The passing path returns instantly, so the deadline in this test only matters
when the liveness check regresses — and at 600 s that is a ten-minute CI hang
rather than a red test. Verified by neutering the check: 5 s gives
`1 failed in 5.30s`; 600 s gave a hang that had to be killed by hand.
@ffroliva

ffroliva commented Sep 9, 2026

Copy link
Copy Markdown
Owner Author

A third defect, found by asking how the two timing layers interact

The maintainer asked a design question after the live runs: does the browser close on a timeout, or by monitoring the process? The answer is neither — it closes on application state (FlowSessionOutcome.AUTHENTICATED from the session endpoint, polled every 3 s), with the timeout as a pure give-up bound that never closes on success. That is the right design, because it makes the close independent of how long the user takes: passwordless or a slow SMS 2FA challenge changes nothing about the mechanism.

But the follow-up — which host is the user on while taking that time — exposed a real bug, now fixed in 449a72c4.

page.is_closed() was only reachable through except PlaywrightError

The host guard continues without touching Playwright. So while the page sits on accounts.google.com, no operation runs, nothing raises, and the reactive close-detection never fires. A user who abandons a 2FA challenge closes the window on exactly that host.

Measured before the fix, closing mid-2FA:

RESULT: AuthLoginTimeoutError after 8.00s -> ran to the FULL deadline
session endpoint touched: 0 times

On the default 600 s timeout that is a ten-minute wait ending in exit 12 for someone who closed the window after thirty seconds.

After (same scenario):

auth_login_browser_closed_by_user strategy=chrome
RESULT: returned None after 0.00s

The fix is one proactive liveness check ahead of the host guard; the close then routes to the on-disk verify like any other manual close.

This was introduced by the host guard two commits earlier, so it never shipped — but note that live run (b) did not catch it. (b) closes the window after signing in, so the page is on the Flow host, ctx.cookies() runs and raises, and the reactive path works. The broken case is only reachable by closing during 2FA. A passing live run is scoped to the state it was run in.

Fixture note

Three strategy doubles needed an explicit is_closed = MagicMock(return_value=False). A bare MagicMock attribute is truthy, so the new check read "already closed" and broke before doing anything under test — the same trap _build_fake_playwright already carries a comment about.

Test deadline

7c6aee78: the regression test uses a 5 s deadline, not 600 s. The passing path returns instantly, so the value only matters once this regresses — and at 600 s that is a CI hang rather than a red test. Verified by neutering the check: 5 s gives 1 failed in 5.30s; 600 s gave a hang I had to kill by hand.

A/B control (neutered vs restored, source restored from the commit, tree verified clean):

arm result
with fix 1 passed in 0.11s
liveness check neutered 1 failed in 5.30sAuthLoginTimeoutError

Gates at 7ee912c0: 122 passed, ruff check / ruff format --check / hygiene / doc-links / website-mirror all green.

Still open, honestly

The 2FA case is verified by unit test and by a direct probe, not by a live run — closing a real window mid-challenge was not re-run against Google. And auth_login_timeout remains 600 s by default, which is a real ceiling for a slow 2FA flow (an authenticator on another device, an SMS that does not arrive, a recovery challenge). It is configurable to 86400 via GFLOW_CLI_AUTH_LOGIN_TIMEOUT, but the default is worth revisiting separately.

@ffroliva

ffroliva commented Sep 9, 2026

Copy link
Copy Markdown
Owner Author

The 2FA-abandon case is now live-verified too

The previous comment listed this as verified by unit test and probe but not by a live run. It is now.

Deterministic method, because hand-timing it does not work on this machine: launch gflow auth login against a fresh profile, then kill only the Chrome processes whose --user-data-dir is that test profile, five seconds in — well before sign-in can complete.

killed chrome                        09:59:39
auth_login_browser_closed_by_user    09:59:41   <- 2 s later
EXIT=8  ELAPSED_S=15
  • Detection latency 2 s after the window died, with the page still on accounts.google.com — the exact state where the reactive except PlaywrightError path is blind.
  • Exit 8 (AuthMissingError, "no sign-in detected") — the honest answer, not exit 12.
  • Before the liveness check, this identical run sits for the full 600 s and then reports a timeout that did not happen.

The 15 s total is launch + the deliberate 5 s wait + 2 s detect + the on-disk verify; none of it is latency in the path under test.

Why hand-timing failed, which is itself worth recording

Two attempts to close the window by hand both came back probe=in_context, exit 0, at 15.6 s and 15.8 s — near-identical on a fresh profile each time. That is not a human closing a window; on this host the account completes sign-in through a trusted-device/passkey path with no interaction, so the browser is only on Google's host for a few seconds. Any "close it quickly" instruction is a coin flip against that.

Same lesson as the (b) runs earlier in this PR: when the happy path can outrace the branch you mean to test, drive the race deterministically instead of asking a human to win it, and assert on a path-unique event rather than the exit code both paths share.

Timing layers, since this is what prompted the whole investigation

The close is triggered by application stateFlowSessionOutcome.AUTHENTICATED polled every 3 s — not by a timer and not by process monitoring. The timeout only ever gives up; it never closes on success. So how long the user takes is irrelevant to the mechanism: measured on one account in one session, 15.6 s passwordless vs 84.8 s / 63.7 s / 51.6 s interactive, all landing the same way.

Two real constraints remain:

  • auth_login_timeout defaults to 600 s and covers the whole interactive sign-in. A slow 2FA flow — authenticator on another device, an SMS that does not arrive, a recovery challenge — can exceed that, and then it is exit 12 even if the user completes a moment later. Configurable to 86400 via GFLOW_CLI_AUTH_LOGIN_TIMEOUT; the default is worth a separate look.
  • Close detection is bounded by POLL_INTERVAL_SECONDS (3), so a close is noticed within ~3 s rather than instantly. That is the measured 2 s above and is fine.

This branch rewrote `AuthBrowserRejectedError`'s remediation because the
2026-09-08 spike refuted its premise: bundled Chromium signed in normally WITH
the stealth flags, while real Chrome WITHOUT them was rejected at
/v3/signin/rejected in 17.5 s. The discriminator is `navigator.webdriver`, not
the browser binary.

The test still asserted the old strings — "--browser chrome" and
"GFLOW_CLI_AUTH_BROWSER=chrome" — so it was pinning advice that would send a
user to swap browsers over a setting. A test protecting a defect, which is the
fourth instance of that pattern on this branch.

The assertions are inverted deliberately: the disproved advice must not come
back, and the message must name the real discriminator.

Missed locally because my runs were scoped to tests/auth/ and
tests/test_browser_manager.py; CI runs the whole suite. It also took SonarCloud
down with it — the coverage artifact is produced by the test job, so the gate
had nothing to analyse.
Council review of the eight unreviewed commits on this branch returned RED, and
the headline finding refutes what the previous commit's comment claimed.

**The host guard never protected the OAuth callback.** NextAuth runs its
callback on the app's OWN origin, so a labs.google host test passes straight
through it. Verified:

    _is_on_flow_host("https://labs.google/fx/api/auth/callback/google?state=S&code=C")
    -> True
    _is_on_flow_host("https://labs.google/fx/api/auth/signin?error=OAuthCallback")
    -> True

That second URL is the exact failure the comment cited as observed. Excluding
only accounts.google.com excluded the one phase where the callback is NOT
running. The gate now also excludes NextAuth's own auth routes, and the comment
no longer claims more than it does.

Three further fixes fall out of reusing the existing classifier:

- `urlparse("https://[bad").hostname` raises ValueError. It escaped the helper
  into the loop's catch-all `except Exception: break`, which under the
  InternalChromium default reports "Browser closed before the Flow editor
  sign-in was verified" for a browser that is open.
- The helper was a FOURTH copy of the Flow-host set. `flow_host_kind`
  (api/transports/_common.py) is strictly stronger: https required, exact host
  rather than substring, ValueError-safe, non-str-safe.
- Its import must be deferred — a module-level one cycles through
  profile_store back into gflow_cli.auth. The review asserted there was no
  cycle; there is, and the import failed immediately.

Also: `POLL_INTERVAL_SECONDS` governed only ONE of the loop's two sleeps, the
other being a literal 3. That is why widening it during live-verify did not do
what I said it did — the main poll path never changed cadence. Both sites now
use the constant.

Two tests polled with timeout_seconds=30, so a regression would spin 30 s and
~1.1 GB rather than failing fast. Now 5, matching the sibling test.

Docs: three exit-14 remediation sites still prescribed `--browser chrome`,
which the 2026-09-08 spike disproved and which contradicted the test added in
0462c73. The reCAPTCHA-on-generation mention of `--browser chrome` in
DEBUGGING.md is a different surface the spike says nothing about, and stays.

New tests pin the route gate across both cohorts, both auth routes, Google's
host, a malformed URL and a bare mock. Full suite with coverage, as CI runs it:
4146 passed.
@ffroliva
ffroliva marked this pull request as ready for review September 9, 2026 11:17
@ffroliva
ffroliva merged commit 0912eea into develop Sep 9, 2026
16 checks passed
@ffroliva
ffroliva deleted the chore/spike-playwright-chrome-login branch September 9, 2026 11:18
@ffroliva ffroliva mentioned this pull request Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Post-migration hop lands on Google account chooser with signed-out row; CLI stalls instead of auto-selecting recorded account

1 participant