Repository navigation
fix(auth): autoselect recorded account at post-migration chooser - #764
Conversation
|
Thanks for the contribution. This PR has been marked as an external contribution and routed for maintainer review. Before merge, please make sure:
GitHub Copilot code review may be requested as an advisory first pass, but maintainer approval is still required. |
|
🤖 PR-Triage Autopilot Verdict: UNKNOWN A late-arriving sub-agent (spawned internally by one of the council's skill invocations) surfaced two genuinely new findings I hadn't already captured — worth appending to the verdict already delivered. Addendum to PR #764 verdict (2 new must-fix items — count now 12)
Both are corroborated by direct code citation ( Updated line: Consensus remains 🔴 RED — this doesn't change the verdict, just deepens the case against merging as-is. |
Council Review Verdict — 🔴 REDReviewed at Thanks for this — the problem is real, the issue analysis is right, and
Must-fixA. Broken documentation1. The exit-code table is corrupted in both USAGE mirrors. This destroys the Both mirrors are identically broken, which is why B. The feature does not behave as documented2. Substring matching can select the wrong account — with billing consequences. Nothing re-verifies identity after the click on the generation path, so every subsequent run silently generates and bills against the wrong Google account. Suggest matching exactly ( 3. The probe runs before the redirect settles, so it may never fire. 4. The click has no post-condition, and the documented exit-38 case cannot occur.
Relatedly: if 5. 6. A mismatch leaves a fully-authenticated wrong-account profile on disk. So 7. The remediation hint points at a flag that cannot help. 8. An apostrophe in the email throws a raw Playwright error. 9. Google's bot-rejection URL matches C. Evidence10. No e2e test, and the unit tests assert the mock rather than the behaviour. More importantly, the production callsite is untested. The only two tests reaching Your stated blocker for the click-through branch ("the recorded account was already signed in at proof time") is fair, specific, and exactly the kind of thing the contributor exemption is for. It doesn't cover the absence of any e2e file, though — including one your own signed-in account could run, asserting bootstrap still completes with the new callsite present. Suggested home: 11. 12. A selector-cascade miss now raises 38 with no drift detail, and no record of the carve-out. Related, and worth stating plainly: Nice-to-have
Checked and found fine — no action neededRecording these so you don't spend time on them:
Note on CIAll 14 other checks are green, but What would close this out
Items 1, 3, 4 and the nice-to-haves are quick. Items 2 and 5 are the substantive ones. Happy to re-run the council once you've pushed. |
|
🤖 PR-Triage Autopilot Verdict: UNKNOWN PR #764 — Council Review Verdict (Autonomous Mode)Reviewed at Methodology note: This PR already carries a complete, citation-backed council review posted to the thread at Consensus: 🔴 RED
Must-fix (11 remaining of the original 12 — confirmed still present at
|
|
All 11 open must-fix items addressed in Item-by-item:
Nice-to-haves, all done: dup ordering test deleted; Not changed per your "confirmed-good": MCP parity (untouched), CHANGELOG/AGENTS (already accurate), SonarCloud (repo-side). One honest scoping note: the live click-through branch itself is still test-only — the recorded account was signed in at proof time, so |
Council review — consensus 🔴 RED (reviewed at
|
|
I've pushed the blocker fix to this branch directly (maintainer edits are enabled) so it isn't sitting on you — two commits,
|
| result | |
|---|---|
| with the fix | 4 passed in 35.71s |
against the original client.py |
3 failed, 1 passed |
and the failure is the predicted one, verbatim:
E waiting for navigation to "**/project/**" until 'load'
playwright/_impl/_frame.py:264: TimeoutError
I also updated the two unit tests that encoded the false contract — the happy path now pins return_value=None and asserts the predicate accepts both cohorts while rejecting the chooser, and the failure path drives a real PlaywrightTimeoutError side effect and asserts the cause is chained rather than swallowed.
Local gates at 970d9aef: ruff check clean, ruff format clean, pyright src at baseline, tests/api/test_bootstrap_chooser.py + tests/auth/test_account_autoselect.py 9 passed, the new e2e 4 passed.
Still open from the review
Nothing here touches the other must-fixes — the substring host test at client.py:803, the duplicated GOOGLE_REJECTED_BROWSER_ROUTE literal, the docs/USAGE.md:1790 remediation that recommends a flag which cannot select a row, the remediation/detail contradiction on the two raise sites, or the email-in-logs redaction gap. The PR body still says exit 33 as well.
And the live gap is unchanged: this proves the mechanism, not that [data-email] is still the live chooser's anchor. That needs a signed-out account on a migrated profile, which the presentation-reels-google evidence isn't. I'm happy to run that side.
|
🤖 PR-Triage Autopilot Verdict: UNKNOWN PR #764 — Council Review Verdict (Autonomous Mode)Reviewed at Methodology note. This PR already carries three prior full/partial council rounds in-thread (RED at Sandbox constraint (read this before trusting D0/D4 below). This session's tool permissions allow Consensus: 🔴 RED
Must-fix (9)
Nice-to-have (2)
Confirmed-good
False positivesNone — every claim above was verified against Memory actions (D5) — reported only, not applied
Note on CI
Next step — live validation. Not reached. This verdict is RED on both mechanical (D0) and multiple LLM dimensions; live validation is not the next step until must-fix items 1–8 are resolved and CI is confirmed green from an actual log (not a rollup status), which requires tooling outside this sandbox. |
|
🤖 PR-Triage Autopilot Verdict: UNKNOWN PR #764 — Council Review Verdict (Autonomous Mode)Reviewed at Methodology note. This is round 5 of council review on this PR. Four prior rounds are posted in-thread (RED→RED→RED→RED across Consensus: 🔴 RED
Must-fix (6)
Nice-to-have (5)
Confirmed-good
False positivesNone — every claim above (mine and all 5 dispatched agents') was independently verified via direct file reads at Memory actions (D5) — reported only, not applied
Note on CI
Next step — live validation. Not reached. This verdict is RED on 3 LLM dimensions (D1, D8, D9); must-fix items 1–6 need resolving before another round. Items 2, 3, 4 are quick (a stale string in 3 places + a comment fix); items 1, 5, 6 are the substantive ones (an exact-host check, an email-redaction pattern, and a locale-read reorder or guard). |
Signed-off-by: stgmt <stigmat.rudnev@gmail.com>
33 is already claimed by the gflow doctor verdict (a successful diagnosis, not an error class) in EXIT_CODE_MAP documentation; the new typed chooser error must not collide. 38 is free and sits adjacent to the UI-cluster codes. Downstream presentation-reels client keys on class/38/retryable=false. Signed-off-by: stgmt <stigmat.rudnev@gmail.com>
The previous commit split the 37 row and dropped the 38 row from both docs copies; the table now carries the full 37 row followed by the new 38 row (FlowAccountChooserError). Signed-off-by: stgmt <stigmat.rudnev@gmail.com>
Exact data-email row match plus exact-text fallback (a substring superset can no longer win and bill the wrong account); chooser check moved after the locale settle; click-through verified by wait_for_url to the editor; bot-rejection hop excluded from the chooser heuristic; --account raises when the identity is unreadable and warns what the profile now holds on mismatch; remediation names only gflow auth login; chooser wired into diagnostics capture triggers; exit-38 carve-out recorded in the selector memory; e2e_auth bootstrap test added. Signed-off-by: stgmt <stigmat.rudnev@gmail.com>
…iable `Page.wait_for_url` is annotated `-> None`: it returns nothing and signals a miss by raising. So `(await page.wait_for_url(...) or "")` was always `""`, `"project" not in ""` was always True, and every SUCCESSFUL chooser click raised FlowAccountChooserError. The success path — the logger.info and `return True` beneath it — was unreachable in production, and a real stall raised an uncaught Playwright TimeoutError, surfacing as the same opaque exit 1 that ffroliva#763 is about. The landing predicate also changes. `**/project/**` cannot match the bootstrap URL `labs.google/fx/tools/flow`, and only the migrated origin serves /project/<id>, so the glob encoded one cohort's shape as universal. `flow_host_kind` is the codebase's exact-host classifier and answers for both cohorts; a substring test would match any URL merely carrying the host in a ?continue= parameter. The happy-path test asserted `AsyncMock(return_value=".../project/p1")` — a value Playwright cannot produce — which is why CI stayed green over an inert fix. It now pins the real contract: wait_for_url returns None, raises on a miss, and the predicate accepts both Flow cohorts while rejecting the chooser itself.
… page The defect the previous commit fixes shipped through a green unit suite because that suite mocked `page.wait_for_url` as returning a URL string — a contract Playwright does not have. A mock cannot falsify a belief about the mocked thing, so this adds tests that drive a real Chromium: real locator engine, real click, real navigation, real wait_for_url. Zero cost and no Google account: the chooser and both Flow landings are served by route interception, which also makes it deterministic — a real signed-out chooser cannot be staged on demand. Covers the branch production actually uses (the .gflow_account read, which every unit test bypasses by passing the address in), both host cohorts, the real-timeout failure path, and the wrong-account hazard that motivates the exact [data-email=] match. A/B: 4 passed against the fix; 3 failed against the original client.py, the click-through cases dying on an uncaught Playwright TimeoutError — `waiting for navigation to "**/project/**"` — which is the ffroliva#763 symptom. Also types `_handle_account_chooser(page: Page)`. It was `page: Any`, which is what let the inverted check past `pyright src` in the first place; with a real type the landing predicate's parameter is known and the gate has something to check.
`page: Any` erased `wait_for_url`'s signature, so the landing predicate's parameter was unknown and `pyright src` failed with reportUnknownLambdaType — the same looseness that let the original inverted check through the gate. This was claimed in 970d9ae and was not in it: an A/B control there restored client.py from HEAD, which at that moment predated the edit, so the change was silently reverted before the commit. CI caught it on all three Python versions.
Exact-host chooser gate via urlsplit (substring ?continue= can no longer misfire); rejected-browser route imported from its single source instead of an inline literal; USAGE row 38 names only gflow auth login; memory carve-out corrected to match the code (no bot-rejection inclusion, no URL-kind claim); account addresses get their own <redacted:email> pattern in the redaction module with call sites wired; session locale re-read from the editor after click-through; account_email test seam removed; --account success-path test added. Signed-off-by: stgmt <stigmat.rudnev@gmail.com>
b34952c to
fbbbaf3
Compare
|
All 6 must-fix items from round 5 addressed in
Nice-to-haves: Gates at HEAD: ruff check/format, pyright 0, hygiene, doc-links, website mirror in sync, PII clean; 134 focused tests green (incl. redaction + parity suites). Live e2e_auth from the prior round still stands (4 passed, $0); the signed-out chooser click remains yours to run whenever convenient. |
The exact-host urlsplit gate raised TypeError on mocked pages (url is a MagicMock), reddening every suite that enters the client context. Mirror flow_host_kind discipline: non-string or unparseable URLs return False so a probe error never displaces the real bootstrap failure. Regression test pins it. Signed-off-by: stgmt <stigmat.rudnev@gmail.com>
The landing-timeout raise reported only "did not reach Flow within 30s" and omitted the one datum that diagnoses it: the URL the click left the session on. Its sibling raise above interpolates the chooser URL for exactly this reason. This branch fired live on 2026-09-09 against a real Google chooser on profile `ffroliva` — the row was found and clicked, the 30s wait timed out, and the error named no URL, so "a challenge needs a human", "a consent screen needs a click" and "the click never navigated" collapsed into one indistinguishable exit 38. `flow_host_kind` is a host-only match that accepts every Flow landing this codebase knows (`/about` included — verified empirically), so a timeout here is never the predicate being too narrow: the session is still on a Google surface, and which surface is the whole question. The persisted operation row stays clean — redact_error_detail's <redacted:email> pattern scrubs an address carried in a ?continue= param. Signed-off-by: Flavio Oliva <ffroliva@gmail.com>
Maintainer push:
|
`gflow auth login --account` compares with `.lower()` on both sides (cli.py: `actual_account.lower() != account.strip().lower()`), but the chooser row match did not: a CSS attribute selector is case-sensitive unless given the `i` flag, `get_by_text(..., exact=True)` is case-sensitive, and `read_account_file` normalises nothing. An address recorded in one case and rendered by Google in another therefore passed the `--account` assertion and then missed its row, raising "recorded account was not found among selectable accounts" — exit 38 telling the operator to re-login while the account sits on the chooser. That is the exact false negative this feature exists to remove. Relaxing case must not relax the anti-substring discipline, so the text fallback becomes an ANCHORED case-insensitive pattern rather than a bare one: an unanchored relaxation would match the chooser's "Remove <email>" and "Sign out of <email>" rows, and clicking those signs the operator out instead of in. Proven on a real locator engine, because CSS matching semantics are exactly what a mock cannot model: two new route-intercepted e2e cases (zero credits, no account) — one that a case variant selects its row, one that a superset row is still refused. The two unit tests that asserted the old call shape are updated, and the fallback assertion is now behavioural (matches a case variant; refuses both superset forms) rather than a comparison against call arguments, so an over-broad implementation fails it. Signed-off-by: Flavio Oliva <ffroliva@gmail.com>
Second maintainer push:
|
A review pass over this branch found six real defects, verified before acting on each. Four are in the feature's own commits; two were latent and are reached by the new code. 1. An expired session now exits 3 again, not 38. The host gate accepts EVERY accounts.google.com landing, so an email form, a password challenge or a consent interstitial — none of which offer anything to pick — reached the row lookup, found nothing and raised exit 38 claiming the recorded account was "not found among selectable accounts". That both misdirects the operator and changes an exit code callers branch on: the path previously continued to the transport's 401 and classified as AuthExpiredError (exit 3). A chooser is now identified positively, by Google's chooser path OR the presence of account rows; neither matching returns False, exactly as before the feature existed. Two signals because each covers the other's blind spot — a renamed path, or a chooser whose rows carry no data-email. 2. A chooser hop no longer demotes a learned locale. The post-click resolve discarded its from_url, so the fold below ran next_locale_state(cached, None) — which returns PROVISIONAL for a committed segment — and wrote that to disk on every chooser hop (ffroliva#643's bug class). The code logged its own bug: `client.account_locale_state now=? was=pt`. The comment above it claimed "the on-disk cache is safe"; it was not. The editor's segment is folded now. 3. FlowAccountChooserError no longer triggers incident capture. It was in both _capture_triggers() and _screenshot_triggers(), and by construction it fires only while the page is on accounts.google.com — so a bundle would carry a DOM dump and a full-page screenshot of a Google auth surface (every signed-in identity's address, name and avatar; on a sign-in form, the input and hidden-field DOM) into the artifact users are prompted to attach to GitHub issues. diagnostics.py already excludes AuthExpiredError as "deterministic operator remediation", and docs/DEBUGGING.md says so; this has the same remediation and now the same rule. 4. The <redacted:email> pattern no longer eats filesystem paths. It matched `x@y.co` inside `C:/x@y.co/path` and `/var/x@y.io/cache`, and it runs on every persisted error detail, transport snippet and worker payload — silently rewriting the one artifact left for debugging a failure. Bounded by lookaround so a match cannot start after a path separator or continue into a path segment; `?Email=me@x.com` still scrubs. It shipped with no test at all. 5. The mismatch log event says something again. redact_sensitive_text maps every address to one constant, so `required=` and `held=` were two identical tokens and no signal — while the console prints both in the clear on the next line. Replaced with held_recorded, the distinction the event exists to make. 6. .gflow_account is treated as untrusted input. Its value is interpolated into a CSS attribute selector, where a double quote closes the attribute early and makes locator.count() raise a raw Playwright parse error past every typed handler — a generic exit 1, the symptom class ffroliva#763 exists to remove. The reader also decoded UTF-8 while catching only OSError, so a damaged file broke list_profiles() for every profile. Guarded once in the shared reader, which fixes both callers and any I did not enumerate. Full suite 4168 passed / 7 skipped / 92% coverage; pyright unchanged from the develop baseline; ruff, hygiene, doc-links, website-mirror and PII gates green. Signed-off-by: Flavio Oliva <ffroliva@gmail.com>
Third maintainer push:
|
|
Merged as This went five council rounds plus a maintainer takeover, and you turned every one Worth saying plainly what your work actually fixed, because the final diff spreads it
The three maintainer commits on top are recorded above: a diagnosability fix, case Remaining known gaps are in #773 and none of them are yours: the auto-select happy path |
Found by live-verifying the previous commit instead of trusting it. Same
profile, same test, real Google, $0:
BEFORE RuntimeError: Could not find 'New project' CTA on Flow gallery.
URL: https://accounts.google.com/v3/signin/accountchooser
?client_id=...&code_challenge=...&state=PKOA6qjxDh...
AFTER FlowAccountChooserError: Google's account chooser is displayed
(https://accounts.google.com/v3/signin/accountchooser) instead of
the Flow gallery - the session needs a person to pick an account.
Not selector drift.
`flow_landing_kind` returned None for accounts.google.com by design, and a unit
test asserted it, on the reasoning "the chooser has its own handler". That is
true at BOOTSTRAP - `client._handle_account_chooser` - and false for a hop that
happens after it, which is what profile `denon82` did on 2026-09-10: it left the
`?error=Callback` state the 02:00 canary reported and moved to Google's chooser,
where `_enter_editor` swept eighteen selectors for a "+ New project" CTA on
Google's sign-in page and blamed the anchor. So the previous commit's claim to
fix the RED canary was false for the state the profile is actually in.
The classifier now answers for Google's auth host too: "chooser" for a
`/v3/signin/accountchooser` path -> FlowAccountChooserError (38, the class #763
and #764 already established for this), "signin" for its other sign-in surfaces
-> AuthExpiredError (3). The bot-rejection hop (`/v3/signin/rejected`) keeps
returning None; it has its own error and must never read as a missing account.
Path-tested rather than importing GOOGLE_REJECTED_BROWSER_ROUTE, because
`_common` -> `auth` is a real import cycle - which is why `internal_chromium`
imports this module deferred.
`_is_safe_to_probe_session` is unaffected, verified: its `flow_host_kind(url) is
None` short-circuit returns False for accounts.google.com before this function
is consulted, so the cookie-rotation gate the council proved equivalent over 393
cases still behaves identically.
D3's redaction finding is no longer hypothetical - that live message carried the
OAuth `state`, `code_challenge` and `client_id`, and this is the artifact users
paste into issues. The stripping added in 7e29be4 removes them.
Not in scope, and recorded rather than smuggled in: the mid-run chooser now
DIAGNOSES but does not RECOVER. #764 taught the bootstrap path to autoselect the
recorded account; that lives on FlowApiClient and is not reachable from a
transport helper. Teaching this hop to autoselect would rescue the run instead of
naming the failure.
Verified: 4202 passed, 92% coverage; the live A/B above; ruff, format,
doc-links, website mirror and hygiene gates green.
Found by live-verifying the previous commit instead of trusting it. Same
profile, same test, real Google, $0:
BEFORE RuntimeError: Could not find 'New project' CTA on Flow gallery.
URL: https://accounts.google.com/v3/signin/accountchooser
?client_id=...&code_challenge=...&state=PKOA6qjxDh...
AFTER FlowAccountChooserError: Google's account chooser is displayed
(https://accounts.google.com/v3/signin/accountchooser) instead of
the Flow gallery - the session needs a person to pick an account.
Not selector drift.
`flow_landing_kind` returned None for accounts.google.com by design, and a unit
test asserted it, on the reasoning "the chooser has its own handler". That is
true at BOOTSTRAP - `client._handle_account_chooser` - and false for a hop that
happens after it, which is what profile `denon82` did on 2026-09-10: it left the
`?error=Callback` state the 02:00 canary reported and moved to Google's chooser,
where `_enter_editor` swept eighteen selectors for a "+ New project" CTA on
Google's sign-in page and blamed the anchor. So the previous commit's claim to
fix the RED canary was false for the state the profile is actually in.
The classifier now answers for Google's auth host too: "chooser" for a
`/v3/signin/accountchooser` path -> FlowAccountChooserError (38, the class #763
and #764 already established for this), "signin" for its other sign-in surfaces
-> AuthExpiredError (3). The bot-rejection hop (`/v3/signin/rejected`) keeps
returning None; it has its own error and must never read as a missing account.
Path-tested rather than importing GOOGLE_REJECTED_BROWSER_ROUTE, because
`_common` -> `auth` is a real import cycle - which is why `internal_chromium`
imports this module deferred.
`_is_safe_to_probe_session` is unaffected, verified: its `flow_host_kind(url) is
None` short-circuit returns False for accounts.google.com before this function
is consulted, so the cookie-rotation gate the council proved equivalent over 393
cases still behaves identically.
D3's redaction finding is no longer hypothetical - that live message carried the
OAuth `state`, `code_challenge` and `client_id`, and this is the artifact users
paste into issues. The stripping added in 7e29be4 removes them.
Not in scope, and recorded rather than smuggled in: the mid-run chooser now
DIAGNOSES but does not RECOVER. #764 taught the bootstrap path to autoselect the
recorded account; that lives on FlowApiClient and is not reachable from a
transport helper. Teaching this hop to autoselect would rescue the run instead of
naming the failure.
Verified: 4202 passed, 92% coverage; the live A/B above; ruff, format,
doc-links, website mirror and hygiene gates green.
…ector (#775) * fix(transports): name a known Flow landing instead of blaming the selector Closes #756. Also fixes the 2026-09-10 RED nightly canary (#559), which is the same defect one URL apart. flow_host_kind() classifies the ORIGIN. /about, /project/<id> and /fx/api/auth/signin?error=Callback all share one, so when a readiness wait timed out it had nothing left to blame but its own anchor — sending the operator to "check for a newer release, then file a bug" over a session state no release changes. Reported at three sites and special-cased three times before (#721 credits, #749 agent mode, FlowAppError's own crash page), so it is fixed once, in the shared place, rather than a fourth time. - flow_landing_kind() joins flow_host_kind() in api/transports/_common.py and names a known non-app landing ("signin" / "public" / None). raise_for_known_landing() converts the diagnosis at the raise site. - Consulted ONLY inside an already-failed branch. Not ahead of the probe, which would delete the DOM evidence that corrects a wrong absence claim (#739 shipped one that way), and not as a bounded wait after goto, which reads page.url before Flow's client-side redirect lands (#639). - flow.google.com/about instead of the project -> FlowAppError (exit 31), naming the landing and the project it did not open, and deliberately not why: #756 measured the redirect and not its cause, and auth status reports the session verified while it happens. - labs .../auth/signin?error=Callback instead of the gallery -> AuthExpiredError (exit 3). This is the exact page behind the RED canary. - auth/internal_chromium.py drops its private _NEXTAUTH_ROUTE_PREFIX and reuses the shared classifier. The knowledge existed there since #767; no transport could reach it. Retryability was measured, not assumed. Routing /about to exit 31 would have flipped it from non-retryable to retryable purely as a side effect. The spike (scripts/dev/spike_about_redirect_stability.py, $0, ci-probe) came back 0/5 — the redirect stopped reproducing between 2026-09-08 and today — which is equally consistent with "transient" and with "a session state changed", and so settles nothing. The reading was pre-registered in the script before the run. Therefore GFlowError gains a per-instance `retryable` override (same class-default / instance-override shape as remediation_hint) and the /about raise site passes retryable=False to PRESERVE its previous exit-23 answer, not to claim a retry fails. is_retryable() pins the override with isinstance(..., bool): a MagicMock answers every attribute with a truthy child, so a truthiness test would report every mocked error as retryable with nothing noticing. Tests: tests/features/landing_state_diagnosis.feature bound from tests/e2e/test_landing_state_diagnosis_bdd.py — the first BDD-bound e2e. Three scenarios against a real browser over route interception, zero credits: both landings, plus an A/B control proving genuine drift still reports as drift. RED first (the canary's exact RuntimeError, reproduced deterministically), then green. Plus classifier unit tests and five retryability tests. tests/test_marker_registry.py now resolves a BDD module's cost tier through its Gherkin tags. It read Python source only, so it saw no pytest.mark and failed a correctly-tiered file; copying the tier into a pytestmark would have satisfied it and then drifted from the Feature. * fix(review): apply the branch-council findings 12-dimension council on 63fb5bb: 3 GREEN (correctness, auth, BDD), 9 YELLOW, 0 RED, ~19 must-fix. Two dimensions found defects in the workflow hardening itself rather than in the fix. Security (D3): the landing URL is stripped to scheme+host+path before it reaches either the message or the log. The NextAuth family the "signin" arm matches includes /fx/api/auth/callback/google?state=...&code=..., and because AuthExpiredError is capture-exempt that message is the ONLY artifact — so it is exactly what a user pastes into an issue. redact_error_detail was already imported in this module and used 300 lines below; the new raise skipped it. Truthfulness (D2, D15, D9): the message said "its sign-in page" for a family that also contains the callback and /session. docs/MCP.md still told agents FlowAppError is unconditionally retryable and that RETRYABLE_ERRORS "is the whole" list; docs/USAGE.md's exit-3 row still described only the 401/403 trigger; and a docstring claimed the helper "never adds a failure", which is false at migrated_composer.ensure_editor, whose except branch has a recovery path. Reach (D6): a THIRD site. _enter_editor(project_id=...) has no readiness gate, so a landing page reaches _locate_prompt_box and raises a bare RuntimeError, which observability.py hashes because it is not a GFlowError — the operator saw "Unexpected error" with even the URL destroyed. Worse than the report #756 is about, and the guard was not consulted there. Scope (D14, D1): retryable moves off GFlowError onto FlowAppError, the one class that needs it — is_retryable reads it by getattr, so a base field bought no typing and no test-double visibility, only a field on every error in the project. The FlowApiError pass-through it required is deleted. flow_landing_kind's "public" arm is gated to the migrated host, the only one where /about was measured. Instrument (D13): the spike re-implemented the predicate it was measuring (endswith("/about") vs the production classifier), which would score /about?hl=en as a non-reproduction — the #743 shape its own docstring cites. It now calls flow_landing_kind. except BaseException became except Exception: the former swallowed Ctrl-C, recorded it as a non-/about sample and advanced the loop, so an interrupted run could print "DOES NOT REPRODUCE" assembled from interrupts. Guards (D4, D12): the binding guard could pass on an empty scan — every check is assert-not-<list>, so a mis-resolved _FEATURES_DIR made all of them vacuous while the fire-test still passed. It now asserts it scanned something. The "one feature, one binding module" rule stated in docs was the one rule nothing enforced; it is enforced now. Feature-name matching is anchored on the path separator. The "signin" branch had zero offline coverage (only the e2e reached it, and addopts excludes that), so the 4194-pass run never executed it. Memory (D5): docs/superpowers/memory/ui-selector-drift-error-exit-23.md keeps a carve-out ledger for exceptions to exit 23 — exits 36, 37 and 38 each recorded when introduced. Carve-out 4 is now recorded, with the transferable lesson the branch is actually about: flow_host_kind classifies the ORIGIN, not the page. Hardening defects the council found in the first commit (D9): the lane said "steps 3-5 have no gate" beside "step 3 = that Gherkin RED", which is unsatisfiable when step 2 is skipped — the gate is red-before-green, never Gherkin-before-green. And the continuation table routed 1 -> 1b -> 3, orphaning Phase 2 (predict) while the routing table still mandates it for exactly this kind of change. Declined, with reasons: excluding /about from incident capture (FlowAppError also covers the React crash page, where the DOM helps, and a class cannot exclude one shape), and moving the guard after _capture_debug_screenshot (D3 showed that placement removes an existing screenshot of a signed-in page; D9's answer, taken, is to document the capture change in DEBUGGING.md rather than revert it). Verified: 4197 passed, 92% coverage; e2e 3/3 in a real browser at $0; ruff, format, doc-links, website mirror, council-memory and hygiene gates all green. * fix(transports): recognise Google's auth host as a known landing Found by live-verifying the previous commit instead of trusting it. Same profile, same test, real Google, $0: BEFORE RuntimeError: Could not find 'New project' CTA on Flow gallery. URL: https://accounts.google.com/v3/signin/accountchooser ?client_id=...&code_challenge=...&state=PKOA6qjxDh... AFTER FlowAccountChooserError: Google's account chooser is displayed (https://accounts.google.com/v3/signin/accountchooser) instead of the Flow gallery - the session needs a person to pick an account. Not selector drift. `flow_landing_kind` returned None for accounts.google.com by design, and a unit test asserted it, on the reasoning "the chooser has its own handler". That is true at BOOTSTRAP - `client._handle_account_chooser` - and false for a hop that happens after it, which is what profile `denon82` did on 2026-09-10: it left the `?error=Callback` state the 02:00 canary reported and moved to Google's chooser, where `_enter_editor` swept eighteen selectors for a "+ New project" CTA on Google's sign-in page and blamed the anchor. So the previous commit's claim to fix the RED canary was false for the state the profile is actually in. The classifier now answers for Google's auth host too: "chooser" for a `/v3/signin/accountchooser` path -> FlowAccountChooserError (38, the class #763 and #764 already established for this), "signin" for its other sign-in surfaces -> AuthExpiredError (3). The bot-rejection hop (`/v3/signin/rejected`) keeps returning None; it has its own error and must never read as a missing account. Path-tested rather than importing GOOGLE_REJECTED_BROWSER_ROUTE, because `_common` -> `auth` is a real import cycle - which is why `internal_chromium` imports this module deferred. `_is_safe_to_probe_session` is unaffected, verified: its `flow_host_kind(url) is None` short-circuit returns False for accounts.google.com before this function is consulted, so the cookie-rotation gate the council proved equivalent over 393 cases still behaves identically. D3's redaction finding is no longer hypothetical - that live message carried the OAuth `state`, `code_challenge` and `client_id`, and this is the artifact users paste into issues. The stripping added in 7e29be4 removes them. Not in scope, and recorded rather than smuggled in: the mid-run chooser now DIAGNOSES but does not RECOVER. #764 taught the bootstrap path to autoselect the recorded account; that lives on FlowApiClient and is not reachable from a transport helper. Teaching this hop to autoselect would rescue the run instead of naming the failure. Verified: 4202 passed, 92% coverage; the live A/B above; ruff, format, doc-links, website mirror and hygiene gates green. * docs(changelog): fold the duplicate Added heading the rebase created Rebasing onto develop after #774 squash-merged stacked this branch's Added section above the one that arrived with the hardening, leaving two under [Unreleased]. Keep a Changelog wants one per type; content is unchanged.
Summary
Fixes #763. When the post-migration hop lands on the Google account chooser
(
accounts.google.com/v3/signin/accountchooser) with the recorded account rowsigned out, the CLI now auto-selects the profile's recorded account from
.gflow_accountright after bootstrapgoto, before any mint/probe work.Unselectable cases (row absent, click-through does not return to
editor/session page,
--accountmismatch) raise a dedicated non-retryableFlowAccountChooserError(exit 38) instead of stalling into an opaqueUnexpectedError(exit 1) or selector-drift report.gflow auth logingains--account <email>to assert recorded == verified email.Sibling of #756 (the
/aboutlanding misreported asUiSelectorDriftError);this covers the accountchooser landing state.
Lifecycle
checkgreen, including the step 1b CLI-MCP mirror sweep (auth loginis exempt in
tests/mcp/test_cli_parity.pyas interactive sessionmanagement, so the new
--accountflag needs no MCP twin)auth status+ mintdry-run only; Generate never clicked); paid generation paths untouched
is stated below (the chooser auto-click branch itself: the recorded
account was already signed in at proof time, so the click-through is
covered by unit + bootstrap tests, not by a live chooser click)
Validation
[Unreleased] -> Added)uv run python scripts/ci/check_doc_links.pyuv run ruff check src testsuv run pyright srcuv run pytest -q tests/auth/ tests/api/test_bootstrap_chooser.py tests/api/test_client_migrated_mint.py(93 passed) +
tests/test_errors.py tests/mcp/test_cli_parity.py(63 passed)Live evidence ($0 — navigation and DOM reads only, Generate never clicked)
gflow auth status --profile presentation-reels-google-> Flow session verifiedf1ad61287861586d28fed31e6b3fce6a5f02833e1834a6213027a2e73990a940Contribution Checklist
developSigned-off-by:(git commit -s)