Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ the five other mirror axes), which no command here can check and no CI gate can

- Type hints everywhere; `pyright` strict on `src/gflow_cli`.
- Structured logging only (`structlog`) — **never** raw `print()` or `import logging` in `src/`.
- Errors as RFC 9457 Problem Details with stable per-class exit codes (3–37, e.g. 11 is `ConfigurationError` — including `ProfileLockedError` for same-profile lease contention, 16 is the `DataStoreError` family, 19 `SceneConcatError`, 20 `FrameExtractionError`, 21 `ChainPartialError`, 22 `UpscaleUnavailableError`, 25 `FlowAgentUiError`, 28 `UiModeUnavailableError`, 29 `MentionIndexUnavailableError`, 30 `QueueSchemaError`, 37 `InsufficientCreditsError`). See `src/gflow_cli/errors.py::EXIT_CODE_MAP` for the complete mapping. Exit 33 is reserved outside that map: `gflow doctor` findings-present — a successful diagnosis, not an error class.
- Errors as RFC 9457 Problem Details with stable per-class exit codes (3–38, e.g. 11 is `ConfigurationError` — including `ProfileLockedError` for same-profile lease contention, 16 is the `DataStoreError` family, 19 `SceneConcatError`, 20 `FrameExtractionError`, 21 `ChainPartialError`, 22 `UpscaleUnavailableError`, 25 `FlowAgentUiError`, 28 `UiModeUnavailableError`, 29 `MentionIndexUnavailableError`, 30 `QueueSchemaError`, 37 `InsufficientCreditsError`). See `src/gflow_cli/errors.py::EXIT_CODE_MAP` for the complete mapping. Exit 33 is reserved outside that map: `gflow doctor` findings-present — a successful diagnosis, not an error class.
- 100-char line length, `ruff` configured. Imports sorted by `ruff` (isort rules).
- **YAGNI / least-code**: prefer the smallest change that works. No speculative abstractions (interface/factory with one implementation), no config or flags nobody sets, no dead constants/helpers, no reinventing the stdlib. Review carries this as its own lens — the **D14 over-engineering** dimension of [`pr-council-review`](skills/pr-council-review/SKILL.md) (baseline, always runs). Its rubric is portable; the `ponytail` plugin (see CONTRIBUTING) is an optional accelerant, not a dependency.
- **MCP & CLI Schema Symmetry**: Any updates or additions to user-facing CLI command parameters (e.g., `gflow image t2i`, `gflow video`) must be mirrored in the corresponding MCP tool definitions. Never add option/argument fields to Click commands without updating the MCP server implementation. This symmetry is enforced programmatically in CI via `tests/mcp/test_cli_parity.py` (every CLI leaf command needs a mapped MCP tool or an explicit, reasoned exemption) plus the schema checks in `tests/mcp/test_server.py`.
Expand Down
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
exit 23 — which told the user to file a frontend-drift bug about a frontend that was
behaving correctly.

- **Account auto-selection at post-migration sign-in chooser**
([#763](https://github.com/ffroliva/gflow-cli/issues/763)). When Google
Flow hands the session over to `flow.google.com` and redirects to an account
chooser, `FlowApiClient` now auto-selects the profile's recorded account from
`.gflow_account`. If the recorded account is absent or cannot be selected, the
client raises a dedicated, non-retryable `FlowAccountChooserError` (exit code 38),
avoiding generic `UnexpectedError` or selector drift stalls. `gflow auth login`
gains an optional `--account <email>` option to assert that login authenticates
as the required account. When the click-through does not reach Flow, the error
names the URL the session actually landed on, so a Google challenge that needs a
human is distinguishable from a click that never navigated. Account matching is
case-insensitive on both tiers, matching `--account`'s own comparison, so a
recorded address whose case differs from Google's rendering still selects its
row instead of reporting the account as absent.

### Fixed

- **`gflow auth list` no longer fails on a profile whose `.gflow_account` is
damaged.** The reader decoded as UTF-8 and caught only `OSError`, so a
non-UTF-8 or truncated file raised out of `list_profiles()` and broke the
listing for *every* profile, not just the damaged one. The value is also
interpolated into a DOM attribute selector, where a stray quote produced an
untyped failure; unusable content now reads as "no account recorded", which
every caller already handles.

### Changed

- **`gflow auth login` closes the browser for you.** It drives your real Google Chrome
Expand Down
12 changes: 12 additions & 0 deletions docs/AUTHENTICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,18 @@ Set ffroliva as default profile.
If a profile named after the email local-part already exists, the rename is skipped
and the profile keeps the name `default`.

#### `--account <email>`

Asserts that the login authenticates as one exact Google account. After the
session verifies, the CLI compares the verified email against `--account`
(case-insensitive) and fails with `FlowAccountChooserError` (exit 38) on a
mismatch — including when no verified email was recorded at all, since an
identity assertion that cannot read the identity must not pass.

A mismatch means the profile now holds the *other* account's session: re-run
`gflow auth login --profile <name> --account <email>` while signed in as the
required account.

#### `--browser [auto|chrome|internal]`

| Value | Browser used | When to use |
Expand Down
2 changes: 1 addition & 1 deletion docs/DEBUGGING.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ at command startup).

Captured: `FlowAppError` (31), `FlowAgentUiError` (25),
`FlowHostMigratedError` (36), `UiModeUnavailableError` (28),
`UiSelectorDriftError` (23),
`UiSelectorDriftError` (23), `FlowAccountChooserError` (38),
`TransportTimeoutError` (9), `BrowserSessionClosedError` (15),
`WireFormatError` (7), `WafRejectionError` (10), `NetworkError` (6),
unexpected exceptions while a page is alive, and `ProfileLockedError` (11)
Expand Down
1 change: 1 addition & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1787,6 +1787,7 @@ shell scripts can branch on the failure mode without parsing stderr.
| `35` | `ExtendUnavailableError` | No Veo extend model is orderable for this account and aspect — the extend family is tier-gated and there is no square variant. **Never auto-retry**: a tier gate does not clear on its own. |
| `36` | `FlowHostMigratedError` | Flow served the project from `flow.google.com` and the request could not be represented by the migrated composer, or `GFLOW_CLI_FLOW_HOST=labs.google` disabled it. Supported today: `video t2v`; local-file video i2v/r2v; `image t2i`; and local-file `image i2i`. Image UUID/entity/instruction/Imagen-4 forms, `image batch`, and the `3:4` image aspect remain unsupported. Not selector drift (23) | **Not retryable.** Use one of the supported forms — `--project` is required for images as well as video — or the REST surface (`gflow project list`, `gflow data …`); follow #639 for the remaining matrix |
| `37` | `InsufficientCreditsError` | The account's balance is short **for the model it asked for**, so Flow **replaced** the submit control with its `Insufficient credits warning` instead of disabling it. Short, not necessarily empty: measured 2026-09-07, an account holding **50** credits requesting `--model veo-quality` (**100**) rendered the warning. Explicitly **not** selector drift (23): reporting it as drift told users to file a frontend bug over a credit shortfall | Check the balance with `gflow credits user`, then pick a cheaper `--model` (`veo-lite` costs 10), top up, or wait for the allowance to reset. Nothing was submitted, so no credit was spent. `gflow image` draws on a separate daily quota and may still work |
| `38` | `FlowAccountChooserError` | The post-migration hop landed on Google's account chooser and the profile's recorded account (`.gflow_account`) could not be selected automatically (row absent, click-through did not return to the editor, or `--account` mismatch) | **Not retryable**: run `gflow auth login --profile <name>` and complete the chooser manually, while signed in as the recorded account (re-run `gflow auth login` if the chooser offers a different session) |
| `130`| SIGINT | User-interrupted (Ctrl-C) | — |

**Exit code 16 — data store / migration error.** Fires when:
Expand Down
16 changes: 16 additions & 0 deletions docs/superpowers/memory/ui-selector-drift-error-exit-23.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,19 @@ description: "Selector-probe failures = typed UiSelectorDriftError exit 23, neve
**Remediation contract updated by PR #504 (2026-08-13, #493):** `UiSelectorDriftError._default_remediation` now asks for "the diagnostics JSON and/or debug screenshot referenced in this message, plus the incident bundle's report.md" — the old "debug screenshot from this message" was a false promise on the mode-switch probe, which writes `diag_mode_switch_miss.json` ONLY (no screenshot; the full-page screenshot lives in the incident bundle's `sensitive/`). The exit-23 mode-switch fall-through detail additionally names the unrecognized-new-variant hypothesis. See [[issue-493-third-editor-variant-predict-stop]].

See [[pr-184-e2e-drift-sim-results]], [[flow-library-ui-drift-174]], [[exit-code-map-ordering-invariant-test-pitfall]].

**Carve-out recorded by PR #764 (2026-09-08, #763):** a selector-cascade miss on
`accounts.google.com` (the Google account chooser after the post-migration hop)
raises `FlowAccountChooserError` (exit 38), NOT `UiSelectorDriftError` (exit 23).
The chooser is Google-auth UI, not the Flow editor: reporting it as drift would
tell users to file a frontend bug about a working chooser, and the exit-23
remediation (attach diagnostics, check for a release) cannot fix a missing
account row. The miss is evidence about the *recorded account* (absent row or
a click-through that never reaches the editor). Each raise site interpolates the
observed chooser URL verbatim — there is no URL-kind taxonomy. Explicitly out
of scope: the bot-rejection hop (`.../v3/signin/rejected`) is excluded from the
chooser gate and surfaces as its own error, never as a missing account.
Recovery is `gflow auth login --profile <name>` while signed in as the recorded
account. Precedent:
exits 36 (`FlowHostMigratedError`) and 37 (`InsufficientCreditsError`) each got
the same carve-out recorded when introduced.
139 changes: 138 additions & 1 deletion src/gflow_cli/api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import base64
import json
import os
import re
import sys
import time
import uuid
Expand All @@ -27,6 +28,7 @@

import structlog
from playwright.async_api import BrowserContext, Page, Playwright, async_playwright
from playwright.async_api import TimeoutError as PlaywrightTimeoutError

from gflow_cli.api import routes, video_extend
from gflow_cli.api._engine import (
Expand Down Expand Up @@ -61,7 +63,11 @@
make_transport,
resolve_transport_name,
)
from gflow_cli.api.transports._common import await_url_settled, raise_if_migrated
from gflow_cli.api.transports._common import (
await_url_settled,
flow_host_kind,
raise_if_migrated,
)
from gflow_cli.api.transports.base import (
FlowTransportStrategy,
SupportsTransportSetup,
Expand All @@ -75,6 +81,7 @@
parse_video_status,
)
from gflow_cli.api.video_extend import ExtendStarted
from gflow_cli.auth.internal_chromium import GOOGLE_REJECTED_BROWSER_ROUTE
from gflow_cli.browser_manager import channel_for_profile
from gflow_cli.config import BrowserEngine, Settings
from gflow_cli.diagnostics import IncidentRecorder, run_retention, validated_incidents_root
Expand All @@ -86,6 +93,7 @@
BrowserSessionClosedError,
ConfigurationError,
ContentPolicyError,
FlowAccountChooserError,
FlowApiError, # re-exported via gflow_cli.api.__init__
FlowHostMigratedError,
NetworkError,
Expand Down Expand Up @@ -783,6 +791,121 @@ async def _enter_setup(self) -> None:
# S1 can share this context rather than opening its own.
await self._setup_transport()

async def _handle_account_chooser(self, page: Page) -> bool:
"""Select the recorded Google account on accountchooser if encountered (#763).

Returns True if an account was clicked, False if not on chooser.
Raises FlowAccountChooserError if on chooser but account is missing/not selectable.

Matching is exact on the account row only: the chooser's loose surfaces
("Remove <email>", "Sign out of <email>", signed-in-as subtitle) would
otherwise win a substring match and click the wrong account, billing it.
"""
from gflow_cli.profile_store import read_account_file

url = getattr(page, "url", "") or ""
# Exact host match, never a substring test: a Flow URL merely carrying
# accounts.google.com in a ?continue= param must not read as a chooser.
# The rejected-browser hop is not a chooser either and must surface as
# its own error rather than a missing account.
# Total by construction (same discipline as flow_host_kind): a probe
# error must never displace the real bootstrap failure, and suites
# drive this path with mocked pages whose url is not a string.
if not isinstance(url, str):
return False
try:
parts = urlsplit(url)
host = (parts.hostname or "").lower()
except ValueError:
return False
is_accounts_host = parts.scheme == "https" and host == "accounts.google.com"
if not is_accounts_host or GOOGLE_REJECTED_BROWSER_ROUTE in url:
return False

# Identify a chooser POSITIVELY. The host gate accepts every
# accounts.google.com landing, and most are not choosers — the email form, a
# password challenge, a consent interstitial — where there is nothing to pick.
# Reporting those as a chooser misdirects the operator and changes an exit code
# callers branch on: they otherwise reach the transport's 401 and classify as
# AuthExpiredError (exit 3). Two independent signals, because each covers the
# other's blind spot — Google renaming the path, or a chooser whose rows carry
# no data-email. Neither matching means we return False, which is exactly how
# this path behaved before the feature existed.
on_chooser_path = parts.path.rstrip("/").endswith("accountchooser")
if not on_chooser_path and await page.locator("[data-email]").count() == 0:
return False

email = read_account_file(self.profile_dir)
if not email:
raise FlowAccountChooserError(
detail=(
f"Google sign-in/chooser displayed at {url} but no account is recorded "
f"in this profile to auto-select."
)
)

# Exact row match only (D3): data-email is the chooser's stable per-account
# anchor. A substring/text-engine fallback would match "Remove <email>"
# or "Sign out of <email>" and click a DOM-order-first wrong account.
# Case-insensitive on BOTH tiers, because `gflow auth login --account`
# already compares with `.lower()` and `read_account_file` normalises
# nothing: an address recorded in one case and rendered by Google in
# another otherwise passes the --account assertion and then misses the
# row, raising "not found among selectable accounts" while the account
# sits on the chooser. CSS attribute matching is case-sensitive unless
# the `i` flag is given; the text fallback stays ANCHORED so relaxing
# case does not start matching "Remove <email>" / "Sign out of <email>"
# — clicking those signs the operator out instead of in.
row = page.locator(f'[data-email="{email}" i]')
count = await row.count()
if count == 0:
row = page.get_by_text(re.compile(rf"^{re.escape(email)}$", re.IGNORECASE))
count = await row.count()

if count == 0:
raise FlowAccountChooserError(
detail=(
f"Account chooser displayed at {url} but recorded account '{email}' "
f"was not found among selectable accounts."
)
)

# The row is the account's entry; verify we actually leave the chooser.
await row.first.click()
# `wait_for_url` returns None and signals a miss by RAISING, so its return value
# is falsy on success as well as failure — testing it inverted the check and made
# every successful click raise. Catch the raise instead.
#
# The landing predicate is "on any Flow host", not a `**/project/**` glob: the
# bootstrap URL is `labs.google/fx/tools/flow` with no /project/ segment, and only
# the migrated origin serves /project/<id>. `flow_host_kind` is the codebase's
# exact-host classifier (a substring test matches any URL merely mentioning the
# host in a ?continue= param), and it answers for both cohorts.
try:
await page.wait_for_url(lambda u: flow_host_kind(u) is not None, timeout=30_000)
except PlaywrightTimeoutError as exc:
# Where the click left us IS the diagnosis, so the detail has to carry it
# (its sibling raise above interpolates the chooser URL for the same
# reason). `flow_host_kind` is a host-only match that accepts every Flow
# landing this codebase knows, `/about` included, so a timeout here is
# never the predicate being too narrow — the session is still on a Google
# surface. WHICH surface is the whole question: a challenge needs a human,
# a consent screen needs a click, and a URL still equal to `url` above
# means the click never navigated at all. Shipped without this, the branch
# fired live on 2026-09-09 and said only "did not reach Flow within 30s".
landed = page.url
raise FlowAccountChooserError(
detail=(
f"Clicked recorded account '{email}' on the chooser but the session "
f"did not reach Flow within 30s — it is at {landed}."
)
) from exc
logger.info(
"client.account_chooser_autoselected",
account=redact_sensitive_text(email),
)
return True

async def _bootstrap_and_resolve_locale(self) -> None:
"""Navigate the bootstrap page and settle the account locale (#580, #587).

Expand Down Expand Up @@ -819,6 +942,20 @@ async def _bootstrap_and_resolve_locale(self) -> None:
self._account_locale, from_url = await self._resolve_account_locale(
self._page, settle=settle
)
# #763: the chooser hop lands through the same post-goto redirect chain as
# the locale hop, so it is observable only after the settle above.
# BOTH outputs of the first resolve are the chooser's, and both must be
# replaced. `self._account_locale` would otherwise carry
# accounts.google.com's <html lang> for the rest of the run. `from_url`
# is subtler and was wrong: a chooser yields None, and
# `next_locale_state(cached="pt", observed=None)` returns PROVISIONAL, so
# the fold below wrote a DEMOTION of a committed locale on every chooser
# hop (#643's bug class). The post-click resolve holds the editor's real
# segment — fold that.
if await self._handle_account_chooser(self._page):
self._account_locale, from_url = await self._resolve_account_locale(
self._page, settle=False
)
if not settle:
# Kept (not merged into account_locale_state) because field reports key
# on this event to tell "the settle was skipped" from "it timed out".
Expand Down
Loading