diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 5db72b31..558af528 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "gflow", - "version": "0.71.1", + "version": "0.72.0", "description": "Reusable development and operations workflows for gflow-cli", "author": { "name": "Flavio Oliva", diff --git a/.env.template b/.env.template index 26d2fc01..26c7206d 100644 --- a/.env.template +++ b/.env.template @@ -183,8 +183,9 @@ # GFLOW_CLI_UI_MODE=auto # Which Flow frontend gflow drives (#639). auto (default): flow.google.com is the -# default host for what it can serve today (video t2v, and video i2v from a local -# --initial-frame, with --project) on every +# default host for what it can serve today (video t2v; video i2v from a local +# --initial-frame; video r2v from local --ref files; image t2i; and image i2i from +# local files — all with --project) on every # account; requests it cannot serve yet keep the labs driver on an unmoved # account, and a moved account gets the migrated composer for everything ported. # flow.google.com: force the migrated composer for everything. diff --git a/AGENTS.md b/AGENTS.md index 9ddeef37..a188aa2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,7 @@ by construction, so Codex / Cursor / Aider / `agy` read exactly what Claude Code ## Project at a glance -- Unofficial Python CLI for [Google Flow](https://labs.google/fx/tools/flow) — drives Veo (image-to-video, text-to-video) and Imagen (text-to-image) generations from the terminal by reverse-engineering Flow's private REST API at `aisandbox-pa.googleapis.com` — and, for accounts Google has moved to `flow.google.com`, that frontend's `batchexecute` wire (text-to-video, image-to-video from a local `--initial-frame`, and reference-to-video from local `--ref` files, today; `GFLOW_CLI_FLOW_HOST`). +- Unofficial Python CLI for [Google Flow](https://labs.google/fx/tools/flow) — drives Veo (image-to-video, text-to-video) and Imagen (text-to-image) generations from the terminal by reverse-engineering Flow's private REST API at `aisandbox-pa.googleapis.com` — and, for accounts Google has moved to `flow.google.com`, that frontend's `batchexecute` wire (text-to-video, image-to-video from a local `--initial-frame`, reference-to-video from local `--ref` files, and text-to-image/local-file image-to-image, today; `GFLOW_CLI_FLOW_HOST`). - Python 3.11+ · `uv`-managed · `hatchling` builds · Playwright Chromium transport · `pyright` strict · `ruff` · `pytest`. - Single-package modular monolith. Top-level modules under `src/gflow_cli/`: `api/`, `auth/`, `data/`, `mcp/`, `services/`, `tools/`, `ui/`, `worker/`, `browser_manager.py`, `cli.py`, `_cli_helpers.py`, `diagnostics.py`, `json_output.py`, `media.py`, `profile_lease.py`, `redaction.py`, `storage.py`, `winsec.py`, `cli_project.py`, `cli_character.py`, `cli_credits.py`, `cli_data.py`, `cli_image.py`, `cli_instructions.py`, `cli_models.py`, `cli_movie.py`, `cli_run.py`, `cli_scene.py`, `cli_tools.py`, `cli_video.py`, `chain.py`, `chain_manifest.py`, `cli_doctor.py`, `cli_update.py`, `composition.py`, `config.py`, `errors.py`, `file_integrity.py`, `flow_selectors/`, `update_check.py`, `exceptions.py`, `image_batch.py`, `movie_manifest.py`, `observability.py`, `paths.py`, `profile_store.py`. - Command surface: `gflow auth`, `gflow credits` (user/list — read-only Veo balance), `gflow image` (t2i/i2i/batch/upload/upscale), `gflow video` (t2v/i2v/r2v/chain/extend — `extend` continues an existing clip past Flow's 8s ceiling, server-seeded from the source so the join is continuous; no `batch` subcommand; the nonfunctional stub was removed, loop `gflow video t2v`/`i2v` from the shell for multi-clip runs), `gflow character` (create/list/show/rm/voices — reusable project-scoped Flow Character entities), `gflow scene` (create/show — Add Clip / Scenes, with `create --output` for credit-free server-side extended video), `gflow instructions` (persistent Agent-Mode brief cards — add/list/enable/disable/rm/apply/toggle-mode, credits-free, `--project` required), `gflow movie` (run/template — multi-scene manifest pipeline), `gflow tools` (list/show/run — prompt-rewriting tools, also `--tool` on generation commands), `gflow data` (catalog queries), `gflow doctor` (read-only pre-flight diagnostic, exit 33 = findings present), `gflow update` (self-update through the installer that put it here — uv tool / pipx / pip; `--check` only reports; source installs refused, exit 11; deliberately no MCP twin), `gflow project`, `gflow models`, `gflow run`, `gflow mcp` (run/setup — stdio MCP server), and `gflow serve` (Streamable HTTP at `/mcp`; `--transport sse` is deprecated). @@ -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 b9793edf..5dfac6fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,90 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.72.0] — 2026-09-09 + +### Added + +- **`gflow image t2i` and local-file `i2i` now run on migrated `flow.google.com` + accounts** ([#639](https://github.com/ffroliva/gflow-cli/issues/639)). The Angular + composer binds Image mode, Nano Banana 2 / Pro, the four aspect ratios measured on that + host (16:9, 4:3, 1:1, 9:16) and counts 1–4, + then observes the page-owned `ogiZ0b` `batchexecute` reply and returns the same + `GeneratedImage` contract as the labs driver. Local references reuse the measured + `maseQ` upload + mention path and are verified in the outgoing submit body before the + result is trusted. The migrated page owns reCAPTCHA minting, avoiding the root-grid + `RecaptchaError`; unsupported UUID/entity/instruction/Imagen-4 forms still fail before + submit rather than silently dropping options. + +- **`gflow image batch` is refused on the migrated host instead of failing as selector + drift** ([#639](https://github.com/ffroliva/gflow-cli/issues/639)). The batch path + drives labs selectors only; it now raises `FlowHostMigratedError` (exit 36) before any + submit, rather than running those selectors against `flow.google.com` and reporting + 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** + ([#673](https://github.com/ffroliva/gflow-cli/issues/673)). Every migrated image run + parks its page on `about:blank`, which routes as `labs` — so the page-owned-mint + capability, derived from `page.url`, answered `False` on the next call and sent it + back to minting on the pooled bootstrap page. The transport now latches the observed + host: `gflow image batch`, which runs every prompt through one `FlowApiClient`, + generated its first prompt and failed the rest with the exact `RecaptchaError` this + release exists to remove. +- **`--aspect 3:4` is refused on the migrated host rather than reported as selector + drift.** The composer's aspect radiogroup was enumerated there with four radios — + `crop_16_9`, `crop_landscape`, `crop_square`, `crop_9_16` — and no `crop_portrait`, so + a 3:4 request missed its selector and raised exit 23. It is now an unported form + (exit 36). The "all five aspect ratios" claim has been corrected to the four measured + wherever it appeared. +- **The exit-36 remediation no longer contradicts the error it accompanies.** + `FlowHostMigratedError._default_remediation` still described the migrated host as + 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). + +### Security + +- **`httpx2` / `httpcore2` bumped to 2.12.0, clearing five newly published CVEs** + ([#766](https://github.com/ffroliva/gflow-cli/pull/766)). `httpcore2` CVE-2026-84381 and + `httpx2` CVE-2026-84378 / -84379 / -84380 / -84382, all against 2.9.1; both arrive + transitively through `mcp`. The advisories were published against an unchanged lockfile — + `Dependency audit (pip-audit)` went red on `develop` without any dependency change — so + this is not a regression introduced by a feature PR. `uvx pip-audit` on the exported + requirements now reports no known vulnerabilities. The bump also adds `httpx2-jsfetch` + 1.0 to the lock, marked `sys_platform == 'emscripten'` (Pyodide/WASM only); it is never + installed on any platform gflow supports. + ## [0.71.1] — 2026-09-08 ### Fixed @@ -4580,7 +4664,8 @@ shell-script template that branches on these codes. First skeleton. Not functional end-to-end yet. -[Unreleased]: https://github.com/ffroliva/gflow-cli/compare/v0.71.1...HEAD +[Unreleased]: https://github.com/ffroliva/gflow-cli/compare/v0.72.0...HEAD +[0.72.0]: https://github.com/ffroliva/gflow-cli/compare/v0.71.1...v0.72.0 [0.71.1]: https://github.com/ffroliva/gflow-cli/compare/v0.71.0...v0.71.1 [0.71.0]: https://github.com/ffroliva/gflow-cli/compare/v0.70.0...v0.71.0 [0.70.0]: https://github.com/ffroliva/gflow-cli/compare/v0.69.0...v0.70.0 diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index c016fcd9..f098e2b6 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -14,13 +14,13 @@ Living list of behaviour that's broken, surprising, or limited by design — alo ## Open -### Flow is migrating to `flow.google.com`; gflow drives the migrated frontend for t2v, i2v from a local start frame, and r2v from local reference files (rest of the matrix pending) +### Flow is migrating to `flow.google.com`; generation coverage is partial but includes images -- **Status:** Open (partially resolved) · **Severity:** High for everything except text-to-video, local-file image-to-video and local-file reference-to-video · **Affected:** on accounts the rollout has reached, `gflow video t2v`, `gflow video i2v --initial-frame ` and `gflow video r2v --ref ` now run on the migrated host (with `--project`); an end frame, a frame by UUID or `@Name`, references by `@Name` or `--reference-entity`, `image`, scenes, extend, instructions and tools are not ported yet and still exit 36. **`character` is NOT in that list any more** — `character create` was verified working on the migrated host in v0.70.0 and `character list` was re-verified there on 2026-09-07. This line claimed otherwise for a day, which is the dangerous direction for a stale doc to be stale in: a migrated user reading it concludes a working feature is impossible. Of the remainder, only the i2v-by-UUID case rests on a positive observation of absence (the Frames picker's tiles carry no media id); `scenes`, `extend`, `instructions` and `tools` have never had a probe run against them at all, so read them as *unported by gflow*, never as *impossible on the host* +- **Status:** Open (partially resolved) · **Severity:** High for unported forms · **Affected:** on accounts the rollout has reached, `gflow video t2v`, local-file `video i2v` / `r2v`, `gflow image t2i`, and local-file `gflow image i2i` now run on the migrated host. Image mode supports Nano Banana 2 / Pro, the four aspect ratios its radiogroup was enumerated with (16:9, 4:3, 1:1, 9:16), and count 1–4; `3:4` had no radio in that enumeration and is refused before submit rather than reported as selector drift. Image refs by UUID, `@Name` / `--reference-entity`, Agent instructions, Imagen 4, video end frames, video refs by UUID/name, scenes, extend, instructions and tools are not ported yet and fail before submit. **`character` is NOT in that list any more** — `character create` and `character list` work on the migrated host. Of the remainder, only the i2v-by-UUID case rests on a positive observation of absence (the Frames picker tiles carry no media id); `scenes`, `extend`, `instructions` and `tools` remain *unported by gflow*, never proven impossible on the host. - **Tracked:** [#639](https://github.com/ffroliva/gflow-cli/issues/639) · Reported 2026-09-02 against 0.59.0, 0.62.1, 0.63.0 and 0.65.0 - **Confirmed live 2026-09-03 on a second, independent account** (`ffroliva`) — see [LIVE_VERIFICATION_v0.66.0](docs/LIVE_VERIFICATION_v0.66.0.md). A read-only probe of the migrated origin measured `i_total: 0`, reproducing the reporter's central measurement. - **`--reference-entity` was refused on `r2v` but not on `t2v`** ([#716](https://github.com/ffroliva/gflow-cli/issues/716), fixed in v0.71.0): the "not ported, exit 36" refusal above sat inside the r2v branch of the routing gate, so a `t2v` request carrying a character entity returned from that gate without its entities ever being inspected — and nothing downstream attaches one on this host. The generation was **submitted and billed** with the entity silently dropped, returning a plausible clip of the wrong person. The check is now mode-independent and ahead of every early return. If you ran `gflow video t2v @Name …` or `--reference-entity` on a moved account before this fix, the identity in those clips was never bound. -- **The migrated composer DOES have an image mode** ([#692](https://github.com/ffroliva/gflow-cli/issues/692)): a code comment claimed, as "measured, not assumed", that it has none. Falsified 2026-09-07 — its settings overlay carries a `mode` radiogroup of `[imageImage, videocamVideo]`, present and hit-testable. Nothing was clicked on that axis and nothing was submitted, so this does **not** establish that `image` works on a moved account; it establishes only that the claim it cannot is unfounded. Read the remaining exit 36 as *gflow does not drive this yet*, never as *the host cannot do it*; see [the spike](docs/superpowers/spikes/2026-09-07-migrated-composer-has-an-image-mode.md). +- **The migrated composer image path is now driven** ([#692](https://github.com/ffroliva/gflow-cli/issues/692)): the first probe established a hit-testable Image mode; the 2026-09-08 follow-up captured real T2I and local-file I2I submissions on `ogiZ0b`, including page-owned reCAPTCHA, upload ids, response records and signed JPEG downloads. See [the submit-wire spike](docs/superpowers/spikes/2026-09-08-migrated-image-submit-wire.md). - **Image commands on a moved account** ([#673](https://github.com/ffroliva/gflow-cli/issues/673), fixed in v0.69.0): through v0.68.0 `image t2i` / `i2i` (and `upscale`, `extend`) died with exit 1 `RecaptchaError` within seconds, before any submit, instead of the exit 36 above, because the labs client minted the reCAPTCHA token on the `flow.google.com` project grid before any migration guard ran. The guard now runs at the mint. If you still see a `RecaptchaError` on a moved account right after `auth login`, that is the labs logged-out landing page from the [#644](https://github.com/ffroliva/gflow-cli/issues/644) cookie harvest, not this. Google is moving Flow off Labs onto its own origin. On a migrated page load, @@ -48,7 +48,7 @@ This is **not** selector rot, not [#493](https://github.com/ffroliva/gflow-cli/i and not the agentic cohort — the agentic indicators are absent too. It is a different origin serving different markup. -**What works now — text-to-video on the migrated host.** `gflow video t2v … --project ` +**What works now — generation on the migrated host.** `gflow video t2v … --project ` drives the migrated editor directly (settings through its option groups, prompt, submit, then it observes the app's own `batchexecute` status replies and downloads the clip). Two real clips were generated this way on 2026-09-05 — spike @@ -56,10 +56,11 @@ the clip). Two real clips were generated this way on 2026-09-05 — spike (`GFLOW_CLI_FLOW_HOST=auto`): flow.google.com is the **default** host for that command on every account — moved or not; `flow.google.com` forces it for everything, and `labs.google` switches the migrated composer off. Limits today: `--project` is required (project creation from the -migrated editor is not ported), and only `t2v`, `i2v` from a local `--initial-frame` (no end frame, +migrated editor is not ported), and `t2v`, `i2v` from a local `--initial-frame` (no end frame, no UUID/`@Name` frame — the migrated Frames picker exposes no media id in its DOM, so a frame is found by file name after gflow uploads it through the editor), and `r2v` from local `--ref` files -(see the next paragraph) — everything else still exits 36. +(see the next paragraph), plus `image t2i` and local-file `image i2i` — unsupported +forms still exit 36. **`r2v` from local `--ref` files also runs there (2026-09-06).** Each file is uploaded through the same editor toolbar path i2v uses — so the app's own `maseQ` reply names the @@ -73,6 +74,12 @@ References by `@Name` and character entities stay on labs, for the same reason a UUID does: the picker exposes no media id to anchor on. Capture: [2026-09-05-migrated-r2v-attach-surface](docs/superpowers/spikes/2026-09-05-migrated-r2v-attach-surface.md). +**Images also run there (2026-09-08).** The driver selects Image mode, Nano Banana 2 +or Pro, any supported aspect and count 1–4, then observes the page's own synchronous +`ogiZ0b` reply. Local I2I files use the existing `maseQ` upload and mention path; every +uploaded id must appear in the submit body before the result is trusted. UUID/entity +references, Agent instructions, and Imagen 4 remain pre-submit refusals on this host. + **Models on the migrated host.** Its picker is driven for every tier the account's menu actually renders, `veo-lite-lp` included — matched by the `[Lower Priority]` tag alone, exactly as on labs.google. That entry was first captured on 2026-09-05 @@ -100,8 +107,9 @@ which Playwright updates in the same tick as the hand-off navigation — and fai with the distinct, non-retryable exit 36 instead of the misleading `UiSelectorDriftError` (exit 23, "file a selector bug"). `_check_logged_in` also accepts the migrated host, so a migrated load is no longer misread as a -logged-out session. Text-to-video is driven (above); image, i2v/r2v, characters, -scenes, extend, instructions, tools and project creation are the remaining work +logged-out session. The generation forms listed above and characters are driven; +scenes, extend, instructions, tools, project creation, and the named reference/model +variants are the remaining work tracked here — no retry helps for those until each is ported. > **v0.66.1's fast-fail did not fire in the field, and v0.66.2 is the correction.** @@ -1515,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 @@ -1525,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 de3ebd74..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: > @@ -127,7 +127,7 @@ gflow CLI → Provider (interchangeable) → Flow (ui_automation) / Mock (te **Current transport:** `ui_automation` drives Flow through a persistent Playwright Chromium profile. It is production-stable and verified end-to-end every release (see the per-release `LIVE_VERIFICATION_*` evidence files). -**Two Flow frontends:** Google is moving accounts from `labs.google` onto `flow.google.com` ([#639](https://github.com/ffroliva/gflow-cli/issues/639)) — same product, different widget toolkit and wire protocol (`batchexecute` instead of `aisandbox-pa`). `flow.google.com` is the default host for what gflow has ported to it — text-to-video, image-to-video from a local start frame, and reference-to-video from local `--ref` files, today — on every account; the rest of the matrix keeps the labs driver until ported (`GFLOW_CLI_FLOW_HOST`, see [CONFIGURATION](docs/CONFIGURATION.md#gflow_cli_flow_host)). +**Two Flow frontends:** Google is moving accounts from `labs.google` onto `flow.google.com` ([#639](https://github.com/ffroliva/gflow-cli/issues/639)) — same product, different widget toolkit and wire protocol (`batchexecute` instead of `aisandbox-pa`). The migrated driver covers text-to-video, image-to-video from a local start frame, reference-to-video from local files, text-to-image, and image-to-image from local files. Image generation supports Nano Banana 2 / Pro, the four aspect ratios measured on that host (16:9, 4:3, 1:1, 9:16), and counts 1–4; an existing project is required at the transport boundary. UUID/entity references, instructions, Imagen 4, and the rest of the matrix keep the labs driver until ported (`GFLOW_CLI_FLOW_HOST`, see [CONFIGURATION](docs/CONFIGURATION.md#gflow_cli_flow_host)). **What's blocked:** a pure HTTP transport for video generation. The video upload endpoint returns HTTP 401 under non-Chrome browsers plus a reCAPTCHA mint we cannot reproduce headlessly. Three earlier HTTP strategies (`evaluate_fetch`, `bearer`, `sapisidhash`) live under `src/gflow_cli/api/transports/experimental/` for research, off the production path. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6f023d90..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. @@ -436,7 +436,7 @@ We attempted three pure-HTTP transport strategies before settling on `ui_automat All three now live as standalone modules under `src/gflow_cli/api/transports/experimental/` (`evaluate_fetch.py` / `bearer.py` / `sapisidhash.py`), preserved for reference and future iteration. None survives Google's anti-bot stack for mutation/generation endpoints, so the production path is `ui_automation`. -**The migrated `flow.google.com` host (#639, v0.67.0).** Google is moving accounts off `labs.google` onto `flow.google.com`, a rewritten frontend with a different widget toolkit and a `batchexecute` wire instead of `aisandbox-pa` REST. `src/gflow_cli/api/transports/migrated_composer.py` drives that editor for text-to-video, image-to-video from a local start frame, and reference-to-video from local `--ref` files (v0.70.0) — still through the same real-Chrome Playwright session — by operating the composer UI and then *observing* the app's own `batchexecute` replies (`YhhmEf`/`eb1hJf` submit, `jwpduf` status, `as29s` result) before downloading the clip. A start frame goes in through the editor's own Upload entry (the app's `maseQ` reply names the media id — `migrated.frame_uploaded`), is bound in the Frames picker by file name (`migrated.frame_bound`), and the submit *request* is inspected for that id and an i2v model key before the run is trusted (`WireFormatError` otherwise); a modal over a fresh editor is closed first (`migrated.dialog_dismissed`). `GFLOW_CLI_FLOW_HOST` routes between the two (`auto` = migrated host for every request it can serve, `--project` required there); the rest of the command matrix keeps `ui_automation` on unmoved accounts and exits 36 on moved ones. Recon: `docs/superpowers/spikes/2026-09-05-migrated-host-wire-protocol.md`. +**The migrated `flow.google.com` host (#639, v0.67.0).** Google is moving accounts off `labs.google` onto `flow.google.com`, a rewritten frontend with a different widget toolkit and a `batchexecute` wire instead of `aisandbox-pa` REST. `src/gflow_cli/api/transports/migrated_composer.py` drives that editor for text-to-video, local-file image-to-video/reference-to-video, text-to-image, and local-file image-to-image — still through the same real-Chrome Playwright session — by operating the composer UI and then *observing* the app's own replies. Video uses `YhhmEf`/`eb1hJf`/`MZZa6b` submit plus `jwpduf`/`as29s` status/result; image uses synchronous `ogiZ0b` replies carrying signed JPEG URLs. Local files go through the editor's own `maseQ` upload and the resulting media ids are asserted in the outgoing submit body before the run is trusted (`WireFormatError` otherwise). The migrated page owns image reCAPTCHA minting; the client skips the labs token path so moved accounts no longer fail with `RecaptchaError` on the root grid. `GFLOW_CLI_FLOW_HOST` routes between the two (`auto` keeps migrated images on moved accounts and uses labs for them on unmoved accounts; `--project` is required on the migrated path); unsupported UUID/entity/instruction/Imagen-4 image forms still fail before submit. Recon: `docs/superpowers/spikes/2026-09-08-migrated-image-submit-wire.md`. **Standalone-only transports.** `bearer` and `sapisidhash` discard any caller-supplied Playwright page and launch their own browser under a fresh `ProfileLease`, so they are **standalone-only** — they cannot run inside a `FlowApiClient` that already holds the profile lease (the second acquire would self-lock with `ProfileLockedError`). Selecting either via `GFLOW_CLI_TRANSPORT` (or the Python API) while the client owns the profile now fails fast with a clear `ConfigurationError` naming the transport, rather than the opaque lock error. `evaluate_fetch` is exempt: it reuses the client's shared page and takes no second lease. The standalone-only set lives in `STANDALONE_ONLY_TRANSPORTS` (`api/transports/__init__.py`); to drive `bearer`/`sapisidhash`, run them outside an owning client. diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index 2fe574be..dcc4b5bf 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -143,7 +143,7 @@ for you to sign in. The CLI automatically detects success and persists the sessi ```bash gflow auth login # default profile, auto browser gflow auth login --profile work # named profile (creates if missing) -gflow auth login --browser chrome # force real Chrome (bypasses G12 block) +gflow auth login --browser chrome # force the chrome strategy (real Chrome + its profile marker) ``` Re-running this command refreshes an expired session: it reuses the existing profile dir, @@ -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/CHARACTER_RECON.md b/docs/CHARACTER_RECON.md index 970fc046..d5fe3da9 100644 --- a/docs/CHARACTER_RECON.md +++ b/docs/CHARACTER_RECON.md @@ -207,7 +207,9 @@ With both fixed, the deep-linked editor binds reliably. See - `gflow character rm ` → entity delete (capture verb later). - Reuse: **`--character ` (repeatable → multi-reference)** on `gflow video` → adds `referenceEntities:[{entityId}]` to `video:batchAsyncGenerateVideoReferenceImages` (R2V mode, async + poll). - Same flag on `gflow image` pending an image-path confirm. + Same flag on `gflow image` remains pending on the migrated host: local-file image + generation is confirmed, but character/entity binding is intentionally refused until + its migrated picker wire is captured. ## Reuse from PR #123 (kittinan) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f5ab0198..dd71875a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -361,11 +361,11 @@ GFLOW_CLI_HISTORY_PROMPTS=redacted gflow image t2i "confidential brief" **What:** Which Flow frontend gflow drives. Google is moving accounts from `labs.google/fx/tools/flow` onto `flow.google.com` one at a time ([#639](https://github.com/ffroliva/gflow-cli/issues/639)); the two are the same product on different widget toolkits and different wire protocols, so each has its own driver. **Values:** -- `auto` (default) — **`flow.google.com` is the default host for every request it can serve today** (`video t2v`, `video i2v` from a local `--initial-frame` with no end frame, and `video r2v` from local `--ref` files — all with `--project`), on moved and unmoved accounts alike — the new host serves both. A request the new host cannot serve yet keeps the labs driver on an unmoved account; a moved account has no labs to fall back to: other modes exit 36, a missing `--project` or a labs-only model exit 11. +- `auto` (default) — **`flow.google.com` is the default host for every video request it can serve today** (`video t2v`, local-file `video i2v` and `video r2v`, all with `--project`) on moved and unmoved accounts. Image requests stay on labs for an unmoved account, while a moved account uses the migrated composer for `image t2i` and local-file `image i2i`. A request the new host cannot serve keeps the labs driver on an unmoved account; a moved account has no labs fallback and unsupported forms exit 36/11 before submit. - `flow.google.com` — force the migrated composer for everything, including what it cannot serve yet (those requests then exit 36/11 instead of falling back). - `labs.google` — never use the migrated composer; a moved account fails with exit 36 (kill switch). **Default:** `auto` -**Scope today:** the migrated composer covers `gflow video t2v`, `gflow video i2v --initial-frame ` and `gflow video r2v --ref `, each with `--project ` (settings via the option groups, model picker, local files uploaded through the editor's own Upload entry — bound in the Frames picker by file name for i2v, attached as `@` mentions in the prompt for r2v — submit, status observed on the page's own `batchexecute` replies, download from the signed CDN URL). An end frame, a frame given by UUID or `@Name`, references given by `@Name` or `--reference-entity`, image, characters, scenes, extend, instructions and tools are not ported yet and exit 36 on a moved account. MCP inherits the setting from the server/daemon environment, not per call. +**Scope today:** the migrated composer covers `gflow video t2v`, local-file `video i2v` / `r2v`, `gflow image t2i`, and local-file `gflow image i2i`. Images support Nano Banana 2 / Pro, the four aspect ratios enumerated on that host (16:9, 4:3, 1:1, 9:16 — `3:4` was not present and is refused before submit) and count 1–4; the page owns the `ogiZ0b` reCAPTCHA + submit and the response already contains completed signed image URLs. An end frame, UUID/name references, character entities, Agent instructions, Imagen 4, scenes, extend, instructions and tools are not ported yet and fail before submit on a moved account. MCP uses the same image service and queue payload, and inherits this setting from the server/daemon environment rather than per call. ### `GFLOW_CLI_PREFER_CLASSIC` *(deprecated — use `GFLOW_CLI_UI_MODE=classic`)* diff --git a/docs/DEBUGGING.md b/docs/DEBUGGING.md index 5bc50f6d..10755e5c 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 | @@ -317,8 +317,9 @@ remediations. The most-hit categories: ([selector-cascades](#selector-cascades)) - **Session expiry on a long-running worker** → use `health_check()` + recreate on `BrowserSessionClosedError` -- **reCAPTCHA score too low** → must use real Chrome - (`--browser chrome`); never Playwright's bundled Chromium +- **reCAPTCHA score too low** (generation, *not* sign-in) → must use real Chrome + (`--browser chrome`). Sign-in is a separate question: the 2026-09-08 spike measured + `navigator.webdriver`, not the binary, as what Google's sign-in rejects. ## See also diff --git a/docs/INDEX.md b/docs/INDEX.md index 2912d874..106e3aca 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -149,7 +149,7 @@ Slash commands for Claude Code, stored in `.claude/commands/gflow/`. All prefixe **"How do I upgrade gflow-cli?"** → [USAGE § `gflow update`](USAGE.md#gflow-update) (`gflow update` runs the installer that put it here — uv tool / pipx / pip; `--check` only reports; source installs are refused with exit 11) **"How do I run the MCP server so an agent can't spend credits?"** → [CONFIGURATION § GFLOW_MCP_NO_SPEND](CONFIGURATION.md#gflow_mcp_no_spend) + [MCP § Option A2](MCP.md#option-a2-read-only-server) (`gflow mcp run --no-spend` / `GFLOW_MCP_NO_SPEND=1`; v0.57.0 — both generate tools go unregistered, not merely refused) **"Which Flow UI arm does a run require, and why did it exit 2 or 28?"** → [CONFIGURATION § GFLOW_CLI_UI_MODE](CONFIGURATION.md#gflow_cli_ui_mode) (`auto` ≡ `classic` for **both** images and video — video since v0.57.0/#299, images since #595; unreachable classic aborts pre-submit with exit 28, $0; explicit `agentic` on video exits 2) -**"Which Flow frontend does a run drive — labs.google or the migrated flow.google.com — and why did it exit 36 or 11?"** → [CONFIGURATION § GFLOW_CLI_FLOW_HOST](CONFIGURATION.md#gflow_cli_flow_host) (`auto` = flow.google.com for every request it can serve — `t2v`, `i2v` from a local `--initial-frame`, and `r2v` from local `--ref` files, with `--project`, on moved and unmoved accounts; `flow.google.com` forces it; `labs.google` is the kill switch. On a moved account an end frame, a UUID/`@Name` frame, references by `@Name`/`--reference-entity` and everything except `t2v`/`i2v`/`r2v` exit 36, non-retryable, and a missing `--project` exits 11) · recon: [character surface + reCAPTCHA mint](superpowers/spikes/2026-09-06-migrated-character-surface-and-recaptcha.md), [migrated-host wire protocol](superpowers/spikes/2026-09-05-migrated-host-wire-protocol.md), [handoff mechanism](superpowers/spikes/2026-09-04-migrated-host-handoff-mechanism.md), [agent mode hides the settings trigger](superpowers/spikes/2026-09-08-migrated-composer-agent-mode-hides-settings.md), [frames attach (i2v)](superpowers/spikes/2026-09-05-migrated-frames-attach.md) · evidence: [LIVE_VERIFICATION_v0.67.0](LIVE_VERIFICATION_v0.67.0.md) (t2v), [LIVE_VERIFICATION_v0.69.0](LIVE_VERIFICATION_v0.69.0.md) (i2v from a local start frame, v0.69.0), [LIVE_VERIFICATION_v0.70.0](LIVE_VERIFICATION_v0.70.0.md) (r2v on the migrated host; character create verified there in #703) +**"Which Flow frontend does a run drive — labs.google or the migrated flow.google.com — and why did it exit 36 or 11?"** → [CONFIGURATION § GFLOW_CLI_FLOW_HOST](CONFIGURATION.md#gflow_cli_flow_host) (`auto` = flow.google.com for every request it can serve — `t2v`, `i2v` from a local `--initial-frame`, `r2v` from local `--ref` files, `image t2i`, and `image i2i` from local files, with `--project`, on moved and unmoved accounts; `flow.google.com` forces it; `labs.google` is the kill switch. On a moved account an end frame, a UUID/`@Name` frame, references by `@Name`/`--reference-entity` and everything except `t2v`/`i2v`/`r2v` exit 36, non-retryable, and a missing `--project` exits 11) · recon: [character surface + reCAPTCHA mint](superpowers/spikes/2026-09-06-migrated-character-surface-and-recaptcha.md), [migrated-host wire protocol](superpowers/spikes/2026-09-05-migrated-host-wire-protocol.md), [handoff mechanism](superpowers/spikes/2026-09-04-migrated-host-handoff-mechanism.md), [agent mode hides the settings trigger](superpowers/spikes/2026-09-08-migrated-composer-agent-mode-hides-settings.md), [frames attach (i2v)](superpowers/spikes/2026-09-05-migrated-frames-attach.md) · evidence: [LIVE_VERIFICATION_v0.67.0](LIVE_VERIFICATION_v0.67.0.md) (t2v), [LIVE_VERIFICATION_v0.69.0](LIVE_VERIFICATION_v0.69.0.md) (i2v from a local start frame, v0.69.0), [LIVE_VERIFICATION_v0.70.0](LIVE_VERIFICATION_v0.70.0.md) (r2v on the migrated host; character create verified there in #703) **"Will this get my Google account flagged or banned? What protects me?"** → [ACCOUNT_SAFETY.md](ACCOUNT_SAFETY.md) — the operational answer in one place: rate limit vs WAF block vs ban (they are different), what the tool does to stay unremarkable (headed real Chrome, ±25% interaction jitter, 0.5–1.5 s submission pacing, one project per run, isolated profiles, refuse-don't-retry), what it deliberately does **not** do (no proxies, no fingerprint spoofing, no headless unlock), the knobs, the [#241](https://github.com/ffroliva/gflow-cli/issues/241) field data on what actually triggers a 403, and what we cannot promise. Legal statement: [DISCLAIMER](../DISCLAIMER.md) **"I got a 403 / `PUBLIC_ERROR_UNUSUAL_ACTIVITY` — what now?"** → [ACCOUNT_SAFETY § When Google does push back](ACCOUNT_SAFETY.md#when-google-does-push-back) (stop, cool down 30–60 min, use real Chrome meanwhile, switch profile, widen jitter); cadence evidence in [DEBUGGING § WAF cadence](DEBUGGING.md#waf-cadence); full entry in [KNOWN_ISSUES](../KNOWN_ISSUES.md) @@ -158,7 +158,7 @@ Slash commands for Claude Code, stored in `.claude/commands/gflow/`. All prefixe **"A command failed — where is the incident bundle and what's in it?"** → [DEBUGGING § Automatic incident bundles](DEBUGGING.md#automatic-incident-bundles) (layout, triggers, review-before-sharing); privacy boundaries: [SECURITY § Automatic incident bundles](SECURITY.md#automatic-incident-bundles-gflow_cli_incident_capture-default-on); disable via [CONFIGURATION § GFLOW_CLI_INCIDENT_CAPTURE](CONFIGURATION.md#gflow_cli_incident_capture) **"Flow's UI broke a selector — how do I diagnose it?"** → [DEBUGGING § Inspecting Flow's live UI](DEBUGGING.md#inspecting-flows-live-ui) **"What does each `ui_automation.*` log event mean?"** → [DEBUGGING § Listener & HTTP-layer debugging](DEBUGGING.md#listener--http-layer-debugging) -**What was actually live-verified for the latest release?** → latest: [LIVE_VERIFICATION_v0.71.1](LIVE_VERIFICATION_v0.71.1.md) (**two migrated-host error paths stopped blaming the wrong thing, 2026-09-08, $0 across three profiles.** The agent-mode recovery landed earlier in this same release (#749) and worked, but its first cut collapsed three outcomes into one message — chip clicked / chip found but click blocked / chip clicked and mode still on — so a modal eating the click sent users to toggle a chip that was never the problem, and genuine selector drift *after* the mode was left was filed under an account setting the driver had already changed. Verified by an e2e that drives the account INTO agent mode and carries its own neutered-selector control; run **twice**, because review fixes landed after the first run and it no longer covered the code. **The larger find:** an account's FIRST upload on this host is blocked by Flow's one-time *"Rights to use this image"* dialog, which renders *after* the file chooser hands the file over — so `_dismiss_dialog` never sees it and the driver spent 60 s waiting for a request the page had already declined to make, then advised re-encoding the image. Six runs on `ci-probe` close the chain: guard fires while the dialog is up → owner-authorised accept → uploads (`media_id 8914400f`) → fresh session uploads clean with `dialog: None`. The order was forced, because the dialog is one-off and accepting it destroys the failing state — so the guard was written and verified BEFORE anything was clicked, and its firing branch is now unrepeatable here. `maseQ` is confirmed **not** renamed (six uploads, three profiles), killing #719's top hypothesis. Also: Flow's queue listing is on **`Zzl0ze`**, not the `jwpduf`/`as29s` progress polls, and two `abra_r2v_8s` records sat at status 6 for ~20 h unseen by any CLI command (#741). Three documents are corrected rather than patched — a KNOWN_ISSUES entry that told #719's reader this dialog did not affect gflow-cli, and a v0.71.0 host label calling `ci-probe` *labs* when it is migrated, which helped a credit-based theory survive four runs. Recorded as NOT verified: #719's second failure shape — an upload request that leaves the page and is never answered, ~1 run in 4 — which is unfixed and keeps #719 open.) · previous: [LIVE_VERIFICATION_v0.71.0](LIVE_VERIFICATION_v0.71.0.md) (**`gflow character create --voice` verified end to end for the first time, 2026-09-07 on profile `ffroliva`.** Before this release a repo-wide grep for `--voice` across `tests/e2e/` matched **nothing** — every voice test was a unit test of the hardcoded `VOICES` constant and the one that looked live parsed a fixture, so a voice that silently failed to attach was invisible to the whole suite while the command exited 0. A live create + `character show` read-back settles a contradiction between two of our own documents: `sent='Charon' stored='Charon' identical=True`, so the Capitalized canonical form round-trips and `CHARACTER_RECON.md`'s "preset id is the lowercased name" does not describe today's wire. Both docs also now record that `personalityNotes` is **Agent-scoped** per Flow's own editor copy, not a control on the audio engine. **A claim that was in the code is overturned:** the `_unported_form` entity guard said the submit "never produces a reply" and that the backend refuses — in fact `MZZa6b` replies with a null payload and the generation is **accepted and queued** (Flow types it `abra_r2v_8s` and renders it); the failure is the *observer*, which waits out `SUBMIT_REPLY_BUDGET_S` (60 s, calibrated on 4.0–4.6 s replies against an idle queue) and exits 9 while the video is still rendering. The guard stays until the observer is fixed, because a timeout reported on a healthy generation is worse than an explicit refusal. Also verified: credit shortfall reports exit **37** with `_raise_if_out_of_credits` called from **both** give-up paths — and it is *short for the selected model*, not empty (the measured account held **50** and asked for `veo-quality`, which costs **100**); the "+ New project" CTA anchored structurally on `add` (Tier-1 matches **1** where every previous entry matched **0**, control 47 ligature nodes) after this same release first asserted the control did not exist and a $0 run disproved it in one click; and the incident-bundle DOM dump de-blinded — it queried `i.google-symbols` only, so **every bundle a migrated user sent carried an empty ligature list**, which is why #727 and #731 stayed invisible. One item recorded as NOT verified rather than omitted: whether a bound character's **voice reaches rendered audio** (#738) — attachment is proven, application is not, because no entity-bound generation has yet returned a file to measure. The blocker is no longer credits or account access but retrieval, and the measurement is pre-calibrated: plate-bound takes of one character gave **88 / 103 / 118 Hz** against a **4.3 Hz** engine noise floor, so an applied Algenib should sit near its sample's **163.3 Hz**.) · previous: [LIVE_VERIFICATION_v0.70.0](LIVE_VERIFICATION_v0.70.0.md) (**reference-to-video on the migrated `flow.google.com` host, verified 2026-09-06 on profile `denon82`.** Three r2v runs bound their references every time; the decisive one is semantic rather than structural — two hand-drawn reference images went in and the clip came back carrying the same marker strokes, face and paper texture, which is what separates "Flow bound the reference" from "Flow accepted and ignored it". A four-beat, two-character piece was then produced end to end and joined with the concat filter: 19.000s, 1280x576, `clip_qa` **ok**, A/V **+0.000s**, both actors recognisable across every cut from one face plate plus the canon repeated verbatim. Also verified as exit-code corrections on the real CLI: `-o ` now exits **2 in 0.8s** instead of exit 1 after ~2 min **and a billed, orphaned clip**; `character create` on a moved account exits **36 immediately** instead of a bare `RuntimeError` exit 1 after a 20s wait. Four items recorded as NOT verified rather than omitted: #692's original failure could not be reproduced [the reporter's own re-run was on a build without the fix, so it shows the failure is intermittent, not that the fix works]; Flow CHARACTER entities were never exercised because `character create` cannot run on any account available here; `image`/`scene`/`movie`/`extend` all exit 36 on the migrated host and no unmoved account exists here; and the sign-in-interstitial fidelity question from #699 has no bundle evidence either way.) · previous: [LIVE_VERIFICATION_v0.68.0](LIVE_VERIFICATION_v0.68.0.md) (`gflow update` self-upgrading a real `uv tool` and a real `pipx` install from their own running `gflow.exe` to the PyPI release, Windows 11, zero credits — see its own row below) · previous: [LIVE_VERIFICATION_v0.67.0](LIVE_VERIFICATION_v0.67.0.md) (the migrated `flow.google.com` driver — see its own row below) · previous: [LIVE_VERIFICATION_v0.64.0](LIVE_VERIFICATION_v0.64.0.md) (**#626 `--model omni-flash --end-frame` — first+last interpolation on Omni 1.1 Flash, verified 2026-09-02.** The guard this release removes was evidence-gated, so it was retired on evidence: a route-aborted submit capture on **two distinct Google accounts** (`ffroliva`, `denon82`) fired `video:batchAsyncGenerateVideoStartAndEndImage` with `startImage` AND `endImage` non-null at **zero credits**, ruling out a single-account artifact. The decisive layer is semantic, not structural: the paid 4s generation's **last frame IS the supplied end image** and its first frame is the start image — two visibly different subjects — which is the only check that distinguishes "Flow bound the end frame" from "Flow accepted and ignored it", and which layers 1–4 would pass either way. Render: 4.01s / 720×1280 / h264+aac / 2.4 MB, `MEDIA_GENERATION_STATUS_SUCCESSFUL`. The static capability table is **deleted**, not corrected — it went stale silently once and would again; a post-submit route check (`_assert_i2v_route`) now fails a run whose end frame Flow dropped back to the `StartImage` route, catching a rollback on any account without anyone re-reading a support page. One item is recorded as NOT verified rather than omitted: `--duration 10` + end frame is **submit-verified only** (`duration_set seconds=10` on the correct route with both images bound), because the status poll returned HTTP 401 mid-run and a retry 401'd at `project.create` before submitting — [#561](https://github.com/ffroliva/gflow-cli/issues/561), pre-existing, so verification was stopped rather than spending further credits on an environmental fault.) · previous: [LIVE_VERIFICATION_v0.63.0](LIVE_VERIFICATION_v0.63.0.md) (**`gflow video extend` — continuing a clip past Flow's 8s ceiling, verified 2026-09-01 on profile `ffroliva`, 20 Veo credits.** The decisive observation is that segment 2's `source_media_id` **is** segment 1's `media_id` (`0c9364f3…` seeded from `b9458021…`, then `648f9291…` seeded from `0c9364f3…`) — tail-only chaining, which no mock can establish; had it re-seeded from the source, the output would have been two divergent continuations of the same moment instead of one continuous shot. `extend_model_resolved` logged `candidate_count=99` against `SERVICE_TIER_INTERMEDIATE`, proving the key came from the live capability listing rather than a constant, and plan cost matched actual spend (`20 credits total, balance 875` → `Extended — 2/2 segment(s), 20 credits`). Render: 23.02s / 1280×720 / 24fps / h264+aac. **The run found a defect the offline suite is structurally incapable of seeing:** an extend segment carries **7.000000s** of content, not the 8 Flow advertises and bills, so server-side concat pads every internal seam with a frozen frame and digital silence (15s at −75.1 dB against −29 neighbours, reproduced on a second independent render) — filed in KNOWN_ISSUES with the three questions that must be answered before any clamp, and the reason `--extend N` on t2v/i2v was deliberately NOT shipped. Seven items are recorded as NOT verified rather than omitted: portrait `9:16`, `--aspect` against a mismatched source, chains longer than 2 segments, `--resume-from` against a live partial scene, the insufficient-credits refusal, a live Ctrl+C, and `OperationKind.EXTEND` rows.) · previous: [LIVE_VERIFICATION_v0.62.1](LIVE_VERIFICATION_v0.62.1.md) (**#604 `--model omni-flash` selects again after Flow's `Omni Flash` → `Omni 1.1 Flash` rename, verified 2026-08-30 on profile `ffroliva` at ZERO Veo credits.** The production `_select_video_model` was driven against the real picker: the shipped selector resolves verbatim — `model_selected model=omni_flash via="[role='menuitem']:has-text('Omni'):has-text('Flash'):not(:has-text('[Lower Priority]'))"` — and all six cases came back as expected: the four offered tiers SELECT, a tier Flow does not offer REFUSES, and a deliberately ambiguous selector REFUSES rather than resolving `.first`. Selection happens before submit, so the whole matrix is free. The transport's own refusal diagnostic read the live menu back as `Omni 1.1 Flash / Veo 3.1 - Lite / Fast / Quality`, independently corroborating the fixture. Four items are recorded as NOT verified rather than omitted: the rename *direction* [the `Omni Flash` baseline is a different account, locale and date], an actual `Omni ... [Lower Priority]` entry [Flow has never offered one], a full credit-spending generation on omni-flash, and #539's absence question, which stays open.) · previous: [LIVE_VERIFICATION_v0.62.0](LIVE_VERIFICATION_v0.62.0.md) (**#595 `auto` ≡ `classic` for images, verified 2026-08-28 on profile `ci-probe` — an account Flow had moved to the agentic cohort the day before — at ZERO Veo credits.** With no flags and no env vars the run logged `ui_driver.ui_mode.attempt_exit_agent` → `ui_driver.bound mode=classic ui_mode=classic` and generated a real 768x1376 JPEG (exit 0); the day before, the identical command on the same account bound `auto`→`agentic` and failed. The same log independently closed **#183**: `mode_switch_trigger` and `image_mode_tab`, the selectors it reported as unfindable, both matched. #591 was proven against the real 500-row catalog — **0** occurrences of the string `"None"`, 31 proper JSON `null`s, over a catalog holding 119 NULL-bearing rows. #592 shows exactly one `client.account_locale_cached` per command. Three items are recorded as NOT verified rather than omitted: the exit-28 abort on a *pinned* agentic account, the #597 batch inter-prompt guard, and any fresh announcement modal — the last two because all three accounts have already acked the current changelog, so no modal can be raised until Google ships the next one.) · previous: [LIVE_VERIFICATION_v0.61.0](LIVE_VERIFICATION_v0.61.0.md) (**#539 video-model refusal + #586 image-model refusal and server-side attribution, verified 2026-08-26/27 on profile `denon82`, ONE Veo generation total** — the video fix was proven by a **zero-credit A/B against the stashed pre-fix source running on live Flow**: the old code returned SELECTED for `veo-lite-lp`, a model Flow does not offer to this account, and for a deliberately ambiguous selector matching 3 entries; the new code refuses both with exit 18 naming what Flow offered. Model selection happens before submit, so refusals cost nothing and the A/B was free. The happy path still generates: `--model omni-flash` → exit 0, a real 2.2 MB `ftypisom` mp4, catalog recording `omni_flash`. Two items are recorded as NOT verified rather than omitted: three of #584's four navigation-settle sites were never exercised [no bearer refresh occurred in these runs], and #582's canary self-re-exec has its first real exercise tonight. Also falsified: #539's recorded note that the video picker uses a different trigger — the two constants are byte-identical strings, and the repeated empty menu captures were the capture's own fault.) · previous: [LIVE_VERIFICATION_v0.60.0](LIVE_VERIFICATION_v0.60.0.md) +**What was actually live-verified for the latest release?** → latest: [LIVE_VERIFICATION_v0.72.0](LIVE_VERIFICATION_v0.72.0.md) (**auto-closing `gflow auth login`, verified 2026-09-09 on a migrated account at $0 across six runs.** Four paths: auto-close fires (`probe=in_context` at `elapsed_s` **84.8**, reproduced 3x), a manual close still verifies from disk (exit 0, never exit 12), `--browser auto` selects and completes, and a window closed **mid-2FA** is noticed in **2 s** (exit 8) where it previously ran the full 600 s deadline with the session endpoint touched **zero** times. **Two runs were discarded rather than counted**, and that is the useful part: the first returned exit 0 at `elapsed_s: 0.2` because a throwaway profile had survived the previous session and nobody signed in — a cached session reading as a clean pass — and three attempts at the manual-close path all returned exit 0 while being the *auto-close* path in disguise, separable only by an **absent** `probe=in_context`. The manual path was obtained by widening the poll interval for one run so the competing branch could not fire; the 2FA path by killing the profile's own Chrome, because hand-timing lost every race on a host that completes passkey sign-in in ~15 s. **Recorded as NOT verified rather than omitted:** the migrated-host image slice (#692) was **re-run this cycle** — CLI `t2i` PASSED first-hand on a moved account, while the queued-MCP `i2i` twin failed with a typed non-retryable exit 27 whose **cause is unknown** — some dialog opened after the file was chosen and no `maseQ` left the page, on an account that has ALREADY consented, so the message's "most likely the one-time upload-terms dialog" does not fit; no incident bundle was written, so nothing names the dialog, and it is filed for a spike rather than guessed; the subprocess fallback is unit-tested only; and the OAuth-callback *mechanism* behind the poll fix is **inferred, not proven** — a host-only gate does not exclude NextAuth's callback, since it runs on the app's own origin, yet all four sign-ins passed under it, so #769 carries the spike that would settle it.) · previous: [LIVE_VERIFICATION_v0.71.1](LIVE_VERIFICATION_v0.71.1.md) (**two migrated-host error paths stopped blaming the wrong thing, 2026-09-08, $0 across three profiles.** The agent-mode recovery landed earlier in this same release (#749) and worked, but its first cut collapsed three outcomes into one message — chip clicked / chip found but click blocked / chip clicked and mode still on — so a modal eating the click sent users to toggle a chip that was never the problem, and genuine selector drift *after* the mode was left was filed under an account setting the driver had already changed. Verified by an e2e that drives the account INTO agent mode and carries its own neutered-selector control; run **twice**, because review fixes landed after the first run and it no longer covered the code. **The larger find:** an account's FIRST upload on this host is blocked by Flow's one-time *"Rights to use this image"* dialog, which renders *after* the file chooser hands the file over — so `_dismiss_dialog` never sees it and the driver spent 60 s waiting for a request the page had already declined to make, then advised re-encoding the image. Six runs on `ci-probe` close the chain: guard fires while the dialog is up → owner-authorised accept → uploads (`media_id 8914400f`) → fresh session uploads clean with `dialog: None`. The order was forced, because the dialog is one-off and accepting it destroys the failing state — so the guard was written and verified BEFORE anything was clicked, and its firing branch is now unrepeatable here. `maseQ` is confirmed **not** renamed (six uploads, three profiles), killing #719's top hypothesis. Also: Flow's queue listing is on **`Zzl0ze`**, not the `jwpduf`/`as29s` progress polls, and two `abra_r2v_8s` records sat at status 6 for ~20 h unseen by any CLI command (#741). Three documents are corrected rather than patched — a KNOWN_ISSUES entry that told #719's reader this dialog did not affect gflow-cli, and a v0.71.0 host label calling `ci-probe` *labs* when it is migrated, which helped a credit-based theory survive four runs. Recorded as NOT verified: #719's second failure shape — an upload request that leaves the page and is never answered, ~1 run in 4 — which is unfixed and keeps #719 open.) · previous: [LIVE_VERIFICATION_v0.71.0](LIVE_VERIFICATION_v0.71.0.md) (**`gflow character create --voice` verified end to end for the first time, 2026-09-07 on profile `ffroliva`.** Before this release a repo-wide grep for `--voice` across `tests/e2e/` matched **nothing** — every voice test was a unit test of the hardcoded `VOICES` constant and the one that looked live parsed a fixture, so a voice that silently failed to attach was invisible to the whole suite while the command exited 0. A live create + `character show` read-back settles a contradiction between two of our own documents: `sent='Charon' stored='Charon' identical=True`, so the Capitalized canonical form round-trips and `CHARACTER_RECON.md`'s "preset id is the lowercased name" does not describe today's wire. Both docs also now record that `personalityNotes` is **Agent-scoped** per Flow's own editor copy, not a control on the audio engine. **A claim that was in the code is overturned:** the `_unported_form` entity guard said the submit "never produces a reply" and that the backend refuses — in fact `MZZa6b` replies with a null payload and the generation is **accepted and queued** (Flow types it `abra_r2v_8s` and renders it); the failure is the *observer*, which waits out `SUBMIT_REPLY_BUDGET_S` (60 s, calibrated on 4.0–4.6 s replies against an idle queue) and exits 9 while the video is still rendering. The guard stays until the observer is fixed, because a timeout reported on a healthy generation is worse than an explicit refusal. Also verified: credit shortfall reports exit **37** with `_raise_if_out_of_credits` called from **both** give-up paths — and it is *short for the selected model*, not empty (the measured account held **50** and asked for `veo-quality`, which costs **100**); the "+ New project" CTA anchored structurally on `add` (Tier-1 matches **1** where every previous entry matched **0**, control 47 ligature nodes) after this same release first asserted the control did not exist and a $0 run disproved it in one click; and the incident-bundle DOM dump de-blinded — it queried `i.google-symbols` only, so **every bundle a migrated user sent carried an empty ligature list**, which is why #727 and #731 stayed invisible. One item recorded as NOT verified rather than omitted: whether a bound character's **voice reaches rendered audio** (#738) — attachment is proven, application is not, because no entity-bound generation has yet returned a file to measure. The blocker is no longer credits or account access but retrieval, and the measurement is pre-calibrated: plate-bound takes of one character gave **88 / 103 / 118 Hz** against a **4.3 Hz** engine noise floor, so an applied Algenib should sit near its sample's **163.3 Hz**.) · previous: [LIVE_VERIFICATION_v0.70.0](LIVE_VERIFICATION_v0.70.0.md) (**reference-to-video on the migrated `flow.google.com` host, verified 2026-09-06 on profile `denon82`.** Three r2v runs bound their references every time; the decisive one is semantic rather than structural — two hand-drawn reference images went in and the clip came back carrying the same marker strokes, face and paper texture, which is what separates "Flow bound the reference" from "Flow accepted and ignored it". A four-beat, two-character piece was then produced end to end and joined with the concat filter: 19.000s, 1280x576, `clip_qa` **ok**, A/V **+0.000s**, both actors recognisable across every cut from one face plate plus the canon repeated verbatim. Also verified as exit-code corrections on the real CLI: `-o ` now exits **2 in 0.8s** instead of exit 1 after ~2 min **and a billed, orphaned clip**; `character create` on a moved account exits **36 immediately** instead of a bare `RuntimeError` exit 1 after a 20s wait. Four items recorded as NOT verified rather than omitted: #692's original failure could not be reproduced [the reporter's own re-run was on a build without the fix, so it shows the failure is intermittent, not that the fix works]; Flow CHARACTER entities were never exercised because `character create` cannot run on any account available here; `image`/`scene`/`movie`/`extend` all exit 36 on the migrated host and no unmoved account exists here; and the sign-in-interstitial fidelity question from #699 has no bundle evidence either way.) · previous: [LIVE_VERIFICATION_v0.68.0](LIVE_VERIFICATION_v0.68.0.md) (`gflow update` self-upgrading a real `uv tool` and a real `pipx` install from their own running `gflow.exe` to the PyPI release, Windows 11, zero credits — see its own row below) · previous: [LIVE_VERIFICATION_v0.67.0](LIVE_VERIFICATION_v0.67.0.md) (the migrated `flow.google.com` driver — see its own row below) · previous: [LIVE_VERIFICATION_v0.64.0](LIVE_VERIFICATION_v0.64.0.md) (**#626 `--model omni-flash --end-frame` — first+last interpolation on Omni 1.1 Flash, verified 2026-09-02.** The guard this release removes was evidence-gated, so it was retired on evidence: a route-aborted submit capture on **two distinct Google accounts** (`ffroliva`, `denon82`) fired `video:batchAsyncGenerateVideoStartAndEndImage` with `startImage` AND `endImage` non-null at **zero credits**, ruling out a single-account artifact. The decisive layer is semantic, not structural: the paid 4s generation's **last frame IS the supplied end image** and its first frame is the start image — two visibly different subjects — which is the only check that distinguishes "Flow bound the end frame" from "Flow accepted and ignored it", and which layers 1–4 would pass either way. Render: 4.01s / 720×1280 / h264+aac / 2.4 MB, `MEDIA_GENERATION_STATUS_SUCCESSFUL`. The static capability table is **deleted**, not corrected — it went stale silently once and would again; a post-submit route check (`_assert_i2v_route`) now fails a run whose end frame Flow dropped back to the `StartImage` route, catching a rollback on any account without anyone re-reading a support page. One item is recorded as NOT verified rather than omitted: `--duration 10` + end frame is **submit-verified only** (`duration_set seconds=10` on the correct route with both images bound), because the status poll returned HTTP 401 mid-run and a retry 401'd at `project.create` before submitting — [#561](https://github.com/ffroliva/gflow-cli/issues/561), pre-existing, so verification was stopped rather than spending further credits on an environmental fault.) · previous: [LIVE_VERIFICATION_v0.63.0](LIVE_VERIFICATION_v0.63.0.md) (**`gflow video extend` — continuing a clip past Flow's 8s ceiling, verified 2026-09-01 on profile `ffroliva`, 20 Veo credits.** The decisive observation is that segment 2's `source_media_id` **is** segment 1's `media_id` (`0c9364f3…` seeded from `b9458021…`, then `648f9291…` seeded from `0c9364f3…`) — tail-only chaining, which no mock can establish; had it re-seeded from the source, the output would have been two divergent continuations of the same moment instead of one continuous shot. `extend_model_resolved` logged `candidate_count=99` against `SERVICE_TIER_INTERMEDIATE`, proving the key came from the live capability listing rather than a constant, and plan cost matched actual spend (`20 credits total, balance 875` → `Extended — 2/2 segment(s), 20 credits`). Render: 23.02s / 1280×720 / 24fps / h264+aac. **The run found a defect the offline suite is structurally incapable of seeing:** an extend segment carries **7.000000s** of content, not the 8 Flow advertises and bills, so server-side concat pads every internal seam with a frozen frame and digital silence (15s at −75.1 dB against −29 neighbours, reproduced on a second independent render) — filed in KNOWN_ISSUES with the three questions that must be answered before any clamp, and the reason `--extend N` on t2v/i2v was deliberately NOT shipped. Seven items are recorded as NOT verified rather than omitted: portrait `9:16`, `--aspect` against a mismatched source, chains longer than 2 segments, `--resume-from` against a live partial scene, the insufficient-credits refusal, a live Ctrl+C, and `OperationKind.EXTEND` rows.) · previous: [LIVE_VERIFICATION_v0.62.1](LIVE_VERIFICATION_v0.62.1.md) (**#604 `--model omni-flash` selects again after Flow's `Omni Flash` → `Omni 1.1 Flash` rename, verified 2026-08-30 on profile `ffroliva` at ZERO Veo credits.** The production `_select_video_model` was driven against the real picker: the shipped selector resolves verbatim — `model_selected model=omni_flash via="[role='menuitem']:has-text('Omni'):has-text('Flash'):not(:has-text('[Lower Priority]'))"` — and all six cases came back as expected: the four offered tiers SELECT, a tier Flow does not offer REFUSES, and a deliberately ambiguous selector REFUSES rather than resolving `.first`. Selection happens before submit, so the whole matrix is free. The transport's own refusal diagnostic read the live menu back as `Omni 1.1 Flash / Veo 3.1 - Lite / Fast / Quality`, independently corroborating the fixture. Four items are recorded as NOT verified rather than omitted: the rename *direction* [the `Omni Flash` baseline is a different account, locale and date], an actual `Omni ... [Lower Priority]` entry [Flow has never offered one], a full credit-spending generation on omni-flash, and #539's absence question, which stays open.) · previous: [LIVE_VERIFICATION_v0.62.0](LIVE_VERIFICATION_v0.62.0.md) (**#595 `auto` ≡ `classic` for images, verified 2026-08-28 on profile `ci-probe` — an account Flow had moved to the agentic cohort the day before — at ZERO Veo credits.** With no flags and no env vars the run logged `ui_driver.ui_mode.attempt_exit_agent` → `ui_driver.bound mode=classic ui_mode=classic` and generated a real 768x1376 JPEG (exit 0); the day before, the identical command on the same account bound `auto`→`agentic` and failed. The same log independently closed **#183**: `mode_switch_trigger` and `image_mode_tab`, the selectors it reported as unfindable, both matched. #591 was proven against the real 500-row catalog — **0** occurrences of the string `"None"`, 31 proper JSON `null`s, over a catalog holding 119 NULL-bearing rows. #592 shows exactly one `client.account_locale_cached` per command. Three items are recorded as NOT verified rather than omitted: the exit-28 abort on a *pinned* agentic account, the #597 batch inter-prompt guard, and any fresh announcement modal — the last two because all three accounts have already acked the current changelog, so no modal can be raised until Google ships the next one.) · previous: [LIVE_VERIFICATION_v0.61.0](LIVE_VERIFICATION_v0.61.0.md) (**#539 video-model refusal + #586 image-model refusal and server-side attribution, verified 2026-08-26/27 on profile `denon82`, ONE Veo generation total** — the video fix was proven by a **zero-credit A/B against the stashed pre-fix source running on live Flow**: the old code returned SELECTED for `veo-lite-lp`, a model Flow does not offer to this account, and for a deliberately ambiguous selector matching 3 entries; the new code refuses both with exit 18 naming what Flow offered. Model selection happens before submit, so refusals cost nothing and the A/B was free. The happy path still generates: `--model omni-flash` → exit 0, a real 2.2 MB `ftypisom` mp4, catalog recording `omni_flash`. Two items are recorded as NOT verified rather than omitted: three of #584's four navigation-settle sites were never exercised [no bearer refresh occurred in these runs], and #582's canary self-re-exec has its first real exercise tonight. Also falsified: #539's recorded note that the video picker uses a different trigger — the two constants are byte-identical strings, and the repeated empty menu captures were the capture's own fault.) · previous: [LIVE_VERIFICATION_v0.60.0](LIVE_VERIFICATION_v0.60.0.md) **"Was the playwright upper bound raised, and on what evidence?"** → [LIVE_VERIFICATION_playwright_1.61](LIVE_VERIFICATION_playwright_1.61.md) (**`>=1.59.0,<1.60.0` → `>=1.61.0,<1.62.0` verified 2026-08-05, 1 Imagen + 1 Veo credit** — the 2026-08-03 regression that motivated the bound does NOT reproduce on 1.61.0: a live `i2v` drove the full chain with `image_uploaded status=200` → `frame_attached` → `generate_captured status=200` with `startImage` parsed, where **1.62.0 hung silently at exactly that upload step**; live `i2i` local-ref attach passed outright [96.7s]. **1.62.0 stays excluded** — never root-caused. Also found, A/B-proven pre-existing on BOTH 1.59.0 and 1.61.0: Flow dropped the duration-tab UI, so **`--duration` is currently broken for i2v** — `UiSelectorDriftError` fires correctly [refusing rather than silently accepting Flow's default, #288] but the selector needs re-deriving; that is why the i2v proof was driven via the CLI without `--duration`, and Flow's own server-side `PUBLIC_ERROR_VIDEO_GENERATION_TIMED_OUT` means no finished mp4 is claimed). **"Where is the reverse-engineered wire protocol for a feature?"** → the `*_RECON.md` design docs: [CHARACTER_RECON](CHARACTER_RECON.md) (Flow character entity protocol), [IMAGE_UPSCALE_RECON](IMAGE_UPSCALE_RECON.md) (`/v1/flow/upsampleImage` wire). Naming convention: one `_RECON.md` per reverse-engineered surface, kept as the durable spec after the feature ships. Pre-capture research recons: [ASSET_TAGGING_RECON](ASSET_TAGGING_RECON.md) (`@`-mention asset tagging — shipped in v0.40.0, see [LIVE_VERIFICATION_v0.40.0](LIVE_VERIFICATION_v0.40.0.md)). **"What was live-verified for v0.66.1?"** → [LIVE_VERIFICATION_v0.66.1](LIVE_VERIFICATION_v0.66.1.md) — **the two migrated-origin fixes, verified 2026-09-03 on profile `ffroliva` at ZERO credits, on BOTH sides of what was then read as a flapping rollout** (superseded 2026-09-04: the handoff is a one-way per-account flag and exit 36 is non-retryable since). On a real `flow.google.com` load `get_ui_driver` raised `FlowHostMigratedError` in **0 ms** (was ~36 s: ~8 s detect window + ~24 s crop cascade + 4 s URL settle), exit 36, `retryable: true` (then; `false` since 2026-09-04), `ui_driver.migrated_host_bail` logged; `await_url_settled` returned `null` in **0 ms** (measured 4018 ms before); and the locale was recovered as `en` from `html lang=en-GB` where the URL gave `null`. The no-regression half is the stronger evidence: minutes later the same command landed on the OLD host and completed **exit 0** with a real 768x1376 JPEG in 42.2 s, proving the host guard is scoped and does not short-circuit the working path. Three items recorded as NOT verified rather than omitted: driving the migrated frontend (still impossible, #639 stays open), the pt-BR recovery live (measured `html lang=pt` on `denon82` but exercised live only on `en-GB`), and the `en-GB`→`en` region reduction against a locale where region is load-bearing (`zh-Hans`/`zh-Hant`) — only two locales observed. diff --git a/docs/LIVE_VERIFICATION_v0.72.0.md b/docs/LIVE_VERIFICATION_v0.72.0.md new file mode 100644 index 00000000..ed1d0192 --- /dev/null +++ b/docs/LIVE_VERIFICATION_v0.72.0.md @@ -0,0 +1,217 @@ +# Live verification — v0.72.0 + +> What was exercised against **real** Flow for this release, and — just as importantly — +> what was **not**. + +**Date:** 2026-09-09 · **Profile:** `autoclose-verify` / `e2e-2fa-abandon` — throwaway +profiles created and deleted for these runs, authenticating `ffroliva@gmail.com` +(**migrated**, `flow.google.com`) · **Host:** Windows 11, real Google Chrome 149 · +**Cost: $0** — no run below reaches a submit, so no Veo credit is reachable. + +> **Profile hygiene note, and a false pass it caused.** The first auto-close run returned +> exit 0 with `elapsed_s: 0.2` — a *cached* session, because `profile_autoclose-verify` +> had survived the 2026-09-08 session and was never deleted. It read as a clean pass and +> proved nothing: no human signed in. Every run below was made against a profile deleted +> immediately **before** the run, and each is quoted with its `elapsed_s` so a cached +> session cannot masquerade as an interactive one again. + +--- + +## 1. `gflow auth login` closes the browser itself — VERIFIED ✅ + +Reproduced three times on fresh profiles: **84.8 s**, **63.7 s**, **51.6 s** — each an +actual human sign-in, not a cached session. + +``` +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 +``` + +Both oracles are now distinguishable in the log: `probe=in_context` (the live poll that +decides when to close) then `probe=on_disk` (`verify_flow_profile` re-checking what landed). +Before this release they emitted two identical events, leaving "did the on-disk check pass?" +unanswerable. + +## 2. Closing the window yourself still verifies — VERIFIED ✅ + +``` +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 fired, so this is genuinely +the manual-close branch and not run 1 in disguise. Exit **0**, not exit 12. + +> **This took four attempts to obtain honestly.** Attempts 1–3 all returned exit 0 and all +> three were *not* this path: sign-in takes ~50 s, detection fires the moment Flow loads, +> and auto-close won every race — the logs were shape-identical to run 1. Widening +> `POLL_INTERVAL_SECONDS` to 120 for one run made it deterministic (polls at t=0, before +> sign-in, and t=120, after the window was already closed), so the competing path could not +> fire. Only the sleep cadence was changed; the close-detection and fallback-verify logic +> under test was untouched. Instrument reverted, tree verified clean, suite re-run after. + +## 3. `--browser auto` still selects and completes — VERIFIED ✅ + +`--browser auto` → `strategy=chrome`, launched, detected, auto-closed, exit 0. + +**Scoped 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 +remains 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`. + +## 4. A window closed mid-2FA is noticed immediately — VERIFIED ✅ + +The failure mode: the host guard `continue`s without touching Playwright, so while the page +sits on `accounts.google.com` nothing raises and the reactive close-detection never fires. +Measured **before** the fix: a full run to the deadline with the session endpoint touched +**0 times** — on the default 600 s timeout, a ten-minute wait ending in exit 12 for someone +who closed the window after thirty seconds. + +Verified by killing only the Chrome processes whose `--user-data-dir` was the test profile, +five seconds in — hand-timing does not work here, see the note below: + +``` +killed chrome 09:59:39 +auth_login_browser_closed_by_user 09:59:41 <- 2 s later +EXIT=8 ELAPSED_S=15 +``` + +Exit **8** (`AuthMissingError`, "no sign-in detected") is the honest answer — no sign-in +happened — and critically **not** exit 12. 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 it was killed rather than closed by hand.** Two hand-timed attempts both returned +> `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. "Close it quickly" was a coin flip against that. + +## 5. No page URL reaches a log event — VERIFIED ✅ + +OAuth `state` and `code_challenge` live in these URLs and `data/redaction.py` matches +neither. Across all six live runs above, no emitted event contains an +`accounts.google.com` URL. Pinned offline by +`test_never_logs_a_google_url`, which now drives the page from a secret-bearing Google URL +onto the Flow host mid-poll — so the secret-bearing URL is genuinely visited and still +reaches no log event. + +## 6. `--browser internal` no longer launches the configuration Google rejects — VERIFIED (2026-09-08 spike, not re-run) + +The bundled-Chromium path shipped with **no** anti-automation flags, so `navigator.webdriver` +was `true` — and that is what the sign-in rejects. Evidence is the three-arm run of +2026-09-08 (`docs/superpowers/spikes/2026-09-08-g12-blocks-webdriver-not-playwright.md`), +human-driven on fresh unauthenticated profiles, $0: + +| arm | browser | stealth flags | `navigator.webdriver` | result | +|---|---|---|---|---| +| `bare` | real Chrome | **no** | `True` | **BLOCKED** `/v3/signin/rejected` @ 17.5 s | +| `stealth` | real Chrome | yes | `False` | PASS, cookie @ 59.4 s | +| `bundled` | bundled Chromium | yes | `False` | PASS, cookie @ 276.0 s | + +The `bundled` arm is the one that matters here: it is the configuration `--browser internal` +now launches, and it signed in. **The `bare` control arm is the whole reason this concludes +anything** — with only the two passing arms it would have read as "the block is gone", which +would have justified removing the mitigations rather than fixing the flags. + +**Scope, honestly:** measured on **one** account, one Windows host, one residential IP, one +Chrome build, one day, all arms headed. Bundled Chromium was measured only in the *flagged* +configuration, so its unflagged rejection is inferred from the shared `navigator.webdriver` +signal rather than observed directly. The flags were **not** re-exercised for this release — +this cites the spike that motivated them. + +## 7. Migrated-host `gflow image t2i` — VERIFIED ✅ (re-run for this release) + +``` +GFLOW_CLI_E2E_PROFILE=ffroliva GFLOW_CLI_E2E_PROJECT=cec73a13-… +pytest -m e2e_image tests/e2e/test_migrated_host_e2e.py +-> test_e2e_t2i_runs_on_a_moved_account PASSED (116 s total, $0) +``` + +`#692` is the largest user-facing addition in this release and its evidence was +second-hand — verified by its contributor (@arjhinety) during development, not by a +maintainer. This run makes the CLI `t2i` path **first-hand**: a real Chrome, on a migrated +`flow.google.com` account, driving the Angular Image mode and the page-owned `ogiZ0b` wire. +Zero Veo credits — image generation on this host is credit-free. + +--- + +## NOT verified this cycle — stated, not omitted + +### Migrated-host `i2i` over the queued MCP path — FAILED, cause unidentified (#770) + +The second test in the same run failed, and the failure is **not a code defect**: + +``` +test_e2e_mcp_i2i_runs_on_the_migrated_host FAILED +MediaUploadRejectedError (exit 27): migrated host: a dialog opened after the file +was chosen and no maseQ request left the page — most likely Flow's one-time +upload-terms confirmation (host=migrated) +``` + +**The cause is UNKNOWN, and the error's own wording says so — "most likely".** The +maintainer confirms `ffroliva` has **already** accepted Flow's one-time *"Rights to use +this image"* dialog, so the message's leading hypothesis is wrong for this account. + +That is not a lie in the message: the guard **counts** dialogs across the upload window +rather than identifying one, deliberately — the dialog's buttons carry no ligature and no +data attribute, and its copy is translated, so no anchor there satisfies the locale rule. +Its remediation already names this case: *"If you see a different dialog instead (an error, +a quota notice, a re-login), that is the one blocking the upload."* What we know is only +the conjunction the guard reports: **some dialog opened after the file was chosen, and no +`maseQ` request left the page.** + +What this does establish: + +- The MCP queued path reaches the upload stage on the migrated host, so the adapter and + worker wiring are exercised up to that point. +- The failure is **non-retryable and typed** (exit 27) rather than a 60 s wait ending in + advice to re-encode the file — which is the v0.71.1 behaviour change working, even though + its leading hypothesis does not fit here. + +What it does **not** establish, and must not be read as: that the upload path is healthy, +or that the consent dialog is involved. Note this is a *consented* account with an upload +that produced no `maseQ` — adjacent to [#719](https://github.com/ffroliva/gflow-cli/issues/719) +shape B (on a consented account ~1 upload in 4 sends `maseQ` and receives no reply), but not +the same signature, since here no `maseQ` left the page at all. **No incident bundle was +written** (exit 27 is not in the capture triggers), so there is no DOM dump naming the +dialog. + +**To settle it** a run must capture what actually opened — a DOM/role dump at the moment +the guard fires. That is a spike, not a release step: filed as [#770](https://github.com/ffroliva/gflow-cli/issues/770), which also proposes adding `MediaUploadRejectedError` to the incident-capture triggers so the next occurrence diagnoses itself, and tracking per-account upload consent in the data layer so this guard can stop guessing. + +### The `--browser internal` flags and the migrated refusals + +See §6 above for the flags (spike evidence, not re-run this cycle). The `--aspect 3:4` and +`image batch` refusals fail *before* submit and were not separately exercised live. + +### The reCAPTCHA-mint latch (#673) and the exit-36 refusals + +Pinned by regression tests verified to fail without the fix (the latch test was +A/B-controlled at development time), but not separately exercised live this cycle. The +`--aspect 3:4` and `image batch` refusals fail *before* submit, so a live run costs nothing +but was not performed. + +### `CHROME_BINARY` availability-check fix + +Touches no Flow surface — it is a local Playwright channel-resolution check. Out of scope +for live verification; unit-tested. + +### The OAuth-callback mechanism — INFERRED, NOT PROVEN (issue #769) + +The session poll now stays off `/fx/api/auth/session` while a sign-in is in flight. That +change is correct on its own terms. **The causal story behind it is not established**, and +the first version of it was wrong: a host-only gate does *not* exclude NextAuth's callback, +because the callback runs on the app's own origin — verified, +`/fx/api/auth/callback/google?state=…&code=…` passes a `labs.google` host test. Under that +version the poll could still land mid-callback, yet all four sign-ins above succeeded. + +So: the 600 s `error=OAuthCallback` failure of 2026-09-08 (observed **once**) is *presumed* +to have been caused by the poll, and four post-fix successes cannot distinguish that from +the window simply being narrow. **#769** carries the instrumented spike that would settle +it. Nothing in the CHANGELOG or the code comments should claim more than this paragraph. diff --git a/docs/MCP.md b/docs/MCP.md index 7950d411..9c388c00 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -83,6 +83,7 @@ The server registers three protocol surfaces: ### Tools (Executable actions) * `gflow_generate_image(prompt, model, aspect, count, seed, reference_images, reference_entities, reference_entity_names, tools, profile, project, project_name, instructions, ui_mode, output, wait)`: Triggers text-to-image / image-to-image (Imagen / Nano Banana). `instructions` is an optional list of ephemeral agent-instruction strings (agentic cohort only). `reference_images` switches to i2i and accepts **either a local file path or a generated image's Flow media UUID**. A UUID reference is attached by **selecting the already-existing asset in Flow's reference picker — no duplicate copy is uploaded** (locating the tile by the media id in its thumbnail URL, and searching the recorded display name to surface it when needed); gflow falls back to uploading the asset's on-disk local file only when it can't be located in place (e.g. it lives in a different project's picker). `project` generates into an existing Flow project id (mirrors CLI `--project`) — pass the reference's project to keep it selectable in place. `ui_mode` selects the Flow UI arm (`auto`/`classic`/`agentic`, mirroring CLI `--ui-mode`, matched case-insensitively). Since [#595](https://github.com/ffroliva/gflow-cli/issues/595) `auto` means "no arm was asked for" and **resolves to `classic`** — the arm that can satisfy an image request — so an account in Flow's agentic cohort aborts pre-submit (exit-28 equivalent envelope, zero credits) instead of failing mid-run with selector drift or video bytes; the agentic arm is bound only when named. Passing `instructions` forces `agentic` automatically, so `ui_mode="classic"` + `instructions` is a hard conflict rather than a silent drop. An unknown value returns a 400 problem-details envelope. See [CONFIGURATION § GFLOW_CLI_UI_MODE](CONFIGURATION.md#gflow_cli_ui_mode). + On migrated `flow.google.com` accounts, T2I and local-file I2I are supported with Nano Banana 2 / Pro, the four measured aspects (16:9, 4:3, 1:1, 9:16) and count 1–4; the page owns reCAPTCHA and the `ogiZ0b` submit. UUID/entity references, instructions and Imagen 4 remain pre-submit refusals there. The queued (`wait=false`) and blocking (`wait=true`) calls use the same typed payload and worker path. * `gflow_generate_video(prompt, mode, aspect, initial_frame, end_frame, reference_images, reference_entities, reference_entity_names, model, duration, count, tools, profile, project, project_name, ui_mode, output, wait)`: Triggers vertical or landscape video generation (Veo). `mode` is `t2v`/`i2v`/`r2v`; `model` (`veo_lite`/`veo_fast`/`veo_quality`/`omni_flash`, aliases accepted), `duration` (seconds — 4/6/8 for Veo 3.1 and 4/6/8/10 for omni_flash; 10 is omni_flash-only). **Whether Flow renders a duration control at all is account/cohort-dependent**: on an account that renders none, the job is accepted here and fails in the worker pre-submit (exit 23 equivalent on the labs driver, exit 11 equivalent on the migrated `flow.google.com` host — now the default t2v route, #650 — no credits spent either way) rather than being rejected up front — see [KNOWN_ISSUES](https://github.com/ffroliva/gflow-cli/blob/main/KNOWN_ISSUES.md) (#451/#288/#630), and `count` mirror the CLI `gflow video` flags — an omitted `model` lets the transport apply its i2v veo-lite default (issue #125), and every model — `omni_flash` included — accepts i2v with a start frame and with an end frame (wire-verified 2026-09-02, issue #626); `i2v` requires `initial_frame`, `r2v` requires `reference_images` **or** `reference_entities`; `project` generates into an existing Flow project id (mirrors CLI `--project`); on an account Google has moved to `flow.google.com` (`GFLOW_CLI_FLOW_HOST`, read from the server/daemon environment, not per call — see [CONFIGURATION § GFLOW_CLI_FLOW_HOST](CONFIGURATION.md#gflow_cli_flow_host)) `project` is **required** and omitting it returns the exit-11-equivalent envelope; there the ported modes are text-to-video; image-to-video with a **local** `initial_frame` and no `end_frame` (the file is uploaded through the editor and bound on the Start chip by file name — it stays in the Flow project like any upload); and reference-to-video with local `reference_images` (each file is uploaded the same way and attached as an `@` mention in the prompt; `duration` there accepts only `8` and is pinned when omitted — Flow offers r2v at its base tier alone, and at 4 or 6 it silently drops the references and bills a text-to-video clip, so any other value returns the exit-11-equivalent envelope). A Flow media UUID as `initial_frame`, an `end_frame`, and `r2v` by `reference_entity_names` or `reference_entities` return the exit-36-equivalent envelope. `initial_frame`, `end_frame`, and `reference_images` each accept **either a local file path or the Flow image UUID of a generated asset** — pass a generated image's id straight in to chain image→video, and gflow attaches it for you. Since v0.58.0 (#529) the CLI and MCP surfaces are unified for i2v frames: the UUID keeps its identity and is enriched with the catalog's recorded display name plus an integrity-verified local fallback, so the transport prefers **selecting the exact asset in the project's media picker** (no duplicate upload) and re-uploads the recorded local file only when the tile is unreachable — and only if its byte count/SHA-256 still match. A UUID that isn't in your local asset catalog is rejected up front with a clear "Reference Not Found" error; a catalogued asset with neither a display name nor a verified local file gives a "Reference Not Usable" error (re-generate it or pass a local path). `r2v` UUID refs are resolved to the recorded local file for upload. `ui_mode` selects the Flow UI arm (#299 PR-A, mirroring CLI `--ui-mode`) and **applies to every mode of this tool, including `r2v`** — unlike the CLI, where `video r2v`/`chain` have no flag and follow the env-only path. Video generation has **only a classic driver**, so `auto` ≡ `classic`: both verify the classic editor pre-submit and abort before spending credits if it is unreachable. `ui_mode="agentic"` is rejected with a 400 problem-details envelope, because no agentic video driver exists yet. Values are matched case-insensitively. (MCP tools return envelopes, never process exit codes — the CLI equivalents of these aborts are exit 28 and exit 2 respectively.) See [CONFIGURATION § GFLOW_CLI_UI_MODE](CONFIGURATION.md#gflow_cli_ui_mode). > **Attaching a saved character (the identity axis).** Three routes reach Flow's diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index c988235d..72ce92e2 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -4,6 +4,46 @@ ## Current release +**v0.72.0 — alpha.** **`gflow auth login` closes the browser for you, and Flow's migrated +host now generates images.** + +Sign-in no longer ends with an instruction. gflow drives your real Google Chrome through +Playwright, watches for the completed Flow sign-in and closes the window itself; closing it +yourself still verifies, because that is what three releases of docs told people to do. On a +machine where Playwright cannot resolve a Chrome channel, or where Google rejects the browser +anyway, login falls back automatically to the previous subprocess flow — there is no new flag +and nothing to choose. + +That rests on a measured retraction. The standing claim was that Google rejects Playwright's +bundled Chromium; the 2026-09-08 spike found the discriminator is **`navigator.webdriver`**, +not the browser binary — real Chrome *without* the stealth flags was rejected at +`/v3/signin/rejected` in 17.5 s, while bundled Chromium *with* them signed in normally. The +`bare` control arm is the only reason that concludes anything: with just the two passing arms +it would have read as "the block is gone". + +Four defects surfaced only by driving it live, none of which the offline suite could reach. +The session poll was hitting Flow's NextAuth session endpoint every 3 s for the whole login — +including while Google held the page for the OAuth callback — and it now stays off both +Google's host and NextAuth's own routes, because the callback runs on the *app's* origin and +a host check sails straight through it. Gating that poll on `labs.google` alone would have +timed out on every migrated account. And `page.is_closed()` was reachable only from an +exception handler, so a window closed during a 2FA challenge was never noticed: a full +ten-minute deadline, session endpoint touched zero times, ending in the wrong error. + +`gflow image t2i` and local-file `i2i` also arrive on the migrated `flow.google.com` host +(#692, @arjhinety), driving Angular Image mode and the page-owned `ogiZ0b` wire — Nano Banana +2 / Pro, the four aspects measured there, counts 1–4, `--project` required, with the direct +and queued MCP twins on one payload path. UUID/entity references, Agent instructions, Imagen +4, `image batch` and the 3:4 aspect stay unported there and are refused before submit with +exit 36 rather than reported as selector drift. + +See [LIVE_VERIFICATION_v0.72.0.md](LIVE_VERIFICATION_v0.72.0.md) for what was exercised +against real Flow — and what was not. The OAuth-callback *mechanism* is inferred rather than +proven; issue #769 carries the spike that would settle it. + +
v0.71.1 — two migrated-host failures stop blaming the wrong thing + + **v0.71.1 — alpha.** **Two migrated-host failures stopped blaming the wrong thing — and both were found by asking what the app was *saying*, which this driver had never done.** @@ -57,6 +97,16 @@ second failure shape (an upload request that leaves the page and is never answer branch is now **unrepeatable** here, because the dialog is one-off and all three available accounts have accepted it. +**Unreleased — the migrated image slice of #639 is implemented and live-verified.** +`gflow image t2i` and local-file `gflow image i2i` drive the Angular Image mode and the +page-owned `ogiZ0b` wire on moved accounts (Nano Banana 2 / Pro, the four aspects measured +there — 16:9, 4:3, 1:1, 9:16 — and counts 1–4, with `--project` required); the direct and +queued MCP twins share the same payload path. UUID/entity references, Agent instructions, +Imagen 4, `image batch` and the 3:4 aspect remain unported on that host and are refused +before submit. + +
+
v0.71.0 — character create --voice verified, and two retractions **v0.71.0 — alpha.** **`gflow character create --voice` is verified end to end for the first @@ -875,6 +925,7 @@ reporter-verified e2e on macOS). | Milestone | Status | |---|---| +| `gflow auth login` closes the sign-in browser itself, on a measured retraction — G12 blocks `navigator.webdriver`, not bundled Chromium (#767); `gflow image t2i`/local-file `i2i` driven on the migrated host (#692) | ✅ done (v0.72.0) | | Two migrated-host error paths stop blaming the wrong thing: Flow's agent mode (three distinct outcomes, not one message) and its one-time upload-terms dialog (#749/#752, #719 shape A) | ✅ done (v0.71.1) | | `gflow character create --voice` verified end to end for the first time; a credit shortfall reports exit 37; incident bundles no longer blind on the migrated host | ✅ done (v0.71.0) | | `gflow character create` driven on the migrated `flow.google.com` host; `--model` made deterministic by chip read-back; spike promoted to Phase 0 of the workflow | ✅ done (v0.70.0) | diff --git a/docs/USAGE.md b/docs/USAGE.md index d81f02de..07892b63 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -183,6 +183,14 @@ Notes: Generate 1–4 images from one text prompt, or run a shell-friendly batch of 1–50 prompts through one Flow session/project. +> **Migrated `flow.google.com` accounts (#639):** T2I is supported with Nano Banana 2 +> (`nano2`) and Nano Banana Pro (`nano-pro`), the four aspects measured there (`16:9`, +> `4:3`, `1:1`, `9:16`), and count 1–4. **`--project ` is required** — a fresh project +> can only be created through the labs gallery, so without it the run exits 11. The +> migrated page owns its reCAPTCHA + `ogiZ0b` submit. Imagen 4, Agent instructions, +> character/entity references, `3:4` and `image batch` remain unavailable on that host and +> fail before submit. + ```text gflow image t2i PROMPT [PROMPT ...] [OPTIONS] gflow image t2i --prompts-file FILE [OPTIONS] @@ -322,6 +330,12 @@ A 4-image run with `--out ./logos/` produces: Generate 1–4 images by blending a text prompt with one or more reference images. Same flag set as `t2i`, plus a required `--ref` (repeatable). +> **Migrated `flow.google.com` accounts (#639):** local-file `--ref` values are supported +> and each uploaded media id is verified in the outgoing `ogiZ0b` body. **`--project ` +> is required here** (exit 11 without it). UUID refs, `@Name` / `--reference-entity`, Agent +> instructions, Imagen 4 and the `3:4` aspect remain unavailable on that host and fail +> before submit rather than silently degrading to T2I. + ```text gflow image i2i PROMPT --ref PATH_OR_UUID [--ref ...] [OPTIONS] @@ -590,7 +604,8 @@ Options: > `--initial-frame` and no `--end-frame` runs there too (see [`gflow video i2v`](#gflow-video-i2v)), > as does `r2v` from local `--ref` files (see [`gflow video r2v`](#gflow-video-r2v)); > an end frame, a frame given by UUID or `@Name`, references given by `@Name` or -> `--reference-entity`, and everything else still exit 36 on a moved account. `flow.google.com` forces the migrated composer, +> `--reference-entity`. `image t2i` and local-file `image i2i` also run on a moved +> account; UUID/entity/instruction/Imagen-4 image forms still exit 36. `flow.google.com` forces the migrated composer, > `labs.google` switches it off — see [CONFIGURATION § GFLOW_CLI_FLOW_HOST](CONFIGURATION.md#gflow_cli_flow_host). ```bash @@ -1748,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 | @@ -1770,7 +1785,7 @@ shell scripts can branch on the failure mode without parsing stderr. | `33` | — (`gflow doctor` verdict) | Doctor found warn/fail findings — a successful diagnosis, not an error class | Review the report; see [`gflow doctor`](#gflow-doctor) | | `34` | `SyncPartialError` | `gflow data sync` failed on some projects but succeeded on others — completed writes stay committed | Retryable: re-run the same command; it resumes with what is still nameless (see [`gflow data sync`](#gflow-data-sync)) | | `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` (the origin Google is migrating accounts onto) and the request could not be routed to the migrated composer: `GFLOW_CLI_FLOW_HOST=labs.google` switched it off, or the request type is not ported to that host yet (today `video t2v`; `video i2v` from a local `--initial-frame` with no end frame; and `video r2v` from local `--ref` files — all with `--project`). Not selector drift (23) | **Not retryable.** The handoff is a per-account setting applied on every load. Use `gflow video t2v --project `, `gflow video i2v --initial-frame --project ` or `gflow video r2v --ref --project ` on that host, or the REST surface (`gflow project list`, `gflow data …`); follow #639 for the rest of the matrix | +| `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 | | `130`| SIGINT | User-interrupted (Ctrl-C) | — | @@ -1810,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/medium_tutorial.md b/docs/medium_tutorial.md index a45a793a..a6576da9 100644 --- a/docs/medium_tutorial.md +++ b/docs/medium_tutorial.md @@ -33,12 +33,12 @@ This download requires 150 MB of disk space. Authenticate your Google account. Run the following command: ```bash -gflow auth login --browser chrome +gflow auth login ``` -This command opens a Chrome window. Google blocks standard Playwright browsers. You must specify the `--browser chrome` flag to bypass this check. Log in to your Google account. Solve any CAPTCHA challenges. +This command opens a Chrome window. Google rejects any browser that *advertises* automation (`navigator.webdriver`) — not Playwright as such — and both the default `auto` and an explicit `--browser chrome` launch with the flags that keep it `false`. Log in to your Google account. Solve any CAPTCHA challenges. -After you see the Google Flow dashboard, return to the terminal. The CLI saves your cookies in a user-local directory (for example, `%LOCALAPPDATA%\gflow-cli\` on Windows or `~/Library/Application Support/gflow-cli/` on macOS). +Keep going until the Flow editor itself loads. gflow detects the completed sign-in and closes Chrome for you; closing the window yourself also works. The CLI saves your cookies in a user-local directory (for example, `%LOCALAPPDATA%\gflow-cli\` on Windows or `~/Library/Application Support/gflow-cli/` on macOS). Verify your session status: diff --git a/docs/superpowers/memory/flow-recon-must-run-on-denon82-ffroliva-migrated.md b/docs/superpowers/memory/flow-recon-must-run-on-denon82-ffroliva-migrated.md index c79be8b1..c0a1704c 100644 --- a/docs/superpowers/memory/flow-recon-must-run-on-denon82-ffroliva-migrated.md +++ b/docs/superpowers/memory/flow-recon-must-run-on-denon82-ffroliva-migrated.md @@ -1,6 +1,6 @@ --- name: flow-recon-must-run-on-denon82-ffroliva-migrated -description: "Both maintainer accounts are FULLY migrated to flow.google.com (one-way, not a flap) — there is no labs.google account left, so labs-side behaviour is cohort-external. Recon and live-verification run on either account through the migrated composer, which drives t2v and i2v-from-a-local-frame; unported forms still exit 36. Also: a capability claim without the frontend AND profile named is meaningless." +description: "Both maintainer accounts are FULLY migrated to flow.google.com (one-way, not a flap) — there is no labs.google account left, so labs-side behaviour is cohort-external. Recon and live-verification run on either account through the migrated composer, which drives t2v, i2v/r2v from local files, character create, and image t2i/i2i from local files; unported forms still exit 36. Also: a capability claim without the frontend AND profile named is meaningless." --- **Measured 2026-09-03.** Running `scripts/dev/capture_video_model_capability_matrix.py` @@ -37,11 +37,21 @@ having a migrated driver at all, and read as "the new host cannot be driven"): text with ligature-keyed selectors is the point of that account (see [[flow-locale-leak-icon-ligatures]]); prefer `ffroliva` (en-GB) for a first capture. Verify a generation path on **both** before calling it done. -- A **video** spike on either account exits 36 only for a form the migrated composer has - not ported (today: an end frame, a frame by media UUID or `@Name`, r2v). For a ported - form, exit 36 is a real regression to investigate, not the environment. Every other - surface — image, characters, scenes, extend, instructions, tools — has no migrated - driver at all; how each one *fails* there is the next bullet, not this one. +- A spike on either account exits 36 only for a form the migrated composer has not + ported. For a ported form, exit 36 is a real regression to investigate, not the + environment. **This list has been wrong in the dangerous direction three times** — + it called r2v unported after v0.70.0 shipped it, `character` unported after + `character create` was verified there, and `image` undriven after #639's image + slice landed. A migrated user reading a stale line concludes a working feature is + impossible, so re-derive it from the guards rather than from this bullet: + `_unported_form` and `_unported_image_form` in `migrated_composer.py` ARE the + matrix. As of #639's image slice the ported set is t2v; i2v/r2v from local files; + `character create`/`list`; and image t2i/i2i from local files — each with + `--project`. Unported: end frames, frames/references by UUID or `@Name`, character + entities on a generation, Agent instructions, Imagen 4, `image batch`, the 3:4 + image aspect, scenes, extend and tools. Of those only i2v-by-UUID rests on a + positive observation of absence; the rest are *unported by gflow*, never proven + impossible on the host. - Both accounts are fine for **mint-free** REST-path work (`gflow project list`, `gflow data …`) — the migration changed the *frontend*, not the aisandbox REST surface. See [[rest-path-capability-matrix]]. A REST path that **mints a reCAPTCHA @@ -51,6 +61,10 @@ having a migrated driver at all, and read as "the new host cannot be driven"): `RecaptchaError` through v0.68.0 (#673). Measured by the session that fixed it; PR #678 turned that into the exit 36 it should always have been — the guard now runs at the mint too, so the failure is classified before `discover_site_key` is reached. + **Since #639's image slice this is no longer the end of the story for images:** the + migrated page mints its own token and submits `ogiZ0b` itself, so `image t2i`/`i2i` + now RUN on a moved account and the client skips the labs mint entirely. `upscale` + and `extend` still take the old path and still exit 36. - **Labs-side** behaviour is what is now unreachable: no maintainer account is left on `labs.google`, so a labs-only claim is cohort-external — verify via a contributor or record it NOT verified. diff --git a/docs/superpowers/memory/migrated-host-driver-wire-lessons.md b/docs/superpowers/memory/migrated-host-driver-wire-lessons.md index fdcf9ac1..d405ec71 100644 --- a/docs/superpowers/memory/migrated-host-driver-wire-lessons.md +++ b/docs/superpowers/memory/migrated-host-driver-wire-lessons.md @@ -148,3 +148,32 @@ rounds); e2e `tests/e2e/test_migrated_host_e2e.py`. Read this before re-mining t this is the same shape as #689 (a payload key read but never written) and the MCP r2v guard that was stricter than the rule it fronted. Related: [[dead-wiring-passes-every-gate]]. + +## Image submit — the t2i / i2i slice (#639) + +- **`ogiZ0b` is synchronous, and that is the one place on this host where a submit + reply IS the terminal answer.** Video submits (`YhhmEf` / `eb1hJf` / `MZZa6b`) start a + job and the result arrives later on `jwpduf` / `as29s`; the image submit's own reply + already carries completed records with signed JPEG URLs and dimensions, and there is + no polling rpc on that path at all. Note this explicitly whenever the point comes up, + because the standing rule is the opposite — [[intermediate-signal-is-not-terminal]] + was written from a null `MZZa6b` reply read as a refusal three times. Images are the + documented exception, not a counter-example to it. +- **The migrated page owns the image reCAPTCHA.** The labs client minted a token on the + pool's bootstrap page before the transport ran; on a moved account that page is the + `flow.google.com` grid, which carries no `enterprise.js`, so the mint failed before + any guard could classify it (#673). The client now skips minting when the transport + reports `uses_page_owned_image_recaptcha()` and lets the project page mint + submit. +- **Derive that capability from a latch, not from `page.url`.** The image path parks the + page on `about:blank` when it finishes, which routes as `labs` — so a URL-derived + capability answers `False` on the *second* image in one client session and falls back + to the very mint it exists to avoid. Invisible to every single-image test; reachable + from `gflow image batch`, which runs every prompt through one `FlowApiClient`. Same + shape as the r2v listener above: both halves correct, the join stateful and wrong. +- **Enumerate the axis before mapping to it.** The image aspect radiogroup carried four + radios — `crop_16_9`, `crop_landscape`, `crop_square`, `crop_9_16` — and no + `crop_portrait`. A driver that maps 3:4 to a guessed ligature does not fail as "not + ported"; it misses its selector and raises `UiSelectorDriftError` (exit 23), which + tells the user to file a frontend bug about a frontend that is fine. Refuse an + unmeasured axis value as an unported form (exit 36) and keep the measured set in its + own constant, so the difference between *observed* and *assumed* survives in code. 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/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/docs/superpowers/spikes/2026-09-08-migrated-image-submit-wire.md b/docs/superpowers/spikes/2026-09-08-migrated-image-submit-wire.md new file mode 100644 index 00000000..a57a6472 --- /dev/null +++ b/docs/superpowers/spikes/2026-09-08-migrated-image-submit-wire.md @@ -0,0 +1,64 @@ +# Migrated image generation uses `ogiZ0b` and returns the completed image inline + +**Date:** 2026-09-08 · **Issue:** #639 · **Profile:** permanently migrated `arjhibe` +**Probe:** [`scripts/dev/spike_migrated_image_submit.py`](../../../scripts/dev/spike_migrated_image_submit.py) +**Cost:** two real image-quota submissions (T2I and local-file I2I); aborted controls cost $0 +**Raw evidence:** `scripts/dev/_spike_out/migrated_image_submit_*.json` (gitignored, +token- and signed-query-redacted) + +## What was observed + +The account landed directly on `https://flow.google.com/project/`. It had persisted +Agent mode (`button.agent-mode-chip[aria-pressed=true]`) and an expanded +`flow-agent-panel`; closing the panel and switching that structural control off made the +classic `.settings-trigger-button` visible. Treating a hidden settings button as readiness +would have timed out on a healthy editor. + +The settings pane then rendered Image mode as a hit-testable radio: + +```text +mode: [image* / videocam] +aspect: [crop_16_9* / crop_landscape / crop_square / crop_9_16] +count: [x1* / x2 / x3 / x4] +model: GEM_PIX_2 (button text rendered as Nano Pro) +``` + +For both T2I and local-file I2I the page submitted on **rpcid `ogiZ0b`**. Nothing was +replayed by the probe. The request included a fresh reCAPTCHA token minted by Flow's own +page. The T2I request carried no reference slot; the I2I request carried the exact media id +returned by the page's existing `maseQ` upload and represented by a +`.mention-chip[data-reference-type=media]` in the composer. +The `ogiZ0b` response arrived after generation completed (about 26–40 seconds in the +measured runs) and carried, in one payload: + +- media id; +- workflow id; +- seed and submitted prompt; +- signed `https://flow-content.google/image/?...` URL; +- dimensions (`1376 × 768` in both samples); +- sibling workflow record with display name and project id. + +No status polling RPC was needed to obtain the generated image. Downloading the T2I URL +with redirects disabled returned HTTP 200, **492,358 bytes**, beginning +`ff d8 ff e0 00 10 4a 46 49 46` (JPEG/JFIF). The I2I result appeared as a new +`flow-image-tile` at `1376 × 768`; its response contained the uploaded reference id. + +## Consequences for the implementation + +1. Extend the existing migrated composer rather than create or replay a new RPC client. +2. Route image generation before `FlowApiClient._mint_recaptcha_token`: the page owns the + migrated token and submit, while the old client mint is labs-path work performed too early. +3. Parse `ogiZ0b` as its own image record. The video `CAE` parser is intentionally not + widened: image and video have different invariants and URL fields. +4. Assert Image mode and every requested setting before submit. For I2I, assert every + uploaded media id is in the outgoing `ogiZ0b` body before accepting success. +5. Close/collapse persisted Agent mode before using the classic migrated composer. + +## What was not measured + +- UUID/name-only image references and character references on the migrated image path. +- Every image model. `GEM_PIX_2` was observed; model-menu labels/keys for NARWHAL and + IMAGEN_3_5 still require either a zero-cost menu read or the first live run. +- A content-policy rejection or an out-of-quota image response. +- A labs-host control on this account: the rollout is permanent, so the old host cannot be + reached with this profile. diff --git a/llms.txt b/llms.txt index 77ed778b..188d244c 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # gflow-cli -> Unofficial Python CLI for Google Flow — drives Veo (image-to-video, text-to-video, reference-to-video) and Imagen (text-to-image) generations from the terminal by reverse-engineering Flow's private REST API at aisandbox-pa.googleapis.com — and, for accounts Google has moved to flow.google.com (#639), that frontend's batchexecute wire: text-to-video, image-to-video from a local `--initial-frame` (no end frame, no UUID frame) and reference-to-video from local `--ref` files today, `--project` required there, everything else exits 36 (non-retryable); `GFLOW_CLI_FLOW_HOST` = `auto` (default: flow.google.com for every request it can serve) | `flow.google.com` | `labs.google` (kill switch). Works with any Google account that has Flow access (a paid AI Pro/Ultra plan only affects credit allowances and tier-gated features such as 4K upscale). Uses Playwright Chromium under the hood (one-time real-Chrome auth, then a persistent Chrome session driven via ui_automation). Production-stable for image generation and video T2V/I2V/R2V with `--model`/`--duration`/`--count` pickers and a `--ui-mode` UI-arm gate (video has only a classic driver, so `auto` ≡ `classic`; an explicit `--ui-mode agentic` is refused with exit 2 before any browser work, and an unreachable classic editor aborts pre-submit with exit 28 and zero credits); there is no `batch` video subcommand — the nonfunctional stub was removed in v0.41.0; loop `gflow video t2v`/`i2v` from the shell for multi-clip runs. Also ships `gflow character` (create/list/show/rm/voices — reusable, project-scoped Flow Character entities for consistent subjects across generations), `gflow scene` (create/show — compose ordered clips into a scene; `create --output` renders a credit-free server-side extended video), `gflow video chain` (last-frame I2V chaining from a JSONL manifest), and `gflow video extend` (continues an existing clip by chained 8s Veo extensions, seeded server-side from the source clip rather than an extracted still, landing as a Scene that `--output` renders to one file; note a segment carries ~7s of content though Flow bills 8s, so multi-segment renders hold a frozen second at each internal seam — see KNOWN_ISSUES). Keeps a local SQLite catalog of every generation, queryable via `gflow data list {projects,images,videos,profiles}`. Ships an MCP server (`gflow mcp run` stdio, `gflow serve` Streamable HTTP) with generate/list/instructions tools, a credit-free non-interactive `gflow_auth_status` session probe to call before spending credits, and a `--no-spend` mode (or `GFLOW_MCP_NO_SPEND=1`) that never registers the credit-spending tools. Operational guards (v0.56.0): refuses to open a profile with an older bundled Chromium major than last wrote it (exit 11, protects the session store); `GFLOW_CLI_LEASE_WAIT_SECONDS=N` lets a second same-profile invocation wait for the lease instead of failing fast; a once-a-day stderr banner reports newer PyPI versions (`GFLOW_CLI_UPDATE_CHECK=0` silences it — do so when piping stderr into `jq`), and `gflow update [--check] [--json]` upgrades in place through the installer that put it here (uv tool / pipx / pip; source installs refused, exit 11); `gflow doctor` is the read-only pre-flight diagnostic (exit 33 = findings present). MIT licensed, not affiliated with Google. +> Unofficial Python CLI for Google Flow — drives Veo (image-to-video, text-to-video, reference-to-video) and Imagen (text-to-image) generations from the terminal by reverse-engineering Flow's private REST API at aisandbox-pa.googleapis.com — and, for accounts Google has moved to flow.google.com (#639), that frontend's batchexecute wire: text-to-video, image-to-video from a local `--initial-frame` (no end frame, no UUID frame), reference-to-video from local `--ref` files, text-to-image, and image-to-image from local files today, `--project` required there; references by UUID or `@Name`, character entities, Agent instructions, Imagen 4, image batch and the 3:4 aspect ratio exit 36 (non-retryable); `GFLOW_CLI_FLOW_HOST` = `auto` (default: flow.google.com for every request it can serve) | `flow.google.com` | `labs.google` (kill switch). Works with any Google account that has Flow access (a paid AI Pro/Ultra plan only affects credit allowances and tier-gated features such as 4K upscale). Uses Playwright Chromium under the hood (one-time real-Chrome auth, then a persistent Chrome session driven via ui_automation). Production-stable for image generation and video T2V/I2V/R2V with `--model`/`--duration`/`--count` pickers and a `--ui-mode` UI-arm gate (video has only a classic driver, so `auto` ≡ `classic`; an explicit `--ui-mode agentic` is refused with exit 2 before any browser work, and an unreachable classic editor aborts pre-submit with exit 28 and zero credits); there is no `batch` video subcommand — the nonfunctional stub was removed in v0.41.0; loop `gflow video t2v`/`i2v` from the shell for multi-clip runs. Also ships `gflow character` (create/list/show/rm/voices — reusable, project-scoped Flow Character entities for consistent subjects across generations), `gflow scene` (create/show — compose ordered clips into a scene; `create --output` renders a credit-free server-side extended video), `gflow video chain` (last-frame I2V chaining from a JSONL manifest), and `gflow video extend` (continues an existing clip by chained 8s Veo extensions, seeded server-side from the source clip rather than an extracted still, landing as a Scene that `--output` renders to one file; note a segment carries ~7s of content though Flow bills 8s, so multi-segment renders hold a frozen second at each internal seam — see KNOWN_ISSUES). Keeps a local SQLite catalog of every generation, queryable via `gflow data list {projects,images,videos,profiles}`. Ships an MCP server (`gflow mcp run` stdio, `gflow serve` Streamable HTTP) with generate/list/instructions tools, a credit-free non-interactive `gflow_auth_status` session probe to call before spending credits, and a `--no-spend` mode (or `GFLOW_MCP_NO_SPEND=1`) that never registers the credit-spending tools. Operational guards (v0.56.0): refuses to open a profile with an older bundled Chromium major than last wrote it (exit 11, protects the session store); `GFLOW_CLI_LEASE_WAIT_SECONDS=N` lets a second same-profile invocation wait for the lease instead of failing fast; a once-a-day stderr banner reports newer PyPI versions (`GFLOW_CLI_UPDATE_CHECK=0` silences it — do so when piping stderr into `jq`), and `gflow update [--check] [--json]` upgrades in place through the installer that put it here (uv tool / pipx / pip; source installs refused, exit 11); `gflow doctor` is the read-only pre-flight diagnostic (exit 33 = findings present). MIT licensed, not affiliated with Google. Credit inspection is available through `gflow credits user|list` and the `gflow_get_credits` MCP tool, with a browser-free HTTP fast path and browser fallback. The reported balance funds Veo diff --git a/pyproject.toml b/pyproject.toml index 48f78ebb..b048aeb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gflow-cli" -version = "0.71.1" +version = "0.72.0" description = "Unofficial CLI for Google Flow — drive Veo image-to-video generations from the terminal." readme = "README.md" license = { file = "LICENSE" } diff --git a/scripts/dev/spike_migrated_image_submit.py b/scripts/dev/spike_migrated_image_submit.py new file mode 100644 index 00000000..0ebdbc5d --- /dev/null +++ b/scripts/dev/spike_migrated_image_submit.py @@ -0,0 +1,282 @@ +r"""Capture the migrated composer's image submit/result wire (#639). + +The default run aborts the marker-bearing submit before it leaves Chrome, so it costs +nothing. ``--spend`` lets Flow own the request and records the resulting batchexecute +traffic; it consumes image quota. ``--ref PATH`` uploads and mentions a local image before +the submit so the I2I body can be compared with T2I. + +Raw output is gitignored and aggressively redacted: no cookies, auth headers, reCAPTCHA +tokens, or signed URL query strings are written. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import re +import sys +import time +import uuid +from pathlib import Path +from typing import Any +from urllib.parse import parse_qs, unquote_plus, urlsplit + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _spike_common import ( # noqa: E402, isort: skip + build_client, + default_out_path, + resolve_profile_dir, + step, +) + +from gflow_cli.api.transports.batchexecute import parse_frames # noqa: E402 +from gflow_cli.api.transports.migrated_composer import ( # noqa: E402 + RADIOGROUP, + MigratedComposer, + _ligature, +) + +_TOKEN_RE = re.compile(r"[A-Za-z0-9_\-]{120,}") +_SIGNED_URL_RE = re.compile(r"(https://[^\s\"\\]+)\?[^\s\"\\]+") +_AT_RE = re.compile(r"([?&]at=)[^&\s]+") +_UUID_RE = re.compile(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}", re.I) + + +class ProbeFailedError(RuntimeError): + """The surface was not reached; never reinterpret this as an absence.""" + + +def _url_facts(url: str) -> dict[str, Any]: + parsed = urlsplit(url) + query = parse_qs(parsed.query) + return { + "host": parsed.hostname, + "path": parsed.path, + "rpcids": query.get("rpcids", [None])[0], + } + + +def _redact(text: str, *, limit: int = 8000) -> str: + decoded = unquote_plus(text) + decoded = _TOKEN_RE.sub(lambda m: f"<{len(m.group(0))}-char-token>", decoded) + decoded = _SIGNED_URL_RE.sub(r"\1?", decoded) + decoded = _AT_RE.sub(r"\1", decoded) + return decoded[:limit] + + +def _https_urls(node: object) -> list[str]: + if isinstance(node, str): + return [node] if node.startswith("https://") else [] + if isinstance(node, list): + return [url for item in node for url in _https_urls(item)] + return [] + + +async def _groups(pane: Any) -> list[list[dict[str, Any]]]: + return await pane.locator(RADIOGROUP).evaluate_all( + """groups => groups.map(g => [...g.querySelectorAll('[role=radio]')].map(r => ({ + text: (r.textContent || '').replace(/\\s+/g, ' ').trim(), + checked: r.getAttribute('aria-checked'), + icons: [...r.querySelectorAll('mat-icon')].map(i => (i.textContent || '').trim()) + })))""" + ) + + +async def _run(profile: str, project: str, ref: Path | None, spend: bool) -> int: + marker = "gflowimagecanary" + uuid.uuid4().hex[:10] + prompt = f"a cobalt ceramic cup on a plain white table {marker}" + report: dict[str, Any] = { + "profile": profile, + "project": project[:8] + "...", + "mode": "real" if spend else "aborted", + "kind": "i2i" if ref else "t2i", + "marker": marker, + "requests": [], + "responses": [], + } + t0 = time.monotonic() + marker_seen = asyncio.Event() + signed_image_urls: list[str] = [] + + def rel() -> float: + return round(time.monotonic() - t0, 2) + + async with build_client(resolve_profile_dir(profile)) as client: + page = await client._context.new_page() # noqa: SLF001 - deliberate spike + composer = MigratedComposer() + + async def on_route(route: Any) -> None: + req = route.request + body = str(req.post_data or "") + facts = _url_facts(req.url) + if "batchexecute" in req.url: + report["requests"].append( + { + "t": rel(), + **facts, + "body_len": len(body), + "marker": marker in unquote_plus(body), + "uuid_sample": _UUID_RE.findall(unquote_plus(body))[:8], + "body": _redact(body, limit=5000), + } + ) + if marker in unquote_plus(body): + marker_seen.set() + step("submit", f"rpc={facts['rpcids']} {'continued' if spend else 'aborted'}") + if not spend: + await route.abort() + return + await route.continue_() + + async def on_response(response: Any) -> None: + if "batchexecute" not in response.url: + return + try: + body = await response.text() + except Exception: # noqa: BLE001 - an aborted response is expected in dry mode + return + for _, payload in parse_frames(body): + signed_image_urls.extend( + url + for url in _https_urls(payload) + if urlsplit(url).hostname == "flow-content.google" + and "/image/" in urlsplit(url).path + ) + report["responses"].append( + { + "t": rel(), + **_url_facts(response.url), + "status": response.status, + "body_len": len(body), + "uuid_sample": _UUID_RE.findall(body)[:8], + "body": _redact(body), + } + ) + + await page.route("**/batchexecute**", on_route) + page.on("response", on_response) + try: + # This account persists Agent mode. Collapse its expanded chat panel, then + # switch the locale-independent aria-pressed control off so the classic + # settings trigger can become visible. Presence of the hidden trigger is + # not editor readiness. + await page.goto( + f"https://flow.google.com/project/{project}", + wait_until="domcontentloaded", + timeout=60_000, + ) + await page.wait_for_timeout(3_000) + chat_close = page.locator( + "flow-agent-panel button:has(mat-icon:text-is('close'))" + ).first + if await chat_close.count() and await chat_close.is_visible(): + await chat_close.click(timeout=5_000) + agent = page.locator("button.agent-mode-chip").first + if await agent.count() and await agent.get_attribute("aria-pressed") == "true": + await agent.click(timeout=5_000) + await page.wait_for_timeout(500) + await composer.ensure_editor(page, project) + pane = await composer._open_pane(page) # noqa: SLF001 - spike drives exact seam + await composer._select(page, pane, axis="mode", lig="image") # noqa: SLF001 + await asyncio.sleep(0.8) + report["settings_groups"] = await _groups(pane) + report["model_buttons"] = [ + t.strip() + for t in await pane.locator("button") + .filter(has=_ligature(page, "arrow_drop_down")) + .all_text_contents() + ] + # Count is a persisted billing axis. Pin x1 even for the spike. + await composer._select(page, pane, axis="count", text="x1") # noqa: SLF001 + await composer._close_pane(page, strict=True) # noqa: SLF001 + + reference_ids: tuple[str, ...] = () + if ref is not None: + reference_ids = await composer.attach_references(page, project, (ref,)) + report["reference_ids"] = list(reference_ids) + + await composer.send_prompt(page, prompt, append=bool(ref)) + chips = await composer.read_chips(page) + report["chips"] = chips + if ref is not None and not chips: + raise ProbeFailedError("reference upload completed but no mention chip was bound") + + submit = page.locator("button").filter(has=_ligature(page, "arrow_forward")).first + await submit.wait_for(state="visible", timeout=10_000) + deadline = time.monotonic() + 5 + while not await submit.is_enabled() and time.monotonic() < deadline: + await asyncio.sleep(0.1) + if not await submit.is_enabled(): + raise ProbeFailedError("image submit stayed disabled after the prompt was typed") + await submit.click(timeout=5000) + try: + await asyncio.wait_for(marker_seen.wait(), timeout=30) + except TimeoutError as exc: + raise ProbeFailedError("no marker-bearing image request was observed") from exc + + if spend: + # Let Flow's own polling finish. The response log is the measurement; + # no guessed rpcid or record parser decides when this window ends. + await page.wait_for_timeout(120_000) + report["dom_after"] = await page.evaluate( + """() => ({ + images: [...document.querySelectorAll('flow-image-tile img')].map(i => ({ + src: (i.currentSrc || i.src || '').split('?')[0], + w: i.naturalWidth, h: i.naturalHeight + })).slice(0, 12), + composer: document.querySelectorAll("[contenteditable='true']").length + })""" + ) + report["downloads"] = [] + for url in dict.fromkeys(signed_image_urls): + response = await page.request.get(url, timeout=120_000, max_redirects=0) + body = await response.body() + report["downloads"].append( + { + "host": urlsplit(url).hostname, + "path": urlsplit(url).path, + "status": response.status, + "bytes": len(body), + "magic": body[:12].hex(), + "is_image": body.startswith(b"\x89PNG\r\n\x1a\n") + or body.startswith(b"\xff\xd8\xff") + or (body.startswith(b"RIFF") and body[8:12] == b"WEBP"), + } + ) + report["reached"] = { + "editor": True, + "image_mode": True, + "submit": True, + "result_window": spend, + } + finally: + page.remove_listener("response", on_response) + await page.unroute("**/batchexecute**", on_route) + out = default_out_path(f"migrated_image_submit_{'i2i' if ref else 't2i'}") + out.write_text(json.dumps(report, indent=2), encoding="utf-8") + step("wrote", str(out)) + await page.close() + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--profile", required=True) + parser.add_argument("--project", required=True) + parser.add_argument("--ref", type=Path) + parser.add_argument("--spend", action="store_true") + args = parser.parse_args() + if args.ref is not None and not args.ref.is_file(): + parser.error(f"--ref is not a file: {args.ref}") + try: + return asyncio.run(_run(args.profile, args.project, args.ref, args.spend)) + except ProbeFailedError as exc: + print(f"[spike] PROBE FAILED: {exc}", file=sys.stderr) + print("[spike] This is a failed measurement, NOT evidence of absence.", file=sys.stderr) + return 3 + + +if __name__ == "__main__": + raise SystemExit(main()) 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/skills/gflow-cli/SKILL.md b/skills/gflow-cli/SKILL.md index a926a541..c23ee72f 100644 --- a/skills/gflow-cli/SKILL.md +++ b/skills/gflow-cli/SKILL.md @@ -70,6 +70,10 @@ gflow image t2i "" [--model {nano2|nano-pro|image4}] \ [-n 1..4] [--out DIR] gflow image i2i "" --ref PATH_OR_UUID [--ref ...] [...same as t2i] gflow image batch [-n 1..4] [--aspect ...] [--out DIR] # shared project, up to 5 prompts +# On migrated flow.google.com accounts (#639), t2i and i2i are ported for an +# existing --project (required — exit 11 without it): i2i accepts local --ref +# files only, aspects 16:9/4:3/1:1/9:16. UUIDs, @Name/entity references, Imagen 4 +# (image4), --aspect 3:4, and image batch are refused there with exit 36. # Video generation (Veo 3.1) gflow video t2v "" [--project ID] [--model ...] [--duration 4|6|8|10] [--out-dir DIR] [--aspect ...] # --project required on the migrated flow.google.com host (#639); 10s is omni-flash-only @@ -275,7 +279,7 @@ Documented errors agents commonly make — negative examples for the SkillOpt tr | `--model imagen` / `--model quality` / `--model high` | `--model image4` (Imagen 3.5), `--model nano-pro` (Gem Pix 2), `--model nano2` (Narwhal) | | Python: `client = FlowApiClient(...)` then method calls | Must use `async with FlowApiClient(...) as client:` — it's an async context manager | | Python: `from gflow_cli import FlowApiClient` | `from gflow_cli.api.client import FlowApiClient` | -| `gflow video t2v`/`i2v`/`r2v` without `--project` on an account Google moved to `flow.google.com` (exit 11), or `--end-frame`, a UUID/`@Name` frame, a reference by `@Name`/`--reference-entity`, or any other command there (exit 36) | Pass `--project ` — only text-to-video, image-to-video from a local `--initial-frame`, and reference-to-video from local `--ref` files are ported to the migrated host; exit 36 is non-retryable, `GFLOW_CLI_FLOW_HOST=labs.google` is the kill switch (see USAGE § gflow video t2v / i2v / r2v) | +| `gflow video t2v`/`i2v`/`r2v` without `--project` on an account Google moved to `flow.google.com` (exit 11), or an unported UUID/entity reference or model (exit 36) | Pass `--project ` — migrated hosts support video t2v, local-file i2v/r2v, and image t2i/i2i (local refs) only; exit 36 is non-retryable, `GFLOW_CLI_FLOW_HOST=labs.google` is the kill switch (see USAGE § gflow video t2v / i2v / r2v and image sections) | | Suggesting a native `batch` subcommand under `gflow video` | It doesn't exist — that stub never worked and was removed. Loop `gflow video t2v`/`i2v` from the shell for multi-clip runs (`gflow image batch manifest.tsv\|json` is the real, working batch command, but it's image-only) | ## Disclaimer diff --git a/skills/video-production/SKILL.md b/skills/video-production/SKILL.md index 07a12213..8263fe04 100644 --- a/skills/video-production/SKILL.md +++ b/skills/video-production/SKILL.md @@ -85,7 +85,7 @@ Load `https://labs.google/fx/tools/flow/project/` in the profile's Chrome an | Lands on | Lane | What runs there | |---|---|---| | `labs.google/fx/…` | **A, full** | everything | -| `flow.google.com/project/…` | **B, partial** | `video t2v --project`, `video i2v --initial-frame --project`, and **`video r2v --ref --project`** (ported in v0.70.0, #683). Everything else — `image`, `character create`, `extend`, `scene create`, `movie run`, r2v by `@Name` or `--reference-entity`, and i2v by media UUID / `@Name` or with `--end-frame` — exits 36 **[CONSTRAINT]** | +| `flow.google.com/project/…` | **B, partial** | `video t2v --project`, `video i2v --initial-frame --project`, **`video r2v --ref --project`** (ported in v0.70.0, #683), `character create` (v0.70.0), and **`image t2i` / `image i2i` with local `--ref` files (#639)**. Everything else — `image batch`, `extend`, `scene create`, `movie run`, r2v by `@Name` or `--reference-entity`, i2v by media UUID / `@Name` or with `--end-frame`, Imagen 4, and the 3:4 image aspect — exits 36 **[CONSTRAINT]** | Lane B grows as forms are ported, so **confirm the row rather than trusting it**: the maintained list is [CONFIGURATION § `GFLOW_CLI_FLOW_HOST`](../../docs/CONFIGURATION.md#gflow_cli_flow_host) diff --git a/skills/video-production/failure-modes.md b/skills/video-production/failure-modes.md index f346272e..67c13981 100644 --- a/skills/video-production/failure-modes.md +++ b/skills/video-production/failure-modes.md @@ -13,8 +13,8 @@ Find your symptom in the left column first. Several of these present as somethin | A reference silently stops applying | model selected *after* attaching **[CONSTRAINT]** | choose model, then attach — never the reverse | | HTTP 400 as `WireFormatError`, advice says "simplify the prompt" | two face-bearing references in one generation, **or** an age word in the prose **[CONSTRAINT]** | keep one face reference and carry others as role nouns; strip every age phrase | | The same refusal repeats no matter how the prose is rewritten | the diagnosis is wrong, not the wording **[CALIBRATED]** | after two identical failures stop rewriting and look one step upstream — the references, the casting, the beat itself | -| `RecaptchaError` on a free image command | on gflow ≤ 0.68.0, the migrated host — the guard ran *after* the reCAPTCHA mint on the image path (#673, fixed: it is exit 36 now). On any build, right after `auth login`, the cookie harvest keyed on the old host (#644) **[CALIBRATED]** | check the final host URL before anything else; if it followed a fresh login, see #644 | -| Exit 36 | migrated host, command not ported | that lane runs `t2v` and `i2v --initial-frame `; everything else is unported. Do not retry, it is not transient | +| `RecaptchaError` on a free image command | on gflow ≤ 0.68.0, the migrated host — the guard ran *after* the reCAPTCHA mint on the image path (#673). Since #639 the migrated page mints its own token and images RUN there, so this shape should no longer appear on a moved account at all. On any build, right after `auth login`, the cookie harvest keyed on the old host (#644) **[CALIBRATED]** | check the final host URL before anything else; if it followed a fresh login, see #644 | +| Exit 36 | migrated host, command not ported | that lane runs `t2v`, `i2v`/`r2v` from local files, and `image t2i`/`i2i` from local files; everything else is unported. Do not retry, it is not transient | | A run 400s only after a plate was attached | the plate pushed a multi-person prompt over the person policy | lean the prose to role nouns before attaching | | Exit 11 or a parse error on a manifest | a model and duration pair that could never render | `--dry-run` catches it before spending | diff --git a/src/gflow_cli/__init__.py b/src/gflow_cli/__init__.py index 9c6a9e0b..1ea7e8a1 100644 --- a/src/gflow_cli/__init__.py +++ b/src/gflow_cli/__init__.py @@ -1,3 +1,3 @@ """gflow-cli — unofficial CLI for Google Flow.""" -__version__ = "0.71.1" +__version__ = "0.72.0" diff --git a/src/gflow_cli/api/client.py b/src/gflow_cli/api/client.py index a2d4a598..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": @@ -2423,43 +2423,11 @@ async def _mint_recaptcha_token(self, action: str) -> str: # root grid, which is where the client-side handoff leaves the pooled # bootstrap page, has no script at all. # - # So this guard refuses a HOST that can mint, standing in for a - # DIFFERENT constraint: `gflow image` is UI-driven, and nothing in - # this repo drives the migrated composer's image path yet. - # - # It does NOT stand in for "that composer cannot generate images". - # This comment used to say exactly that — "no image-generation mode - # … measured, not assumed" — and it was FALSIFIED on 2026-09-07 by - # scripts/dev/spike_migrated_composer_mode_axis.py, live on a - # migrated account: the composer's settings overlay opens to 6 - # radiogroups / 16 radios and group 0 is - # `[imageImage, videocamVideo]`, present and hit-testable, with the - # VIDEO option carrying aria-checked. That reproduced the independent - # 2026-09-04 enumeration in - # docs/superpowers/spikes/2026-09-04-migrated-host-handoff-mechanism.md:123-136 - # exactly, three days later, on a different account. - # - # Why video was the checked one is NOT established: the probe opened a - # fresh page and read persisted overlay state, and never invoked the - # driver. Flow remembering the account's last-used mode explains it as - # well as anything gflow does. (`MigratedComposer.apply_video_settings` - # does pin `axis="mode"` to `videocam` unconditionally, but that ran in - # no part of this measurement.) - # - # The retracted claim cited a script whose run was never recorded and - # whose overlay-open was best-effort with a swallowed exception, so it - # could — and evidently did — conclude "video-only" having never opened - # the panel it existed to read. Two features have now been declared - # absent by a probe that failed silently; see skills/spike/SKILL.md. - # - # What IS established is narrow and worth stating precisely, because - # overstating it here would repeat the defect this comment retracts: - # an image radio EXISTS on that axis and is hit-testable. Nothing was - # clicked on it and nothing was submitted, so whether `gflow image` - # can be served from this host is still open. It is no longer safe to - # say it cannot. The next probe — click the radio, route the mint to a - # project page, and see how far a submit gets — is what decides the - # size of the port, and it is tracked separately. + # The migrated image path now bypasses this method through the + # ``uses_page_owned_image_recaptcha`` transport capability. Keep this + # host guard for the narrow race where a labs page hands off while a + # caller is already minting; the project page owns the token and the + # migrated composer submits ``ogiZ0b`` itself. raise_if_migrated(page, at="mint_recaptcha_token") # Patchright evaluates in an isolated world by default, where the # page's main-world ``grecaptcha`` global is undefined; the resolver @@ -2556,8 +2524,16 @@ async def _drive_images_generation( raise RuntimeError( msg, ) - token = await self._mint_recaptcha_token(recaptcha_action) - req_with_token = _dc_replace(req, recaptcha_token=token) + page_owned = getattr(self.transport, "uses_page_owned_image_recaptcha", None) + if callable(page_owned) and page_owned(): + # The migrated Angular page mints and submits its own token on ogiZ0b. + # Minting here first is not only redundant: the pooled bootstrap page is + # flow.google.com/ (no enterprise.js), while /project/ is the page that + # owns the script. Let the transport navigate before Flow spends a token. + req_with_token = req + else: + token = await self._mint_recaptcha_token(recaptcha_action) + req_with_token = _dc_replace(req, recaptcha_token=token) if on_checkpoint is not None: on_checkpoint(GenerationCheckpoint(phase="submit_attempted")) # Kwarg passed only when set: keeps duck-typed fakes/transports that diff --git a/src/gflow_cli/api/transports/batchexecute.py b/src/gflow_cli/api/transports/batchexecute.py index ed7d56de..d39faa3c 100644 --- a/src/gflow_cli/api/transports/batchexecute.py +++ b/src/gflow_cli/api/transports/batchexecute.py @@ -67,6 +67,25 @@ def is_failed(self) -> bool: return self.status is not None and not self.is_done and not self.is_running +@dataclass(frozen=True) +class ImageGenerationRecord: + """One completed image from the migrated host's ``ogiZ0b`` reply. + + Image generation is synchronous at this RPC boundary: the measured response arrives + after the render and already carries its signed CDN URL. It is deliberately distinct + from :class:`GenerationRecord`; image records do not use the video ``CAE`` shape. + """ + + media_id: str + workflow_id: str + project_id: str + seed: int + prompt: str + image_url: str + dimensions: tuple[int, int] + display_name: str | None = None + + def _as_list(node: object) -> list[Any] | None: return cast("list[Any]", node) if isinstance(node, list) else None @@ -133,6 +152,13 @@ def _find_record(node: object) -> list[Any] | None: return None +def _walk_lists(node: object) -> list[list[Any]]: + items = _as_list(node) + if items is None: + return [] + return [items, *(child for item in items for child in _walk_lists(item))] + + def _at(node: object, *path: int) -> Any: current: Any = node for i in path: @@ -183,3 +209,80 @@ def generation_record(rpcid: str, payload: Any) -> GenerationRecord: poster_url=_url(_at(rec, 5, 10)), size_bytes=size if isinstance(size, int) else None, ) + + +def image_records(rpcid: str, payload: Any) -> list[ImageGenerationRecord]: + """Decode every completed image in the migrated ``ogiZ0b`` payload. + + The media tuple is identified by invariants measured on both T2I and I2I: + UUID media/workflow ids at slots 0/2, generation details at ``[6][0]``, a signed + HTTPS URL at details slot 13, and integer dimensions at ``[6][2]``. Sibling + workflow tuples supply the project id and display name. A shape change fails loud + with a redacted discovery head instead of guessing positional fallbacks. + """ + workflow_meta: dict[str, tuple[str, str | None]] = {} + lists = _walk_lists(payload) + for node in lists: + if len(node) < 5 or not isinstance(node[0], str) or not _UUID_RE.match(node[0]): + continue + meta = _as_list(node[3]) + project_id = node[4] + if meta is None or not isinstance(project_id, str) or not _UUID_RE.match(project_id): + continue + title = meta[0] if meta and isinstance(meta[0], str) else None + workflow_meta[node[0]] = (project_id, title) + + records: list[ImageGenerationRecord] = [] + for node in lists: + if len(node) < 7: + continue + media_id, workflow_id = node[0], node[2] + if not ( + isinstance(media_id, str) + and _UUID_RE.match(media_id) + and isinstance(workflow_id, str) + and _UUID_RE.match(workflow_id) + ): + continue + container = _as_list(node[6]) + if container is None: + continue + details = _as_list(_at(container, 0)) + dims = _as_list(_at(container, 2)) + if details is None or dims is None or len(details) <= 13 or len(dims) < 2: + continue + image_url = _url(details[13]) + seed, prompt = _at(details, 1), _at(details, 7) + width, height = dims[0], dims[1] + meta = workflow_meta.get(workflow_id) + if not ( + image_url + and isinstance(seed, int) + and isinstance(prompt, str) + and isinstance(width, int) + and isinstance(height, int) + and meta is not None + ): + continue + records.append( + ImageGenerationRecord( + media_id=media_id, + workflow_id=workflow_id, + project_id=meta[0], + seed=seed, + prompt=prompt, + image_url=image_url, + dimensions=(width, height), + display_name=meta[1], + ) + ) + if not records: + raise WireFormatError( + detail=( + f"batchexecute {rpcid}: no completed image record " + "([media_uuid, …, workflow_uuid, …, details-with-https-url]) in the reply" + ), + route=f"batchexecute:{rpcid}", + discovery={"rpcid": rpcid, "payload_head": _discovery_head(payload)}, + ) + return records diff --git a/src/gflow_cli/api/transports/migrated_composer.py b/src/gflow_cli/api/transports/migrated_composer.py index ffa61dbf..600a40d0 100644 --- a/src/gflow_cli/api/transports/migrated_composer.py +++ b/src/gflow_cli/api/transports/migrated_composer.py @@ -1,5 +1,7 @@ -"""Drive Flow's migrated ``flow.google.com`` editor (Angular Material) — t2v, and i2v -from a local start frame. +"""Drive Flow's migrated ``flow.google.com`` editor (Angular Material). + +Supported generation paths are t2v, i2v from a local start frame, r2v from local +reference files, t2i, and i2i from local reference files. Google is moving accounts from ``labs.google/fx/tools/flow`` onto ``flow.google.com`` (issue #639). The migrated app is the same product on a @@ -8,12 +10,14 @@ ``[role=radio]`` buttons instead of ``role=menu`` tabs, the model picker is a ``[role=menu]`` of ``[role=menuitem]``s, and the composer is a ``contenteditable`` (the ``textarea`` next to it is not clickable). On the wire it is ``batchexecute``, -not aisandbox REST: submit is rpcid ``YhhmEf`` (t2v) or ``eb1hJf`` (i2v), the app +not aisandbox REST: video submit is ``YhhmEf``/``eb1hJf``/``MZZa6b`` and image submit +is ``ogiZ0b``. The app then polls ``jwpduf`` every 5 s by itself and fetches the result with ``as29s`` — so this driver **observes** the page's own traffic and adds none. A start frame goes in through the editor's own upload (``maseQ``) and its library picker. Recon with -measurements: ``docs/superpowers/spikes/2026-09-05-migrated-host-wire-protocol.md`` -and ``docs/superpowers/spikes/2026-09-05-migrated-frames-attach.md``. +measurements: ``docs/superpowers/spikes/2026-09-05-migrated-host-wire-protocol.md``, +``docs/superpowers/spikes/2026-09-05-migrated-frames-attach.md``, and +``docs/superpowers/spikes/2026-09-08-migrated-image-submit-wire.md``. Every anchor here is structural or a Material Symbols ligature; the only text matched is a numeric token (``8s``, ``x2``) or a product name (``Veo 3.1 - Lite``). @@ -36,10 +40,14 @@ import structlog +from gflow_cli.api.dto import GeneratedImage +from gflow_cli.api.image import Aspect as ImageAspect +from gflow_cli.api.image import Model as ImageModel from gflow_cli.api.transports._common import extract_project_id from gflow_cli.api.transports.batchexecute import ( GenerationRecord, generation_record, + image_records, parse_frames, ) from gflow_cli.api.video import ( @@ -65,6 +73,7 @@ if TYPE_CHECKING: from playwright.async_api import Page + from gflow_cli.api.image import GenerateImageRequest from gflow_cli.api.video import GenerateVideoRequest, VideoStartedCallback log = structlog.get_logger(__name__) @@ -131,6 +140,7 @@ #: ``MZZa6b`` — measured 2026-09-05. Watching only the first is why r2v looked #: for several rounds like it never submitted at all. SUBMIT_RPCS = ("YhhmEf", "eb1hJf", "MZZa6b") +IMAGE_SUBMIT_RPC = "ogiZ0b" STATUS_RPCS = ("jwpduf", "as29s") UPLOAD_RPC = "maseQ" #: The model key Flow puts in the submit body. Two observed shapes: mode-infixed @@ -195,6 +205,7 @@ #: URLs (``as29s``) followed 2–5 s later in every measured run. Wait that long #: for it before settling for the URL-less record. RESULT_URL_GRACE_S = 20.0 +IMAGE_REPLY_BUDGET_S = 180.0 #: Product names read back verbatim from the live migrated menu (v0.62.1's refusal #: diagnostic, corroborated by the 2026-09-05 spike). These are the tiers a *not yet @@ -235,13 +246,16 @@ class ModelMenuMatcher: """ contains: str - excludes: str | None = LOWER_PRIORITY_TAG + excludes: str | tuple[str, ...] | None = LOWER_PRIORITY_TAG def matches(self, text: str) -> bool: folded = text.casefold() if self.contains.casefold() not in folded: return False - return self.excludes is None or self.excludes.casefold() not in folded + if self.excludes is None: + return True + exclusions = (self.excludes,) if isinstance(self.excludes, str) else self.excludes + return all(exclusion.casefold() not in folded for exclusion in exclusions) #: Every model the migrated menu can be *driven* to, including the lower-priority Lite @@ -255,6 +269,35 @@ def matches(self, text: str) -> bool: Aspect.LANDSCAPE: "crop_16_9", Aspect.PORTRAIT: "crop_9_16", } +#: Ligature per aspect. Only the four in :data:`IMAGE_ASPECT_LIGATURE_MEASURED` +#: were observed on the migrated host; ``crop_portrait`` is this driver's guess at +#: what a 3:4 radio WOULD be called, kept so that adding it later is a one-line +#: change, and deliberately not reachable until something measures it. +IMAGE_ASPECT_LIGATURE: dict[ImageAspect, str] = { + ImageAspect.LANDSCAPE: "crop_16_9", + ImageAspect.PORTRAIT: "crop_9_16", + ImageAspect.SQUARE: "crop_square", + ImageAspect.LANDSCAPE_FOUR_THREE: "crop_landscape", + ImageAspect.PORTRAIT_THREE_FOUR: "crop_portrait", +} + +#: The aspects actually enumerated in the migrated composer's radiogroup — +#: ``[crop_16_9*, crop_landscape, crop_square, crop_9_16]``, one account, +#: 2026-09-08 (docs/superpowers/spikes/2026-09-08-migrated-image-submit-wire.md). +IMAGE_ASPECT_LIGATURE_MEASURED: frozenset[ImageAspect] = frozenset( + { + ImageAspect.LANDSCAPE, + ImageAspect.PORTRAIT, + ImageAspect.SQUARE, + ImageAspect.LANDSCAPE_FOUR_THREE, + }, +) +IMAGE_MODEL_MENU_MATCHERS: dict[ImageModel, ModelMenuMatcher] = { + # Exact enough to exclude the separate "Nano Banana 2 Lite" entry without + # depending on the decorative banana glyph that precedes both live labels. + ImageModel.NARWHAL: ModelMenuMatcher("Nano Banana 2", excludes=("Lite",)), + ImageModel.GEM_PIX_2: ModelMenuMatcher("Nano Banana Pro"), +} def _unported_form(request: GenerateVideoRequest) -> str | None: @@ -338,10 +381,11 @@ def _unported_form(request: GenerateVideoRequest) -> str | None: def migrated_can_serve(request: GenerateVideoRequest, project_id: str | None) -> bool: """Can the migrated composer take this request as it stands? Text-to-video, or - image-to-video from a **local** start frame, in an existing project, with a model - the new host offers (or none). Everything else — an end frame, a frame by UUID or - ``@Name``, r2v media, character references, a fresh project, a labs-only model — - is not ported yet, so an unmoved account keeps the labs driver for it. + image-to-video / reference-to-video from **local** files, in an existing project, + with a model the new host offers (or none). Everything else — an end frame, a + frame or reference by UUID or ``@Name``, character references, a fresh project, + a labs-only model — is not ported yet, so an unmoved account keeps the labs + driver for it. Gated on :data:`VIDEO_MODEL_MENU_LABELS`, not on the wider :data:`VIDEO_MODEL_MENU_MATCHERS`: this decides whether to *move* a request off @@ -356,6 +400,26 @@ def migrated_can_serve(request: GenerateVideoRequest, project_id: str | None) -> return request.model is None or request.model in VIDEO_MODEL_MENU_LABELS +def _unported_image_form(request: GenerateImageRequest) -> str | None: + if request.refs: + return "a reference given by Flow media UUID" + if request.reference_entities: + return "character references" + if request.instructions: + return "Agent instructions" + if request.model not in IMAGE_MODEL_MENU_MATCHERS: + return f"the {request.model.value} model" + if request.aspect not in IMAGE_ASPECT_LIGATURE_MEASURED: + # The aspect radiogroup was enumerated once on this host and carried four + # radios — crop_16_9, crop_landscape, crop_square, crop_9_16 — with no + # crop_portrait. Refusing here is the difference between exit 36 ("gflow + # has not ported this") and exit 23 ("file a frontend-drift bug"), and the + # second is a lie: nothing is drifting. If a later enumeration finds the + # radio, move the aspect into the measured map rather than deleting this. + return f"the {request.aspect.value} aspect ratio" + return None + + def _exact(label: str) -> re.Pattern[str]: return re.compile(r"^\s*" + re.escape(label) + r"\s*$") @@ -490,6 +554,31 @@ def _r2v_body_problem(body: str, rpcid: str, media_ids: tuple[str, ...]) -> str return None +def _image_body_problem( + body: str, + reference_ids: tuple[str, ...], + model: ImageModel | None = None, +) -> str | None: + """Why an ``ogiZ0b`` request is not the image run the caller asked for.""" + if not body: + return ( + "migrated host: the image submit body could not be read, so the request " + "could not be confirmed before Flow acted on it" + ) + if model is not None and model.value not in body: + return ( + f"migrated host: the image submit body does not carry requested model " + f"{model.value} — refusing to report a generation made with persisted settings" + ) + missing = [media_id for media_id in reference_ids if media_id not in body] + if missing: + return ( + "migrated host: the image submit body is missing uploaded reference(s) " + f"{', '.join(missing[:4])} — refusing to report a text-only generation as i2i" + ) + return None + + class MigratedComposer: """Settings → prompt → submit → observe, against the migrated editor.""" @@ -621,6 +710,20 @@ async def _exit_agent_mode(cls, page: Page) -> tuple[AgentModeExit, Exception | """ if not await cls._agent_chip_pressed(page): return "absent", None + + # Some accounts leave the Agent panel expanded over the chip. Close only the + # structural panel close button; the pressed-state chip has already been + # confirmed above, so this cannot click a healthy composer into agent mode. + panel_close = ( + page.locator("flow-agent-panel button").filter(has=_ligature(page, "close")).first + ) + try: + if await panel_close.count() and await panel_close.is_visible(): + await panel_close.click(timeout=5000) + await asyncio.sleep(0.2) + except Exception as e: # noqa: BLE001 - best-effort panel cleanup + log.warning("migrated.agent_panel_close_failed", error=str(e)[:200]) + try: await page.locator(AGENT_MODE_CHIP).first.click(timeout=5000) except Exception as e: # noqa: BLE001 - the caller decides what it means @@ -726,6 +829,30 @@ async def apply_video_settings(self, page: Page, request: GenerateVideoRequest) raise await self._close_pane(page, strict=True) + async def apply_image_settings(self, page: Page, request: GenerateImageRequest) -> None: + """Bind Image mode, model, aspect and count with read-back before submission.""" + pane = await self._open_pane(page) + try: + await self._select(page, pane, axis="mode", lig="image") + await self._select_image_model(page, pane, request.model) + await self._select( + page, + pane, + axis="aspect", + lig=IMAGE_ASPECT_LIGATURE[request.aspect], + ) + await self._select(page, pane, axis="count", text=f"x{request.count}") + log.info( + "migrated.image_settings_applied", + model=request.model.value, + aspect=request.aspect.value, + count=request.count, + ) + except BaseException: + await self._close_pane(page, strict=False) + raise + await self._close_pane(page, strict=True) + async def _open_pane(self, page: Page) -> Any: trigger = page.locator(READY_ANCHOR).first try: @@ -968,6 +1095,39 @@ async def _select_model(self, page: Page, pane: Any, model: VideoModel) -> None: await items.nth(hits[0]).click(timeout=4000) log.info("migrated.model_selected", model=offered[hits[0]], requested=model.value) + async def _select_image_model(self, page: Page, pane: Any, model: ImageModel) -> None: + matcher = IMAGE_MODEL_MENU_MATCHERS.get(model) + if matcher is None: + raise ConfigurationError( + detail=f"image model '{model.value}' is not available on the migrated Flow host", + remediation_hint="Use nano-banana-2 or nano-pro, or force the labs host.", + ) + button = pane.locator("button").filter(has=_ligature(page, "arrow_drop_down")).first + if not await button.count(): + raise UiSelectorDriftError( + detail="migrated host: image model picker is missing (host=migrated)" + ) + current = (await button.text_content() or "").strip() + if matcher.matches(current): + log.info("migrated.image_model_already_selected", model=current, requested=model.value) + return + await button.click(timeout=4000) + items = page.locator(MENU_ITEM) + await items.first.wait_for(state="visible", timeout=5000) + offered = [text.strip() for text in await items.all_text_contents()] + hits = [index for index, text in enumerate(offered) if matcher.matches(text)] + if len(hits) != 1: + await page.keyboard.press("Escape") + raise ConfigurationError( + detail=( + f"image model '{model.value}' matched {len(hits)} entries on the migrated " + f"host; offered: {', '.join(offered)}" + ), + remediation_hint="Choose one offered image model or omit --model.", + ) + await items.nth(hits[0]).click(timeout=4000) + log.info("migrated.image_model_selected", model=offered[hits[0]], requested=model.value) + # --- start frame (i2v) ------------------------------------------------------ async def attach_start_frame(self, page: Page, project_id: str, image_path: Path) -> str: @@ -1603,6 +1763,117 @@ async def on_response(response: Any) -> None: if expect_media_id is not None or expect_reference_ids: page.remove_listener("request", on_request) + async def submit_images_and_observe( + self, + page: Page, + request: GenerateImageRequest, + *, + reference_ids: tuple[str, ...] = (), + ) -> list[GeneratedImage]: + """Submit Image mode and decode the completed ``ogiZ0b`` reply.""" + loop = asyncio.get_running_loop() + result: asyncio.Future[list[GeneratedImage]] = loop.create_future() + route_error: asyncio.Future[WireFormatError] = loop.create_future() + + def on_request(raw_request: Any) -> None: + url = str(getattr(raw_request, "url", "")) + if _rpcid(url) != IMAGE_SUBMIT_RPC or route_error.done(): + return + problem = _image_body_problem(_post_data(raw_request), reference_ids, request.model) + if problem is not None: + route_error.set_result( + WireFormatError(detail=problem, route=f"batchexecute:{IMAGE_SUBMIT_RPC}") + ) + + async def on_response(response: Any) -> None: + url = str(getattr(response, "url", "")) + if _rpcid(url) != IMAGE_SUBMIT_RPC or result.done(): + return + status = int(getattr(response, "status", 0) or 0) + if status != 200: + result.set_exception( + WireFormatError( + detail=f"migrated image submit answered HTTP {status}", + status=status, + route=f"batchexecute:{IMAGE_SUBMIT_RPC}", + ) + ) + return + try: + text = await response.text() + records = [ + record + for rpcid, payload in parse_frames(text) + if rpcid == IMAGE_SUBMIT_RPC + for record in image_records(rpcid, payload) + ] + if not records: + raise WireFormatError( + detail="migrated image submit returned no ogiZ0b frame", + route=f"batchexecute:{IMAGE_SUBMIT_RPC}", + ) + images = [ + GeneratedImage( + media_name=record.media_id, + workflow_id=record.workflow_id, + seed=record.seed, + prompt=record.prompt, + model_name_type=request.model.value, + aspect_ratio=request.aspect.value, + fife_url=record.image_url, + dimensions=record.dimensions, + display_name=record.display_name, + ) + for record in records + ] + except Exception as exc: # noqa: BLE001 - delivered through the waiting future + result.set_exception(exc) + return + result.set_result(images) + + page.on("request", on_request) + page.on("response", on_response) + try: + submit = page.locator("button").filter(has=_ligature(page, "arrow_forward")).first + if not await submit.count(): + await _raise_if_out_of_credits(page) + raise UiSelectorDriftError( + detail="migrated host: image submit button is missing (host=migrated)" + ) + enable_deadline = time.monotonic() + SUBMIT_ENABLE_BUDGET_S + while not await submit.is_enabled(): + if time.monotonic() >= enable_deadline: + await _raise_if_out_of_credits(page) + raise UiSelectorDriftError( + detail="migrated host: image submit stayed disabled (host=migrated)" + ) + await asyncio.sleep(SUBMIT_ENABLE_POLL_S) + await submit.click(timeout=5000) + done, _ = await asyncio.wait( + {result, route_error}, + timeout=IMAGE_REPLY_BUDGET_S, + return_when=asyncio.FIRST_COMPLETED, + ) + if route_error.done(): + raise route_error.result() + if result not in done: + raise TransportTimeoutError( + detail=( + f"migrated host: no {IMAGE_SUBMIT_RPC} image result within " + f"{IMAGE_REPLY_BUDGET_S:.0f}s of clicking submit" + ) + ) + return result.result() + finally: + # Consume the future's exception even on the paths that never read it + # (a route error raised first, a timeout): otherwise asyncio logs + # "exception was never retrieved" at GC, in a process that has already + # reported a different, correct error. Not a no-op — do not delete. + if result.done() and not result.cancelled(): + result.exception() + page.remove_listener("request", on_request) + page.remove_listener("response", on_response) + @staticmethod async def _await_terminal( terminal: asyncio.Future[GenerationRecord], @@ -1724,19 +1995,21 @@ async def run_video( """The migrated-host twin of the labs ``_generate_video_locked`` tail: same inputs, same ``VideoResult``, so recorder, CLI, MCP and worker are untouched. - t2v, and i2v from a local start frame (uploaded through the editor and bound on - the Start chip by file name). An end frame, a frame by UUID / ``@Name`` and r2v - are not ported yet; a fresh project can only be created through the labs - gallery, so the caller must name one (``--project``). + t2v, i2v from a local start frame (uploaded through the editor and bound on the + Start chip by file name), and r2v from local ``--ref`` files. An end frame and a + frame or reference given by UUID / ``@Name`` are not ported yet; a fresh project + can only be created through the labs gallery, so the caller must name one + (``--project``). """ unported = _unported_form(request) if unported is not None: raise FlowHostMigratedError( detail=( f"this account's Flow lives on flow.google.com, where gflow drives " - f"text-to-video and image-to-video from a local start frame; {unported} " - f"is not ported yet (#639) — pass --initial-frame without " - f"an end frame" + f"text-to-video, image-to-video from a local start frame, and " + f"reference-to-video from local files; {unported} is not ported yet " + f"(#639) — pass --initial-frame / --ref as local files, without an " + f"end frame" ), ) pid = project_id or extract_project_id(page.url) @@ -1809,3 +2082,47 @@ async def run_video( project_id=pid, flow_operation_id=record.workflow_id, ) + + +async def run_images( + page: Page, + request: GenerateImageRequest, + *, + project_id: str | None, +) -> list[GeneratedImage]: + """Drive supported image requests through the migrated project composer.""" + unported = _unported_image_form(request) + if unported is not None: + raise FlowHostMigratedError( + detail=( + "this account's Flow lives on flow.google.com, where gflow drives t2i " + f"and i2i from local files; {unported} is not ported yet (#639)" + ) + ) + pid = project_id or extract_project_id(page.url) + if not pid: + raise ConfigurationError( + detail=( + "image generation on flow.google.com needs an existing project; pass --project " + ) + ) + composer = MigratedComposer() + await composer.ensure_editor(page, pid) + await composer.apply_image_settings(page, request) + reference_ids: tuple[str, ...] = () + if request.ref_paths: + reference_ids = await composer.attach_references(page, pid, request.ref_paths) + chips = await composer.read_chips(page) + if len(chips) != len(reference_ids): + raise ReferenceNotFoundError( + detail=( + f"migrated host: {len(reference_ids)} image reference(s) uploaded but " + f"{len(chips)} mention chip(s) were bound before submit" + ) + ) + await composer.send_prompt(page, request.prompt, append=bool(reference_ids)) + return await composer.submit_images_and_observe( + page, + request, + reference_ids=reference_ids, + ) diff --git a/src/gflow_cli/api/transports/ui_automation.py b/src/gflow_cli/api/transports/ui_automation.py index 62ae9647..8fb4ba5a 100644 --- a/src/gflow_cli/api/transports/ui_automation.py +++ b/src/gflow_cli/api/transports/ui_automation.py @@ -36,7 +36,9 @@ extract_project_id, flow_host_kind, generation_error, + migrated_route, offered_menu_labels, + raise_if_migrated, ) from gflow_cli.api.transports.migrated_composer import MENU_ITEM, ModelMenuMatcher from gflow_cli.api.transports.ui_automation_video import ( @@ -952,6 +954,15 @@ def __init__(self) -> None: self._page: Page | None = None self._setup_done: bool = False self._owns_playwright: bool = False + # Latches once this transport has seen Flow serve the migrated host. The + # handoff is a server-assigned per-account boolean applied on every load, + # so it does not flip back mid-session — and the image path parks the page + # on about:blank after every run, which reads back as `labs`. Without the + # latch the SECOND image in one client session mints on the labs path and + # dies with the RecaptchaError of #673, i.e. the exact bug the page-owned + # mint exists to fix. Reachable from `gflow image batch`, which runs every + # prompt through one FlowApiClient (image_batch.py::_run_sequential). + self._served_migrated_host: bool = False # Cross-process profile lease (D3). Held ONLY on the standalone-context # path (setup with page=None), where this transport owns the persistent # context. On the shared-page path the caller (FlowApiClient) owns both @@ -3003,6 +3014,29 @@ async def generate_images( request, project_id=project_id, name_resolver=name_resolver ) + def uses_page_owned_image_recaptcha(self) -> bool: + """Whether this run will let the migrated page own token mint + submit. + + Kept as a narrow optional transport capability rather than changing every + strategy protocol: only UI automation can observe a page-owned request. + + Answers from the LATCH as well as the current URL. Reading `page.url` + alone was wrong in a way no single-image test could see: every migrated + image run ends by parking the page on ``about:blank`` (see + ``_generate_images_locked``), which routes as ``labs``, so a second image + on one warm client fell back to minting on the labs bootstrap page — the + #673 failure this capability exists to prevent. + """ + if self._page is None: + return self._served_migrated_host + from gflow_cli.config import get_settings # noqa: PLC0415 + + route = migrated_route(self._page.url, get_settings().flow_host) + if route in {"migrated", "blocked"}: + self._served_migrated_host = True + return True + return self._served_migrated_host + async def _generate_images_locked( self, request: GenerateImageRequest, @@ -3018,10 +3052,34 @@ async def _generate_images_locked( page: Page = self._page # type: ignore[assignment] # guard in caller out_dir = self._out_dir - await self._enter_editor(page, out_dir, project_id=project_id) - # Dismiss any Flow changelog / "What's new" overlay that may be on top - # of the editor before we click into settings / submit (#26). - await self._dismiss_blocking_overlays(page, out_dir) + from gflow_cli.api.transports.migrated_composer import run_images # noqa: PLC0415 + from gflow_cli.config import get_settings # noqa: PLC0415 + + flow_host = get_settings().flow_host + route = migrated_route(page.url, flow_host) + if route == "labs": + await self._enter_editor(page, out_dir, project_id=project_id) + # Dismiss any Flow changelog / "What's new" overlay that may be on top + # of the editor before we click into settings / submit (#26). + await self._dismiss_blocking_overlays(page, out_dir) + route = migrated_route(page.url, flow_host) + if route in {"migrated", "blocked"}: + self._served_migrated_host = True + if route == "blocked": + raise_if_migrated(page, at="image_flow_host_kill_switch") + if route == "migrated": + try: + return await run_images(page, request, project_id=project_id) + finally: + # Park off the project so the next borrower of this page does not + # inherit a mounted composer. The URL is therefore NOT a reliable + # record of which host served us — `_served_migrated_host` above + # is, and `uses_page_owned_image_recaptcha` reads that latch. + try: + await page.goto("about:blank", wait_until="commit", timeout=5_000) + await self._settle_if_redirecting(page) + except Exception as exc: # noqa: BLE001 - parking is best-effort + log.warning("migrated.image_page_park_failed", error=str(exc)[:120]) # Determine the arm this command REQUIRES: explicit --ui-mode / env, or # inferred — agent instructions (-i) are an agentic-only surface, so they @@ -3236,6 +3294,11 @@ async def generate_images_batch( raise RuntimeError( msg, ) + # The batch path drives labs selectors only — `run_images` is the single-image + # port. Without this it ran those selectors against flow.google.com and failed + # as selector drift (exit 23), blaming a frontend that was fine. Refuse before + # any submit so the user gets the non-retryable exit 36 and the real reason. + raise_if_migrated(self._page, at="image_batch_unported") async with self._generate_lock: return await self._generate_images_batch_locked( prompts=prompts, 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/config.py b/src/gflow_cli/config.py index e512de73..f4c3a349 100644 --- a/src/gflow_cli/config.py +++ b/src/gflow_cli/config.py @@ -549,10 +549,11 @@ def _validate_llm_base_url(cls, v: object) -> object: default="auto", description=( "Which Flow frontend gflow drives. 'auto' (default): flow.google.com is the " - "default host for every request it can serve today (text-to-video, and " + "default host for every video request it can serve today (text-to-video, and " "image-to-video from a local start frame, in an existing project), on moved " "and unmoved accounts alike; requests it cannot " - "serve yet keep the labs driver on an unmoved account. 'flow.google.com': force " + "serve yet keep the labs driver on an unmoved account. Migrated accounts also " + "use it for t2i and local-file i2i. 'flow.google.com': force " "the migrated composer for everything. 'labs.google': never use it — a moved " "account fails with exit 36 (kill switch). Override via GFLOW_CLI_FLOW_HOST." ), diff --git a/src/gflow_cli/errors.py b/src/gflow_cli/errors.py index 22367a12..0989a5ae 100644 --- a/src/gflow_cli/errors.py +++ b/src/gflow_cli/errors.py @@ -721,8 +721,10 @@ class FlowHostMigratedError(GFlowError): The migrated frontend is a different build (Angular Material over ``batchexecute``): the labs drivers' ligature selectors miss there at once. The - migrated composer drives it for text-to-video; every other request still lands - here. That is NOT selector rot, and reporting it as + migrated composer drives it for text-to-video, image-to-video and + reference-to-video from local files, text-to-image, and image-to-image from + local files; every other request still lands here. That is NOT selector rot, + and reporting it as :class:`UiSelectorDriftError` (exit 23, "file a bug about the selector") sent operators hunting for the wrong cause. @@ -740,8 +742,10 @@ class FlowHostMigratedError(GFlowError): title = "Flow served the migrated flow.google.com frontend" _default_remediation = ( "Google has moved this account's Flow from labs.google to flow.google.com. " - "gflow drives that frontend for text-to-video and for image-to-video from a " - "local --initial-frame (no end frame, no UUID or @Name frame; --project required) " + "gflow drives that frontend for text-to-video, image-to-video from a local " + "--initial-frame, reference-to-video from local --ref files, text-to-image, " + "and image-to-image from local files (no end frame, no UUID or @Name frame, " + "no character entities; --project required) " "(GFLOW_CLI_FLOW_HOST=auto, the default, or flow.google.com); you see this " "error because GFLOW_CLI_FLOW_HOST=labs.google switched the migrated composer " "off, or because this request type is not ported to the migrated host yet. " @@ -962,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/src/gflow_cli/mcp/tools.py b/src/gflow_cli/mcp/tools.py index 94ec379c..2e954bb2 100644 --- a/src/gflow_cli/mcp/tools.py +++ b/src/gflow_cli/mcp/tools.py @@ -741,6 +741,8 @@ def _build_video_media_inputs( "(resolves to referenceEntities/referenceImages). Reference a SAVED named asset via " "@Name; reference an arbitrary one-off image via reference_images. See " "docs/REFERENCE_STRATEGIES.md. " + "On migrated flow.google.com accounts, use an existing project and local reference " + "files; UUID/entity references and image4 are labs-only. " "Returns local file paths to the generated images." ), ) diff --git a/tests/api/transports/test_migrated_composer.py b/tests/api/transports/test_migrated_composer.py index a5cceb60..76cecec2 100644 --- a/tests/api/transports/test_migrated_composer.py +++ b/tests/api/transports/test_migrated_composer.py @@ -18,15 +18,20 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any +from unittest.mock import patch import pytest from structlog.testing import capture_logs +from gflow_cli.api.image import Aspect as ImageAspect +from gflow_cli.api.image import GenerateImageRequest +from gflow_cli.api.image import Model as ImageModel from gflow_cli.api.transports import migrated_composer from gflow_cli.api.video import Aspect, GenerateVideoRequest, Mode, VideoModel from gflow_cli.errors import ( EXIT_CODE_MAP, ConfigurationError, + FlowHostMigratedError, InsufficientCreditsError, MediaUploadRejectedError, ReferenceNotFoundError, @@ -150,6 +155,7 @@ class Dom: agent_chip_probe_raises_from: int | None = None agent_chip_probes: int = 0 agent_chip_click_raises: bool = False + agent_panel_expanded: bool = False def _default_dom() -> Dom: @@ -268,6 +274,8 @@ async def is_enabled(self) -> bool: async def get_attribute(self, name: str) -> str | None: if name == "aria-checked" and self.items and isinstance(self.items[0], Radio): return "true" if self.items[0].checked else "false" + if name == "aria-pressed" and self.kind == "agent_toggle": + return "true" if self.page.dom.agent_mode else "false" return None async def text_content(self) -> str | None: @@ -291,6 +299,10 @@ async def click(self, **_: Any) -> None: target = self.items[0] if self.kind == "trigger": dom.pane_open = not dom.pane_open + elif self.kind == "agent_close": + dom.agent_panel_expanded = False + elif self.kind == "agent_toggle": + dom.agent_mode = not dom.agent_mode elif self.kind == "radio": if not target.stale: group = next(g for g in dom.groups.values() if target in g) @@ -454,7 +466,9 @@ def __init__( self.gotos: list[str] = [] self._handlers: dict[str, list[Any]] = {"response": [], "request": []} self._pending_lig: re.Pattern[str] | None = None - self.scripted_responses: list[tuple[str, str]] = [] # fired on submit click + # (url, text) or (url, text, http_status) — the status defaults to 200, so an + # existing 2-tuple keeps working and a non-200 reply is expressible. + self.scripted_responses: list[tuple[str, ...]] = [] # fired on submit click self.scripted_request: tuple[str, str] | None = None # (rpcid, POST body) on submit async def goto(self, url: str, **_: Any) -> None: @@ -485,8 +499,9 @@ def _fire_submit(self) -> None: asyncio.get_event_loop().create_task( _maybe_await(h(FakeRequest(_batch_url(rpcid), body))) ) - for url, text in self.scripted_responses: - self._fire_response(FakeResponse(url, text)) + for scripted in self.scripted_responses: + url, text, *rest = scripted + self._fire_response(FakeResponse(url, text, *rest)) def expect_file_chooser(self, **_: Any) -> FakeChooserContext: return FakeChooserContext(self) @@ -505,6 +520,9 @@ def locator(self, css: str, *, scope: FakeLocator | None = None) -> FakeLocator: if css == migrated_composer.AGENT_MODE_CHIP: pressed = dom.agent_mode and dom.agent_chip_present return FakeLocator(self, "agent_chip", ["chip"] if pressed else []) + if css == "flow-agent-panel button": + buttons = [Radio("close", "Close")] if dom.agent_panel_expanded else [] + return FakeLocator(self, "agent_close", buttons) if css == TOOLBAR_ADD_XPATH: return FakeLocator(self, "toolbar_add", ["add"] if dom.add_button_present else []) if css == ".cdk-overlay-pane [role='menuitem']:has(mat-icon:text-is('upload'))": @@ -692,6 +710,37 @@ async def test_apply_video_settings_selects_each_axis_and_reads_back() -> None: assert not page.dom.pane_open # closed afterwards +async def test_apply_image_settings_selects_mode_model_aspect_and_count() -> None: + from gflow_cli.api.transports.migrated_composer import MigratedComposer + + page = FakePage() + page.dom.models = ["🍌 Nano Banana 2", "🍌 Nano Banana 2 Lite", "🍌 Nano Banana Pro"] + page.dom.model_label = "🍌 Nano Banana 2 Lite" + page.dom.groups["aspect"] = [ + Radio("crop_16_9", "16:9"), + Radio("crop_landscape", "4:3"), + Radio("crop_square", "1:1", checked=True), + Radio("crop_portrait", "3:4"), + Radio("crop_9_16", "9:16"), + ] + + await MigratedComposer().apply_image_settings( + page, + GenerateImageRequest( + prompt="a crane", + model=ImageModel.NARWHAL, + aspect=ImageAspect.PORTRAIT_THREE_FOUR, + count=3, + ), + ) + + assert page.dom.groups["mode"][0].checked + assert page.dom.model_label == "🍌 Nano Banana 2" + assert page.dom.groups["aspect"][3].checked + assert page.dom.groups["count"][2].checked + assert not page.dom.pane_open + + async def test_the_submode_follows_the_reference_not_the_mode_name() -> None: """A character reference forces Ingredients even on a t2v request (#723). @@ -1461,6 +1510,19 @@ async def test_ensure_editor_skips_navigation_when_already_there() -> None: assert page.gotos == [] +async def test_ensure_editor_exits_persisted_agent_mode_before_waiting_for_settings() -> None: + from gflow_cli.api.transports.migrated_composer import MigratedComposer + + page = FakePage(url="https://flow.google.com/project/p1") + page.dom.agent_mode = True + page.dom.agent_panel_expanded = True + + await MigratedComposer().ensure_editor(page, "p1", timeout_s=1.0) + + assert not page.dom.agent_mode + assert not page.dom.agent_panel_expanded + + async def test_ensure_editor_without_trigger_is_selector_drift() -> None: from gflow_cli.api.transports.migrated_composer import MigratedComposer @@ -2202,3 +2264,242 @@ async def test_ensure_editor_drift_message_does_not_blame_agent_mode_in_classic( with pytest.raises(UiSelectorDriftError) as exc: await MigratedComposer().ensure_editor(page, "p1", timeout_s=0.2) assert "agent mode" not in str(exc.value).casefold() + + +# ---------------------------------------------------------------------------------- +# submit_images_and_observe — the migrated image drive path. +# +# These mirror the video `submit_and_observe` cases above against the same FakePage. +# The path had no offline coverage at all: every image test mocked `run_images` away, +# so the branches that decide whether a run is TRUSTWORTHY — the route-error listener +# that refuses to report a text-only generation as i2i, and a non-200 submit — were +# reachable only from a live account. +# ---------------------------------------------------------------------------------- + + +def _image_frame(reference: str | None = None) -> str: + from tests.api.transports.test_migrated_images import image_payload + + return _frame("ogiZ0b", image_payload(reference=reference)) + + +async def test_image_submit_decodes_the_ogiz0b_reply() -> None: + from gflow_cli.api.image import GenerateImageRequest + from gflow_cli.api.transports.migrated_composer import MigratedComposer + + page = FakePage() + page.dom.prompt = "a blue cup" + page.scripted_responses = [(_batch_url("ogiZ0b"), _image_frame())] + + images = await MigratedComposer().submit_images_and_observe( + page, GenerateImageRequest(prompt="a blue cup") + ) + + assert len(images) == 1 + assert images[0].fife_url.startswith("https://flow-content.google/image/") + assert images[0].dimensions == (1376, 768) + assert page.dom.submit_clicked == 1 + + +async def test_an_image_submit_missing_its_reference_is_refused_not_reported_as_i2i() -> None: + """The route-error listener is the only thing between a dropped upload and a + plausible T2I result handed back as image-to-image. Flow answers 200 either way. + """ + from gflow_cli.api.image import GenerateImageRequest + from gflow_cli.api.transports.migrated_composer import MigratedComposer + + missing = "44444444-4444-4444-8444-444444444444" + page = FakePage() + page.dom.prompt = "a blue cup" + page.scripted_request = ("ogiZ0b", '[["ogiZ0b", "NARWHAL no-reference-here"]]') + page.scripted_responses = [(_batch_url("ogiZ0b"), _image_frame())] + + with pytest.raises(WireFormatError) as info: + await MigratedComposer().submit_images_and_observe( + page, GenerateImageRequest(prompt="a blue cup"), reference_ids=(missing,) + ) + assert missing in str(info.value) + + +async def test_a_non_200_image_submit_is_a_wire_format_error_carrying_the_status() -> None: + from gflow_cli.api.image import GenerateImageRequest + from gflow_cli.api.transports.migrated_composer import MigratedComposer + + page = FakePage() + page.dom.prompt = "a blue cup" + page.scripted_responses = [(_batch_url("ogiZ0b"), "", 500)] + + with pytest.raises(WireFormatError) as info: + await MigratedComposer().submit_images_and_observe( + page, GenerateImageRequest(prompt="a blue cup") + ) + assert "HTTP 500" in str(info.value) + + +async def test_an_image_reply_that_never_arrives_is_a_timeout_not_a_hang( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from gflow_cli.api.image import GenerateImageRequest + from gflow_cli.api.transports import migrated_composer + from gflow_cli.api.transports.migrated_composer import MigratedComposer + + monkeypatch.setattr(migrated_composer, "IMAGE_REPLY_BUDGET_S", 0.05) + page = FakePage() + page.dom.prompt = "a blue cup" + page.scripted_responses = [] # Flow answers nothing + + with pytest.raises(TransportTimeoutError, match="ogiZ0b"): + await MigratedComposer().submit_images_and_observe( + page, GenerateImageRequest(prompt="a blue cup") + ) + + +# ---------------------------------------------------------------------------------- +# run_images — the migrated image entry point. +# +# Every image test mocked this away, so the whole orchestration (guard → project → +# editor → settings → references → prompt → submit) was reachable only from a live +# account. That left SonarCloud's new-code coverage under its gate on the merge, and +# more importantly left the reference chip-count check — the one thing standing +# between a dropped upload and a T2I result returned as i2i — never executed offline. +# ---------------------------------------------------------------------------------- + + +def _image_page() -> FakePage: + """A FakePage on a migrated project, wired for the image axes.""" + page = FakePage(url="https://flow.google.com/project/p1") + page.dom.prompt = "a blue cup" + page.dom.models = ["🍌 Nano Banana 2", "🍌 Nano Banana 2 Lite", "🍌 Nano Banana Pro"] + page.dom.model_label = "🍌 Nano Banana 2" + page.dom.groups["aspect"] = [ + Radio("crop_16_9", "16:9", checked=True), + Radio("crop_landscape", "4:3"), + Radio("crop_square", "1:1"), + Radio("crop_9_16", "9:16"), + ] + page.scripted_responses = [(_batch_url("ogiZ0b"), _image_frame())] + return page + + +async def test_run_images_drives_the_whole_t2i_path() -> None: + from gflow_cli.api.image import GenerateImageRequest + from gflow_cli.api.transports.migrated_composer import run_images + + page = _image_page() + images = await run_images(page, GenerateImageRequest(prompt="a blue cup"), project_id="p1") + + assert len(images) == 1 + assert images[0].fife_url.startswith("https://flow-content.google/image/") + assert page.dom.submit_clicked == 1 + + +async def test_run_images_refuses_an_unported_form_before_touching_the_page() -> None: + from gflow_cli.api.image import GenerateImageRequest, ImageRef + from gflow_cli.api.transports.migrated_composer import run_images + + page = _image_page() + with pytest.raises(FlowHostMigratedError, match="media UUID"): + await run_images( + page, + GenerateImageRequest(prompt="a blue cup", refs=(ImageRef(MEDIA),)), + project_id="p1", + ) + assert page.dom.submit_clicked == 0 + assert page.gotos == [] + + +async def test_run_images_without_a_project_is_a_configuration_error() -> None: + """A fresh project can only be made through the labs gallery, so the caller must + name one. Exit 11, not a mid-run failure after the editor is already mounted. + """ + from gflow_cli.api.image import GenerateImageRequest + from gflow_cli.api.transports.migrated_composer import run_images + + page = _image_page() + page.url = "https://flow.google.com/" # no project id to extract + with pytest.raises(ConfigurationError, match="--project"): + await run_images(page, GenerateImageRequest(prompt="a blue cup"), project_id=None) + assert page.dom.submit_clicked == 0 + + +async def test_run_images_refuses_when_a_reference_uploaded_but_never_bound( + tmp_path: Path, +) -> None: + """Uploads that do not become mention chips are the silent-degrade-to-T2I case: + Flow would accept the submit and bill it, and the caller would get a plausible + image that ignored their reference. + """ + from gflow_cli.api.image import GenerateImageRequest + from gflow_cli.api.transports.migrated_composer import MigratedComposer, run_images + + ref = tmp_path / "reference.png" + ref.write_bytes(b"png") + page = _image_page() + + async def _attach(*_: Any, **__: Any) -> tuple[str, ...]: + return (MEDIA_UP,) + + async def _no_chips(*_: Any, **__: Any) -> list[str]: + return [] + + with ( + patch.object(MigratedComposer, "attach_references", _attach), + patch.object(MigratedComposer, "read_chips", _no_chips), + pytest.raises(ReferenceNotFoundError, match="mention chip"), + ): + await run_images( + page, GenerateImageRequest(prompt="a blue cup", ref_paths=(ref,)), project_id="p1" + ) + assert page.dom.submit_clicked == 0 + + +async def test_a_missing_image_submit_with_a_credits_warning_is_not_drift() -> None: + """The image path carries its own copy of the video path's credits check, so it + needs its own proof. A drained wallet REPLACES Flow's submit button (#721); calling + that selector drift tells the user to file a frontend bug no code change can fix. + """ + from gflow_cli.api.image import GenerateImageRequest + from gflow_cli.api.transports.migrated_composer import MigratedComposer + + page = _image_page() + page.dom.submit_anchor_present = False + page.dom.credits_warning_present = True + + with pytest.raises(InsufficientCreditsError) as caught: + await MigratedComposer().submit_images_and_observe( + page, GenerateImageRequest(prompt="a blue cup") + ) + assert page.dom.submit_clicked == 0 + assert not isinstance(caught.value, UiSelectorDriftError) + + +async def test_a_missing_image_submit_with_no_credits_warning_is_still_drift() -> None: + from gflow_cli.api.image import GenerateImageRequest + from gflow_cli.api.transports.migrated_composer import MigratedComposer + + page = _image_page() + page.dom.submit_anchor_present = False + page.dom.credits_warning_present = False + + with pytest.raises(UiSelectorDriftError, match="is missing"): + await MigratedComposer().submit_images_and_observe( + page, GenerateImageRequest(prompt="a blue cup") + ) + + +async def test_an_image_submit_that_stays_disabled_is_drift_not_a_hang( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from gflow_cli.api.image import GenerateImageRequest + from gflow_cli.api.transports.migrated_composer import MigratedComposer + + monkeypatch.setattr(migrated_composer, "SUBMIT_ENABLE_BUDGET_S", 0.05) + monkeypatch.setattr(migrated_composer, "SUBMIT_ENABLE_POLL_S", 0.01) + page = _image_page() + page.dom.prompt = "" # nothing in the composer: the button never enables + + with pytest.raises(UiSelectorDriftError, match="stayed disabled"): + await MigratedComposer().submit_images_and_observe( + page, GenerateImageRequest(prompt="a blue cup") + ) + assert page.dom.submit_clicked == 0 diff --git a/tests/api/transports/test_migrated_images.py b/tests/api/transports/test_migrated_images.py new file mode 100644 index 00000000..c894467f --- /dev/null +++ b/tests/api/transports/test_migrated_images.py @@ -0,0 +1,361 @@ +"""Image generation on Flow's migrated Angular composer (#639).""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gflow_cli.api.client import FlowApiClient +from gflow_cli.api.dto import GeneratedImage +from gflow_cli.api.image import ( + AgentInstruction, + Aspect, + GenerateImageRequest, + ImageRef, + Model, +) +from gflow_cli.errors import WireFormatError + +MEDIA = "11111111-1111-4111-8111-111111111111" +WORKFLOW = "22222222-2222-4222-8222-222222222222" +PROJECT = "33333333-3333-4333-8333-333333333333" +REFERENCE = "44444444-4444-4444-8444-444444444444" +URL = f"https://flow-content.google/image/{MEDIA}?Expires=1&Signature=secret" + + +def image_payload(*, reference: str | None = None) -> list[Any]: + reference_bits: list[Any] = [] if reference is None else [[[None, 1, reference]]] + media = [ + MEDIA, + None, + WORKFLOW, + None, + None, + None, + [ + [ + None, + 12345, + None, + None, + None, + None, + 1, + "a blue cup", + 25, + None, + None, + WORKFLOW, + None, + URL, + 3, + [None, None, [["a blue cup"]], reference_bits], + None, + MEDIA, + ], + None, + [1376, 768], + ], + ] + workflow = [ + WORKFLOW, + None, + None, + ["Blue cup", [1, 2], None, None, MEDIA, "batch", [3, 4]], + PROJECT, + ] + return [[[media]], [[workflow]]] + + +def _request(**changes: Any) -> GenerateImageRequest: + values: dict[str, Any] = {"prompt": "a blue cup"} + values.update(changes) + return GenerateImageRequest(**values) + + +def test_image_records_parse_the_measured_ogiz0b_shape() -> None: + from gflow_cli.api.transports.batchexecute import image_records + + records = image_records("ogiZ0b", image_payload(reference=REFERENCE)) + assert len(records) == 1 + record = records[0] + assert record.media_id == MEDIA + assert record.workflow_id == WORKFLOW + assert record.project_id == PROJECT + assert record.seed == 12345 + assert record.prompt == "a blue cup" + assert record.image_url == URL + assert record.dimensions == (1376, 768) + assert record.display_name == "Blue cup" + + +def test_image_records_reject_unknown_envelopes_without_leaking_tokens() -> None: + from gflow_cli.api.transports.batchexecute import image_records + + token = "A" * 180 + with pytest.raises(WireFormatError) as info: + image_records("ogiZ0b", ["changed", token]) + assert token not in str(info.value) + assert info.value.route == "batchexecute:ogiZ0b" + + +def test_unported_image_forms_are_named_and_refused_pre_submit() -> None: + from gflow_cli.api.transports.migrated_composer import _unported_image_form + + local = Path("reference.png") + assert _unported_image_form(_request()) is None + assert _unported_image_form(_request(ref_paths=(local,))) is None + assert _unported_image_form(_request(refs=(ImageRef(REFERENCE),))) == ( + "a reference given by Flow media UUID" + ) + assert ( + _unported_image_form( + _request(reference_entities=("entity-1",), reference_entity_names=("Hero",)), + ) + == "character references" + ) + assert ( + _unported_image_form(_request(instructions=(AgentInstruction(text="keep it blue"),))) + == "Agent instructions" + ) + assert _unported_image_form(_request(model=Model.IMAGEN_3_5)) is not None + + +def test_only_the_measured_aspects_are_offered_and_three_four_is_refused() -> None: + """3:4 has no radio in the enumerated aspect row, so it must be refused as an + unported form (exit 36) — never left to miss its selector and surface as + UiSelectorDriftError (exit 23), which tells the user to file a frontend bug + about a frontend that is behaving correctly. + """ + from gflow_cli.api.transports.migrated_composer import ( + IMAGE_ASPECT_LIGATURE_MEASURED, + _unported_image_form, + ) + + for aspect in IMAGE_ASPECT_LIGATURE_MEASURED: + assert _unported_image_form(_request(aspect=aspect)) is None, aspect + assert Aspect.PORTRAIT_THREE_FOUR not in IMAGE_ASPECT_LIGATURE_MEASURED + refusal = _unported_image_form(_request(aspect=Aspect.PORTRAIT_THREE_FOUR)) + assert refusal is not None + assert "aspect" in refusal + + +def test_image_submit_body_requires_every_uploaded_reference() -> None: + from gflow_cli.api.transports.migrated_composer import _image_body_problem + + body = f'[["ogiZ0b", "GEM_PIX_2 {REFERENCE}"]]' + assert _image_body_problem(body, (REFERENCE,)) is None + problem = _image_body_problem(body, (REFERENCE, MEDIA)) + assert problem is not None + assert MEDIA in problem + + +def test_nano_banana_2_does_not_match_the_lite_sibling() -> None: + from gflow_cli.api.transports.migrated_composer import IMAGE_MODEL_MENU_MATCHERS + + matcher = IMAGE_MODEL_MENU_MATCHERS[Model.NARWHAL] + assert matcher.matches("🍌 Nano Banana 2") + assert not matcher.matches("🍌 Nano Banana 2 Lite") + + +class _PageOwnedImageTransport: + def __init__(self, owned: bool) -> None: + self.owned = owned + self.request: GenerateImageRequest | None = None + + def uses_page_owned_image_recaptcha(self) -> bool: + return self.owned + + async def generate_images(self, **kwargs: Any) -> list[GeneratedImage]: + self.request = kwargs["request"] + return [ + GeneratedImage( + media_name=MEDIA, + workflow_id=WORKFLOW, + seed=1, + prompt="a blue cup", + model_name_type="NARWHAL", + aspect_ratio="IMAGE_ASPECT_RATIO_PORTRAIT", + fife_url=URL, + dimensions=(1376, 768), + ) + ] + + +async def test_client_skips_legacy_mint_when_the_page_owns_image_submission( + monkeypatch: pytest.MonkeyPatch, +) -> None: + transport = _PageOwnedImageTransport(owned=True) + client = FlowApiClient.__new__(FlowApiClient) + client.transport = transport # type: ignore[assignment] + mint = AsyncMock(side_effect=AssertionError("legacy mint must not run")) + monkeypatch.setattr(client, "_mint_recaptcha_token", mint) + + images = await client._drive_images_generation( # noqa: SLF001 + project_id=PROJECT, + req=_request(), + recaptcha_action="imageGeneration", + ) + + assert images[0].media_name == MEDIA + assert transport.request is not None + assert transport.request.recaptcha_token == "" + mint.assert_not_awaited() + + +async def test_client_keeps_legacy_mint_for_other_image_transports( + monkeypatch: pytest.MonkeyPatch, +) -> None: + transport = _PageOwnedImageTransport(owned=False) + client = FlowApiClient.__new__(FlowApiClient) + client.transport = transport # type: ignore[assignment] + mint = AsyncMock(return_value="minted") + monkeypatch.setattr(client, "_mint_recaptcha_token", mint) + + await client._drive_images_generation( # noqa: SLF001 + project_id=PROJECT, + req=_request(aspect=Aspect.PORTRAIT), + recaptcha_action="imageGeneration", + ) + + assert transport.request is not None + assert transport.request.recaptcha_token == "minted" + mint.assert_awaited_once_with("imageGeneration") + + +async def test_migrated_image_route_dispatches_before_the_labs_driver( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from gflow_cli.api.transports.ui_automation import UiAutomationTransport + from gflow_cli.config import reset_settings + + monkeypatch.setenv("GFLOW_CLI_FLOW_HOST", "auto") + reset_settings() + transport = UiAutomationTransport() + page = MagicMock() + page.url = f"https://flow.google.com/project/{PROJECT}" + + async def goto(url: str, **_: Any) -> None: + page.url = url + + page.goto = goto + transport._page = page # noqa: SLF001 + transport._setup_done = True # noqa: SLF001 + generated = [ + GeneratedImage( + media_name=MEDIA, + workflow_id=WORKFLOW, + seed=1, + prompt="a blue cup", + model_name_type=Model.NARWHAL.value, + aspect_ratio=Aspect.PORTRAIT.value, + fife_url=URL, + dimensions=(768, 1376), + ) + ] + run_images = AsyncMock(return_value=generated) + monkeypatch.setattr("gflow_cli.api.transports.migrated_composer.run_images", run_images) + + result = await transport.generate_images(project_id=PROJECT, request=_request()) + + assert result == generated + run_images.assert_awaited_once() + assert page.url == "about:blank" + + +def test_page_owned_recaptcha_is_only_selected_for_the_migrated_route( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from gflow_cli.api.transports.ui_automation import UiAutomationTransport + from gflow_cli.config import reset_settings + + monkeypatch.setenv("GFLOW_CLI_FLOW_HOST", "auto") + reset_settings() + transport = UiAutomationTransport() + page = MagicMock() + transport._page = page # noqa: SLF001 + + page.url = f"https://labs.google/fx/en/tools/flow/project/{PROJECT}" + assert not transport.uses_page_owned_image_recaptcha() + page.url = f"https://flow.google.com/project/{PROJECT}" + assert transport.uses_page_owned_image_recaptcha() + + +def test_page_owned_recaptcha_survives_the_post_run_page_park( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The SECOND image in one client session must still skip the labs mint. + + Every migrated image run ends by parking the page on ``about:blank`` + (``_generate_images_locked``), which routes as ``labs``. Deriving the + capability from ``page.url`` alone therefore answered ``False`` on the next + call, sending it back to ``_mint_recaptcha_token`` on the pooled bootstrap + page — the exact ``RecaptchaError`` of #673 that the page-owned mint exists + to prevent. Reachable from ``gflow image batch``, which runs every prompt + through one ``FlowApiClient`` (``image_batch.py::_run_sequential``), so no + single-image test could see it. + """ + from gflow_cli.api.transports.ui_automation import UiAutomationTransport + from gflow_cli.config import reset_settings + + monkeypatch.setenv("GFLOW_CLI_FLOW_HOST", "auto") + reset_settings() + transport = UiAutomationTransport() + page = MagicMock() + transport._page = page # noqa: SLF001 + + page.url = f"https://flow.google.com/project/{PROJECT}" + assert transport.uses_page_owned_image_recaptcha() + + page.url = "about:blank" + assert transport.uses_page_owned_image_recaptcha(), ( + "the parked page must not read as a labs account on the next generation" + ) + + +def test_a_never_migrated_transport_does_not_latch_into_the_page_owned_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The latch is one-way, but it must never arm without evidence — otherwise a + labs account would stop minting the token it genuinely needs. + """ + from gflow_cli.api.transports.ui_automation import UiAutomationTransport + from gflow_cli.config import reset_settings + + monkeypatch.setenv("GFLOW_CLI_FLOW_HOST", "auto") + reset_settings() + transport = UiAutomationTransport() + page = MagicMock() + transport._page = page # noqa: SLF001 + + for url in ("about:blank", f"https://labs.google/fx/en/tools/flow/project/{PROJECT}"): + page.url = url + assert not transport.uses_page_owned_image_recaptcha(), url + + +async def test_image_batch_is_refused_on_the_migrated_host_before_any_submit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """`generate_images_batch` drives labs selectors only. Without this guard it ran + them against flow.google.com and died as UiSelectorDriftError (exit 23) — telling + the user to file a frontend-drift bug about a frontend that was working. The + single-image path had a guard from the start; the batch path had none, and the + parity gate could not see it because both are behind the same leaf command. + """ + from gflow_cli.api.transports.ui_automation import UiAutomationTransport + from gflow_cli.config import reset_settings + from gflow_cli.errors import FlowHostMigratedError + + monkeypatch.setenv("GFLOW_CLI_FLOW_HOST", "auto") + reset_settings() + transport = UiAutomationTransport() + page = MagicMock() + page.url = f"https://flow.google.com/project/{PROJECT}" + transport._page = page # noqa: SLF001 + transport._setup_done = True # noqa: SLF001 + + with pytest.raises(FlowHostMigratedError): + await transport.generate_images_batch(prompts=[_request()], jitter_range=(0.0, 0.0)) 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/conftest.py b/tests/conftest.py index bf039cfd..f59cd341 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,6 +31,40 @@ from tests.fixtures.doctor_env import healthy_doctor_env # noqa: F401 +def _assert_source_under_test_is_this_tree() -> None: + """Fail loudly if ``gflow_cli`` resolves outside the tree these tests live in (#760). + + A git worktree has no ``.venv`` of its own: the venv's editable install pins ``src/`` + in the PRIMARY checkout. So ``pytest`` run inside a worktree executes the worktree's + TESTS against the main checkout's SOURCE — and it fails *open*. A release branch runs + its own gates, sees green, and has validated code it is not shipping; a regression on + that branch passes, because the other tree's healthy code answered for it. + + Caught during the v0.71.1 release only because one new assertion happened to differ + between the two trees. Nothing structural would have caught it, which is why this is a + hard error rather than a warning. + + Deliberately narrow: it fires only when a local ``src/gflow_cli`` exists beside these + tests AND the import came from somewhere else. Testing an installed wheel — where no + local source tree is present — is untouched. + """ + import gflow_cli + + tests_root = Path(__file__).resolve().parent.parent + local_src = tests_root / "src" / "gflow_cli" + if not local_src.is_dir(): + return # no source tree beside the tests: an installed-package run, leave it alone + imported = Path(gflow_cli.__file__).resolve().parent + if imported == local_src.resolve(): + return + raise pytest.UsageError( + f"gflow_cli was imported from {imported}, but these tests live in {tests_root} " + f"(which ships {local_src}). The tests would validate the WRONG source tree and " + f"pass regardless — see #760. If you are in a git worktree, prefix the run with " + f'PYTHONPATH="{tests_root / "src"}".' + ) + + def pytest_configure(config: pytest.Config) -> None: """Stop git from escaping pytest's basetemp into the real clone (#605). @@ -48,6 +82,7 @@ def pytest_configure(config: pytest.Config) -> None: (``tmp_path_factory.getbasetemp()``) free to walk out. Any ceiling the developer already set is preserved after ours. """ + _assert_source_under_test_is_this_tree() basetemp = config.getoption("basetemp", None) if basetemp: ceilings = [str(Path(basetemp).resolve().parent), os.environ.get("GIT_CEILING_DIRECTORIES")] diff --git a/tests/e2e/test_migrated_host_e2e.py b/tests/e2e/test_migrated_host_e2e.py index 38136662..a28ac6c9 100644 --- a/tests/e2e/test_migrated_host_e2e.py +++ b/tests/e2e/test_migrated_host_e2e.py @@ -9,7 +9,7 @@ uv run pytest -m e2e tests/e2e/test_migrated_host_e2e.py -v Cost: the ``e2e_video`` test bills ONE 8 s clip (12 credits at the measured cohort -rate). The ``e2e_auth`` tests spend nothing — they stop before any submit. +rate). The ``e2e_image`` tests use Flow's separate daily image quota. Live evidence for the shipped build: ``docs/LIVE_VERIFICATION_v0.67.0.md``. """ @@ -31,6 +31,7 @@ from gflow_cli.api.video import Aspect, GenerateVideoRequest, Mode, VideoResult from gflow_cli.config import reset_settings from gflow_cli.errors import FlowHostMigratedError, UiSelectorDriftError +from gflow_cli.mcp import tools as mcp_tools pytestmark = pytest.mark.e2e @@ -320,19 +321,14 @@ async def test_e2e_r2v_binds_local_references_on_the_migrated_host( @pytest.mark.asyncio -@pytest.mark.e2e_auth -async def test_e2e_image_on_a_moved_account_exits_36_not_recaptcha( +@pytest.mark.e2e_image +async def test_e2e_t2i_runs_on_a_moved_account( e2e_profile_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, install_log_capture: structlog.testing.LogCapture, ) -> None: - """$0 (#673): ``image t2i --project`` on a MOVED account must fail with the - distinct exit-36 error, not a bare RecaptchaError. The labs client mints the - reCAPTCHA token on the pool's bootstrap page before the transport runs; on a - moved account that page is the flow.google.com grid, which has no - recaptcha/enterprise.js. Measured 2026-09-05: exit 1 in 13 s before the fix. - Skips on an unmoved account — the labs page carries the script there.""" + """A moved profile uses the page-owned ``ogiZ0b`` submit and returns an image.""" project = _project_id() _set_flow_host(monkeypatch, None) req = GenerateImageRequest( @@ -343,22 +339,41 @@ async def test_e2e_image_on_a_moved_account_exits_36_not_recaptcha( assert page is not None if flow_host_kind(page.url) != "migrated": pytest.skip("profile is not on the migrated host; the labs page mints fine") - with pytest.raises(FlowHostMigratedError): - await client.generate_image(project_id=project, req=req) + image = await client.generate_image(project_id=project, req=req) - bails = [ - e for e in install_log_capture.entries if e.get("event") == "ui_driver.migrated_host_bail" - ] - # #692: the bail can legitimately come from EITHER site now. The pre-mint - # guard catches the common case; the re-check on the mint's failure path - # catches the race where Flow's client-side hop lands mid-mint (the guard is - # a point-in-time URL read, so it cannot see a navigation that has not - # happened yet). Pinning this to the pre-mint site alone would make the test - # REJECT the very scenario #692 reports. - assert bails and bails[0].get("at") in { - "mint_recaptcha_token", - "mint_recaptcha_token_after_failure", - }, bails - # The page the mint saw is the migrated origin (the grid, route "/", in the - # reporter's bundle) — not a labs page that merely lost its script. - assert flow_host_kind(str(bails[0].get("url"))) == "migrated", bails[0] + assert image.media_name and image.workflow_id + assert image.fife_url.startswith("https://flow-content.google/image/") + assert image.dimensions[0] > 0 and image.dimensions[1] > 0 + events = [str(e.get("event")) for e in install_log_capture.entries] + assert "migrated.image_settings_applied" in events + assert "ui_driver.migrated_host_bail" not in events + + +@pytest.mark.asyncio +@pytest.mark.e2e_image +async def test_e2e_mcp_i2i_runs_on_the_migrated_host( + e2e_profile_dir: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The MCP twin carries a local reference through queue decode and ``ogiZ0b``.""" + del e2e_profile_dir # fixture selects the real authenticated gflow home + project = _project_id() + _set_flow_host(monkeypatch, None) + profile = os.environ["GFLOW_CLI_E2E_PROFILE"].strip() + reference = _write_reference(tmp_path / "migrated-mcp-i2i.png") + + result = await mcp_tools.gflow_generate_image( + prompt="turn the gradient reference into a folded-paper landscape", + model="nano-pro", + aspect="16:9", + reference_images=[str(reference)], + profile=profile, + project=project, + wait=True, + ) + + assert result["status"] == "completed", result + assert result["params"]["reference_images"] == [str(reference)] + files = [Path(path) for path in result["files"]] + assert files and all(path.exists() and path.stat().st_size > 10_000 for path in files) diff --git a/tests/features/migrated_host_images.feature b/tests/features/migrated_host_images.feature new file mode 100644 index 00000000..27cf174e --- /dev/null +++ b/tests/features/migrated_host_images.feature @@ -0,0 +1,21 @@ +Feature: Image generation on flow.google.com + + Scenario: Text-to-image uses the migrated Image mode + Given an authenticated migrated profile and an existing Flow project + When I request a supported text-to-image generation + Then the page-owned image submission returns a generated image + + Scenario: Image-to-image binds a local reference before submit + Given an authenticated migrated profile, an existing project and a local image + When I request image-to-image with that local image + Then the outgoing image request contains the uploaded reference + + Scenario: An unmeasured migrated reference form is refused before billing + Given a migrated image request using a Flow media UUID + When generation is requested + Then the request is refused before the submit button is clicked + + Scenario: MCP preserves the migrated image request + Given the direct and queued gflow_generate_image surfaces + When each submits the same migrated-host request + Then model, aspect, count and local references reach the shared transport unchanged diff --git a/tests/features/test_migrated_host_images_steps.py b/tests/features/test_migrated_host_images_steps.py new file mode 100644 index 00000000..21a00705 --- /dev/null +++ b/tests/features/test_migrated_host_images_steps.py @@ -0,0 +1,157 @@ +"""BDD bindings for the migrated image-generation contract (#639).""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from pytest_bdd import given, scenarios, then, when + +from gflow_cli.api.client import FlowApiClient +from gflow_cli.api.dto import GeneratedImage +from gflow_cli.api.image import GenerateImageRequest, ImageRef, Model +from gflow_cli.api.transports.batchexecute import image_records +from gflow_cli.api.transports.migrated_composer import ( + _image_body_problem, + _unported_image_form, +) +from tests.api.transports.test_migrated_images import ( + MEDIA, + PROJECT, + REFERENCE, + URL, + WORKFLOW, + image_payload, +) + +scenarios("migrated_host_images.feature") + + +def _request(**changes: Any) -> GenerateImageRequest: + values: dict[str, Any] = {"prompt": "a blue cup"} + values.update(changes) + return GenerateImageRequest(**values) + + +@pytest.fixture +def image_world() -> dict[str, Any]: + return {} + + +@given("an authenticated migrated profile and an existing Flow project") +def _migrated_project(image_world: dict[str, Any]) -> None: + image_world["request"] = _request() + image_world["records"] = image_records("ogiZ0b", image_payload()) + + +@when("I request a supported text-to-image generation") +def _request_t2i(image_world: dict[str, Any]) -> None: + image_world["result"] = image_world["records"][0] + + +@then("the page-owned image submission returns a generated image") +def _t2i_result(image_world: dict[str, Any]) -> None: + record = image_world["result"] + assert record.media_id == MEDIA + assert record.image_url == URL + assert record.workflow_id == WORKFLOW + + +@given("an authenticated migrated profile, an existing project and a local image") +def _migrated_local_image(image_world: dict[str, Any], tmp_path: Path) -> None: + reference = tmp_path / "reference.png" + reference.write_bytes(b"png") + image_world["request"] = _request(ref_paths=(reference,)) + image_world["records"] = image_records("ogiZ0b", image_payload(reference=REFERENCE)) + + +@when("I request image-to-image with that local image") +def _request_i2i(image_world: dict[str, Any]) -> None: + # Exercise the production guard, not the fixture: `_image_body_problem` is what + # decides whether Flow was actually asked for an i2i run, and it is the only + # thing standing between "the upload was dropped" and a plausible T2I result + # reported as image-to-image. + body = f'[["ogiZ0b", "GEM_PIX_2 {REFERENCE}"]]' + image_world["result"] = image_world["records"][0] + image_world["body_problem"] = _image_body_problem(body, (REFERENCE,)) + image_world["missing_ref_problem"] = _image_body_problem(body, (REFERENCE, MEDIA)) + + +@then("the outgoing image request contains the uploaded reference") +def _i2i_result(image_world: dict[str, Any]) -> None: + assert image_world["body_problem"] is None + # …and a reference Flow did NOT carry is caught rather than passed off as i2i. + problem = image_world["missing_ref_problem"] + assert problem is not None + assert MEDIA in problem + assert image_world["result"].media_id == MEDIA + + +@given("a migrated image request using a Flow media UUID") +def _uuid_request(image_world: dict[str, Any]) -> None: + image_world["request"] = _request(refs=(ImageRef(REFERENCE),)) + + +@when("generation is requested") +def _unsupported_request(image_world: dict[str, Any]) -> None: + image_world["supported"] = _unported_image_form(image_world["request"]) is None + + +@then("the request is refused before the submit button is clicked") +def _refused_before_submit(image_world: dict[str, Any]) -> None: + assert image_world["supported"] is False + + +class _ImageTransport: + def __init__(self) -> None: + self.request: GenerateImageRequest | None = None + + def uses_page_owned_image_recaptcha(self) -> bool: + return True + + async def generate_images(self, **kwargs: Any) -> list[GeneratedImage]: + self.request = kwargs["request"] + return [ + GeneratedImage( + media_name=MEDIA, + workflow_id=WORKFLOW, + seed=1, + prompt="a blue cup", + model_name_type=Model.NARWHAL.value, + aspect_ratio="IMAGE_ASPECT_RATIO_PORTRAIT", + fife_url=URL, + dimensions=(768, 1376), + ) + ] + + +@given("the direct and queued gflow_generate_image surfaces") +def _mcp_surfaces(image_world: dict[str, Any]) -> None: + image_world["transport"] = _ImageTransport() + + +@when("each submits the same migrated-host request") +def _shared_request(image_world: dict[str, Any]) -> None: + transport = image_world["transport"] + client = FlowApiClient.__new__(FlowApiClient) + client.transport = transport + client._mint_recaptcha_token = AsyncMock(side_effect=AssertionError("page owns token")) + image_world["result"] = asyncio.run( + client._drive_images_generation( + project_id=PROJECT, + req=_request(ref_paths=(Path("reference.png"),), count=2), + recaptcha_action="imageGeneration", + ) + ) + + +@then("model, aspect, count and local references reach the shared transport unchanged") +def _shared_request_preserved(image_world: dict[str, Any]) -> None: + request = image_world["transport"].request + assert request is not None + assert request.model is Model.NARWHAL + assert request.count == 2 + assert request.ref_paths == (Path("reference.png"),) 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/tests/test_config.py b/tests/test_config.py index ffe13b87..ab284af3 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -603,8 +603,18 @@ def test_silent_once_migrated(self, replacement: str) -> None: env = {"GFLOW_CLI_GEMINI_API_KEY": "AIza-old", replacement: "set"} assert warn_if_removed_gemini_key_set(env) is False - def test_key_is_never_forwarded(self, monkeypatch: pytest.MonkeyPatch) -> None: - """The notice reports; it must not resurrect the value as a fallback.""" + def test_key_is_never_forwarded(self, clean_env: None, monkeypatch: pytest.MonkeyPatch) -> None: + """The notice reports; it must not resurrect the value as a fallback. + + ``clean_env`` is load-bearing, not decoration (#758). ``delenv`` clears the + process env var, but ``Settings`` also reads the dotenv files from + ``config._env_files()`` — so on any machine whose repo-root ``.env`` sets + ``GFLOW_CLI_LLM_API_KEY`` (the documented dev setup) pydantic-settings loaded it + straight back and this failed, while CI stayed green because CI has no ``.env``. + It failed, in other words, for exactly the developers whose environment could + exhibit the leak it guards against. ``clean_env`` fences both dotenv entries into + ``tmp_path`` — see ``TestCleanEnvHermeticity``, which documents this hazard. + """ monkeypatch.setenv("GFLOW_CLI_GEMINI_API_KEY", "AIza-old") monkeypatch.delenv("GFLOW_CLI_LLM_API_KEY", raising=False) assert Settings().llm_api_key is None diff --git a/uv.lock b/uv.lock index fda48b5c..ffc18341 100644 --- a/uv.lock +++ b/uv.lock @@ -801,7 +801,7 @@ wheels = [ [[package]] name = "gflow-cli" -version = "0.71.1" +version = "0.72.0" source = { editable = "." } dependencies = [ { name = "browser-cookie3" }, @@ -1279,15 +1279,15 @@ wheels = [ [[package]] name = "httpcore2" -version = "2.9.1" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "h11" }, { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, ] [[package]] @@ -1350,18 +1350,28 @@ wheels = [ [[package]] name = "httpx2" -version = "2.9.1" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "httpcore2" }, + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, { name = "idna" }, - { name = "truststore" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] diff --git a/website/docs/ARCHITECTURE.md b/website/docs/ARCHITECTURE.md index 6f023d90..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. @@ -436,7 +436,7 @@ We attempted three pure-HTTP transport strategies before settling on `ui_automat All three now live as standalone modules under `src/gflow_cli/api/transports/experimental/` (`evaluate_fetch.py` / `bearer.py` / `sapisidhash.py`), preserved for reference and future iteration. None survives Google's anti-bot stack for mutation/generation endpoints, so the production path is `ui_automation`. -**The migrated `flow.google.com` host (#639, v0.67.0).** Google is moving accounts off `labs.google` onto `flow.google.com`, a rewritten frontend with a different widget toolkit and a `batchexecute` wire instead of `aisandbox-pa` REST. `src/gflow_cli/api/transports/migrated_composer.py` drives that editor for text-to-video, image-to-video from a local start frame, and reference-to-video from local `--ref` files (v0.70.0) — still through the same real-Chrome Playwright session — by operating the composer UI and then *observing* the app's own `batchexecute` replies (`YhhmEf`/`eb1hJf` submit, `jwpduf` status, `as29s` result) before downloading the clip. A start frame goes in through the editor's own Upload entry (the app's `maseQ` reply names the media id — `migrated.frame_uploaded`), is bound in the Frames picker by file name (`migrated.frame_bound`), and the submit *request* is inspected for that id and an i2v model key before the run is trusted (`WireFormatError` otherwise); a modal over a fresh editor is closed first (`migrated.dialog_dismissed`). `GFLOW_CLI_FLOW_HOST` routes between the two (`auto` = migrated host for every request it can serve, `--project` required there); the rest of the command matrix keeps `ui_automation` on unmoved accounts and exits 36 on moved ones. Recon: `docs/superpowers/spikes/2026-09-05-migrated-host-wire-protocol.md`. +**The migrated `flow.google.com` host (#639, v0.67.0).** Google is moving accounts off `labs.google` onto `flow.google.com`, a rewritten frontend with a different widget toolkit and a `batchexecute` wire instead of `aisandbox-pa` REST. `src/gflow_cli/api/transports/migrated_composer.py` drives that editor for text-to-video, local-file image-to-video/reference-to-video, text-to-image, and local-file image-to-image — still through the same real-Chrome Playwright session — by operating the composer UI and then *observing* the app's own replies. Video uses `YhhmEf`/`eb1hJf`/`MZZa6b` submit plus `jwpduf`/`as29s` status/result; image uses synchronous `ogiZ0b` replies carrying signed JPEG URLs. Local files go through the editor's own `maseQ` upload and the resulting media ids are asserted in the outgoing submit body before the run is trusted (`WireFormatError` otherwise). The migrated page owns image reCAPTCHA minting; the client skips the labs token path so moved accounts no longer fail with `RecaptchaError` on the root grid. `GFLOW_CLI_FLOW_HOST` routes between the two (`auto` keeps migrated images on moved accounts and uses labs for them on unmoved accounts; `--project` is required on the migrated path); unsupported UUID/entity/instruction/Imagen-4 image forms still fail before submit. Recon: `docs/superpowers/spikes/2026-09-08-migrated-image-submit-wire.md`. **Standalone-only transports.** `bearer` and `sapisidhash` discard any caller-supplied Playwright page and launch their own browser under a fresh `ProfileLease`, so they are **standalone-only** — they cannot run inside a `FlowApiClient` that already holds the profile lease (the second acquire would self-lock with `ProfileLockedError`). Selecting either via `GFLOW_CLI_TRANSPORT` (or the Python API) while the client owns the profile now fails fast with a clear `ConfigurationError` naming the transport, rather than the opaque lock error. `evaluate_fetch` is exempt: it reuses the client's shared page and takes no second lease. The standalone-only set lives in `STANDALONE_ONLY_TRANSPORTS` (`api/transports/__init__.py`); to drive `bearer`/`sapisidhash`, run them outside an owning client. diff --git a/website/docs/AUTHENTICATION.md b/website/docs/AUTHENTICATION.md index cca93a16..92f1f0c8 100644 --- a/website/docs/AUTHENTICATION.md +++ b/website/docs/AUTHENTICATION.md @@ -143,7 +143,7 @@ for you to sign in. The CLI automatically detects success and persists the sessi ```bash gflow auth login # default profile, auto browser gflow auth login --profile work # named profile (creates if missing) -gflow auth login --browser chrome # force real Chrome (bypasses G12 block) +gflow auth login --browser chrome # force the chrome strategy (real Chrome + its profile marker) ``` Re-running this command refreshes an expired session: it reuses the existing profile dir, @@ -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/CONFIGURATION.md b/website/docs/CONFIGURATION.md index f5ab0198..dd71875a 100644 --- a/website/docs/CONFIGURATION.md +++ b/website/docs/CONFIGURATION.md @@ -361,11 +361,11 @@ GFLOW_CLI_HISTORY_PROMPTS=redacted gflow image t2i "confidential brief" **What:** Which Flow frontend gflow drives. Google is moving accounts from `labs.google/fx/tools/flow` onto `flow.google.com` one at a time ([#639](https://github.com/ffroliva/gflow-cli/issues/639)); the two are the same product on different widget toolkits and different wire protocols, so each has its own driver. **Values:** -- `auto` (default) — **`flow.google.com` is the default host for every request it can serve today** (`video t2v`, `video i2v` from a local `--initial-frame` with no end frame, and `video r2v` from local `--ref` files — all with `--project`), on moved and unmoved accounts alike — the new host serves both. A request the new host cannot serve yet keeps the labs driver on an unmoved account; a moved account has no labs to fall back to: other modes exit 36, a missing `--project` or a labs-only model exit 11. +- `auto` (default) — **`flow.google.com` is the default host for every video request it can serve today** (`video t2v`, local-file `video i2v` and `video r2v`, all with `--project`) on moved and unmoved accounts. Image requests stay on labs for an unmoved account, while a moved account uses the migrated composer for `image t2i` and local-file `image i2i`. A request the new host cannot serve keeps the labs driver on an unmoved account; a moved account has no labs fallback and unsupported forms exit 36/11 before submit. - `flow.google.com` — force the migrated composer for everything, including what it cannot serve yet (those requests then exit 36/11 instead of falling back). - `labs.google` — never use the migrated composer; a moved account fails with exit 36 (kill switch). **Default:** `auto` -**Scope today:** the migrated composer covers `gflow video t2v`, `gflow video i2v --initial-frame ` and `gflow video r2v --ref `, each with `--project ` (settings via the option groups, model picker, local files uploaded through the editor's own Upload entry — bound in the Frames picker by file name for i2v, attached as `@` mentions in the prompt for r2v — submit, status observed on the page's own `batchexecute` replies, download from the signed CDN URL). An end frame, a frame given by UUID or `@Name`, references given by `@Name` or `--reference-entity`, image, characters, scenes, extend, instructions and tools are not ported yet and exit 36 on a moved account. MCP inherits the setting from the server/daemon environment, not per call. +**Scope today:** the migrated composer covers `gflow video t2v`, local-file `video i2v` / `r2v`, `gflow image t2i`, and local-file `gflow image i2i`. Images support Nano Banana 2 / Pro, the four aspect ratios enumerated on that host (16:9, 4:3, 1:1, 9:16 — `3:4` was not present and is refused before submit) and count 1–4; the page owns the `ogiZ0b` reCAPTCHA + submit and the response already contains completed signed image URLs. An end frame, UUID/name references, character entities, Agent instructions, Imagen 4, scenes, extend, instructions and tools are not ported yet and fail before submit on a moved account. MCP uses the same image service and queue payload, and inherits this setting from the server/daemon environment rather than per call. ### `GFLOW_CLI_PREFER_CLASSIC` *(deprecated — use `GFLOW_CLI_UI_MODE=classic`)* diff --git a/website/docs/DEBUGGING.md b/website/docs/DEBUGGING.md index 5bc50f6d..10755e5c 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 | @@ -317,8 +317,9 @@ remediations. The most-hit categories: ([selector-cascades](#selector-cascades)) - **Session expiry on a long-running worker** → use `health_check()` + recreate on `BrowserSessionClosedError` -- **reCAPTCHA score too low** → must use real Chrome - (`--browser chrome`); never Playwright's bundled Chromium +- **reCAPTCHA score too low** (generation, *not* sign-in) → must use real Chrome + (`--browser chrome`). Sign-in is a separate question: the 2026-09-08 spike measured + `navigator.webdriver`, not the binary, as what Google's sign-in rejects. ## See also diff --git a/website/docs/KNOWN_ISSUES.md b/website/docs/KNOWN_ISSUES.md index 21f5bbb0..d5433ba6 100644 --- a/website/docs/KNOWN_ISSUES.md +++ b/website/docs/KNOWN_ISSUES.md @@ -14,13 +14,13 @@ Living list of behaviour that's broken, surprising, or limited by design — alo ## Open -### Flow is migrating to `flow.google.com`; gflow drives the migrated frontend for t2v, i2v from a local start frame, and r2v from local reference files (rest of the matrix pending) +### Flow is migrating to `flow.google.com`; generation coverage is partial but includes images -- **Status:** Open (partially resolved) · **Severity:** High for everything except text-to-video, local-file image-to-video and local-file reference-to-video · **Affected:** on accounts the rollout has reached, `gflow video t2v`, `gflow video i2v --initial-frame ` and `gflow video r2v --ref ` now run on the migrated host (with `--project`); an end frame, a frame by UUID or `@Name`, references by `@Name` or `--reference-entity`, `image`, scenes, extend, instructions and tools are not ported yet and still exit 36. **`character` is NOT in that list any more** — `character create` was verified working on the migrated host in v0.70.0 and `character list` was re-verified there on 2026-09-07. This line claimed otherwise for a day, which is the dangerous direction for a stale doc to be stale in: a migrated user reading it concludes a working feature is impossible. Of the remainder, only the i2v-by-UUID case rests on a positive observation of absence (the Frames picker's tiles carry no media id); `scenes`, `extend`, `instructions` and `tools` have never had a probe run against them at all, so read them as *unported by gflow*, never as *impossible on the host* +- **Status:** Open (partially resolved) · **Severity:** High for unported forms · **Affected:** on accounts the rollout has reached, `gflow video t2v`, local-file `video i2v` / `r2v`, `gflow image t2i`, and local-file `gflow image i2i` now run on the migrated host. Image mode supports Nano Banana 2 / Pro, the four aspect ratios its radiogroup was enumerated with (16:9, 4:3, 1:1, 9:16), and count 1–4; `3:4` had no radio in that enumeration and is refused before submit rather than reported as selector drift. Image refs by UUID, `@Name` / `--reference-entity`, Agent instructions, Imagen 4, video end frames, video refs by UUID/name, scenes, extend, instructions and tools are not ported yet and fail before submit. **`character` is NOT in that list any more** — `character create` and `character list` work on the migrated host. Of the remainder, only the i2v-by-UUID case rests on a positive observation of absence (the Frames picker tiles carry no media id); `scenes`, `extend`, `instructions` and `tools` remain *unported by gflow*, never proven impossible on the host. - **Tracked:** [#639](https://github.com/ffroliva/gflow-cli/issues/639) · Reported 2026-09-02 against 0.59.0, 0.62.1, 0.63.0 and 0.65.0 - **Confirmed live 2026-09-03 on a second, independent account** (`ffroliva`) — see [LIVE_VERIFICATION_v0.66.0](https://github.com/ffroliva/gflow-cli/blob/main/docs/LIVE_VERIFICATION_v0.66.0.md). A read-only probe of the migrated origin measured `i_total: 0`, reproducing the reporter's central measurement. - **`--reference-entity` was refused on `r2v` but not on `t2v`** ([#716](https://github.com/ffroliva/gflow-cli/issues/716), fixed in v0.71.0): the "not ported, exit 36" refusal above sat inside the r2v branch of the routing gate, so a `t2v` request carrying a character entity returned from that gate without its entities ever being inspected — and nothing downstream attaches one on this host. The generation was **submitted and billed** with the entity silently dropped, returning a plausible clip of the wrong person. The check is now mode-independent and ahead of every early return. If you ran `gflow video t2v @Name …` or `--reference-entity` on a moved account before this fix, the identity in those clips was never bound. -- **The migrated composer DOES have an image mode** ([#692](https://github.com/ffroliva/gflow-cli/issues/692)): a code comment claimed, as "measured, not assumed", that it has none. Falsified 2026-09-07 — its settings overlay carries a `mode` radiogroup of `[imageImage, videocamVideo]`, present and hit-testable. Nothing was clicked on that axis and nothing was submitted, so this does **not** establish that `image` works on a moved account; it establishes only that the claim it cannot is unfounded. Read the remaining exit 36 as *gflow does not drive this yet*, never as *the host cannot do it*; see [the spike](https://github.com/ffroliva/gflow-cli/blob/main/docs/superpowers/spikes/2026-09-07-migrated-composer-has-an-image-mode.md). +- **The migrated composer image path is now driven** ([#692](https://github.com/ffroliva/gflow-cli/issues/692)): the first probe established a hit-testable Image mode; the 2026-09-08 follow-up captured real T2I and local-file I2I submissions on `ogiZ0b`, including page-owned reCAPTCHA, upload ids, response records and signed JPEG downloads. See [the submit-wire spike](https://github.com/ffroliva/gflow-cli/blob/main/docs/superpowers/spikes/2026-09-08-migrated-image-submit-wire.md). - **Image commands on a moved account** ([#673](https://github.com/ffroliva/gflow-cli/issues/673), fixed in v0.69.0): through v0.68.0 `image t2i` / `i2i` (and `upscale`, `extend`) died with exit 1 `RecaptchaError` within seconds, before any submit, instead of the exit 36 above, because the labs client minted the reCAPTCHA token on the `flow.google.com` project grid before any migration guard ran. The guard now runs at the mint. If you still see a `RecaptchaError` on a moved account right after `auth login`, that is the labs logged-out landing page from the [#644](https://github.com/ffroliva/gflow-cli/issues/644) cookie harvest, not this. Google is moving Flow off Labs onto its own origin. On a migrated page load, @@ -48,7 +48,7 @@ This is **not** selector rot, not [#493](https://github.com/ffroliva/gflow-cli/i and not the agentic cohort — the agentic indicators are absent too. It is a different origin serving different markup. -**What works now — text-to-video on the migrated host.** `gflow video t2v … --project ` +**What works now — generation on the migrated host.** `gflow video t2v … --project ` drives the migrated editor directly (settings through its option groups, prompt, submit, then it observes the app's own `batchexecute` status replies and downloads the clip). Two real clips were generated this way on 2026-09-05 — spike @@ -56,10 +56,11 @@ the clip). Two real clips were generated this way on 2026-09-05 — spike (`GFLOW_CLI_FLOW_HOST=auto`): flow.google.com is the **default** host for that command on every account — moved or not; `flow.google.com` forces it for everything, and `labs.google` switches the migrated composer off. Limits today: `--project` is required (project creation from the -migrated editor is not ported), and only `t2v`, `i2v` from a local `--initial-frame` (no end frame, +migrated editor is not ported), and `t2v`, `i2v` from a local `--initial-frame` (no end frame, no UUID/`@Name` frame — the migrated Frames picker exposes no media id in its DOM, so a frame is found by file name after gflow uploads it through the editor), and `r2v` from local `--ref` files -(see the next paragraph) — everything else still exits 36. +(see the next paragraph), plus `image t2i` and local-file `image i2i` — unsupported +forms still exit 36. **`r2v` from local `--ref` files also runs there (2026-09-06).** Each file is uploaded through the same editor toolbar path i2v uses — so the app's own `maseQ` reply names the @@ -73,6 +74,12 @@ References by `@Name` and character entities stay on labs, for the same reason a UUID does: the picker exposes no media id to anchor on. Capture: [2026-09-05-migrated-r2v-attach-surface](https://github.com/ffroliva/gflow-cli/blob/main/docs/superpowers/spikes/2026-09-05-migrated-r2v-attach-surface.md). +**Images also run there (2026-09-08).** The driver selects Image mode, Nano Banana 2 +or Pro, any supported aspect and count 1–4, then observes the page's own synchronous +`ogiZ0b` reply. Local I2I files use the existing `maseQ` upload and mention path; every +uploaded id must appear in the submit body before the result is trusted. UUID/entity +references, Agent instructions, and Imagen 4 remain pre-submit refusals on this host. + **Models on the migrated host.** Its picker is driven for every tier the account's menu actually renders, `veo-lite-lp` included — matched by the `[Lower Priority]` tag alone, exactly as on labs.google. That entry was first captured on 2026-09-05 @@ -100,8 +107,9 @@ which Playwright updates in the same tick as the hand-off navigation — and fai with the distinct, non-retryable exit 36 instead of the misleading `UiSelectorDriftError` (exit 23, "file a selector bug"). `_check_logged_in` also accepts the migrated host, so a migrated load is no longer misread as a -logged-out session. Text-to-video is driven (above); image, i2v/r2v, characters, -scenes, extend, instructions, tools and project creation are the remaining work +logged-out session. The generation forms listed above and characters are driven; +scenes, extend, instructions, tools, project creation, and the named reference/model +variants are the remaining work tracked here — no retry helps for those until each is ported. > **v0.66.1's fast-fail did not fire in the field, and v0.66.2 is the correction.** @@ -1515,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 @@ -1525,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/MCP.md b/website/docs/MCP.md index 7950d411..9c388c00 100644 --- a/website/docs/MCP.md +++ b/website/docs/MCP.md @@ -83,6 +83,7 @@ The server registers three protocol surfaces: ### Tools (Executable actions) * `gflow_generate_image(prompt, model, aspect, count, seed, reference_images, reference_entities, reference_entity_names, tools, profile, project, project_name, instructions, ui_mode, output, wait)`: Triggers text-to-image / image-to-image (Imagen / Nano Banana). `instructions` is an optional list of ephemeral agent-instruction strings (agentic cohort only). `reference_images` switches to i2i and accepts **either a local file path or a generated image's Flow media UUID**. A UUID reference is attached by **selecting the already-existing asset in Flow's reference picker — no duplicate copy is uploaded** (locating the tile by the media id in its thumbnail URL, and searching the recorded display name to surface it when needed); gflow falls back to uploading the asset's on-disk local file only when it can't be located in place (e.g. it lives in a different project's picker). `project` generates into an existing Flow project id (mirrors CLI `--project`) — pass the reference's project to keep it selectable in place. `ui_mode` selects the Flow UI arm (`auto`/`classic`/`agentic`, mirroring CLI `--ui-mode`, matched case-insensitively). Since [#595](https://github.com/ffroliva/gflow-cli/issues/595) `auto` means "no arm was asked for" and **resolves to `classic`** — the arm that can satisfy an image request — so an account in Flow's agentic cohort aborts pre-submit (exit-28 equivalent envelope, zero credits) instead of failing mid-run with selector drift or video bytes; the agentic arm is bound only when named. Passing `instructions` forces `agentic` automatically, so `ui_mode="classic"` + `instructions` is a hard conflict rather than a silent drop. An unknown value returns a 400 problem-details envelope. See [CONFIGURATION § GFLOW_CLI_UI_MODE](CONFIGURATION.md#gflow_cli_ui_mode). + On migrated `flow.google.com` accounts, T2I and local-file I2I are supported with Nano Banana 2 / Pro, the four measured aspects (16:9, 4:3, 1:1, 9:16) and count 1–4; the page owns reCAPTCHA and the `ogiZ0b` submit. UUID/entity references, instructions and Imagen 4 remain pre-submit refusals there. The queued (`wait=false`) and blocking (`wait=true`) calls use the same typed payload and worker path. * `gflow_generate_video(prompt, mode, aspect, initial_frame, end_frame, reference_images, reference_entities, reference_entity_names, model, duration, count, tools, profile, project, project_name, ui_mode, output, wait)`: Triggers vertical or landscape video generation (Veo). `mode` is `t2v`/`i2v`/`r2v`; `model` (`veo_lite`/`veo_fast`/`veo_quality`/`omni_flash`, aliases accepted), `duration` (seconds — 4/6/8 for Veo 3.1 and 4/6/8/10 for omni_flash; 10 is omni_flash-only). **Whether Flow renders a duration control at all is account/cohort-dependent**: on an account that renders none, the job is accepted here and fails in the worker pre-submit (exit 23 equivalent on the labs driver, exit 11 equivalent on the migrated `flow.google.com` host — now the default t2v route, #650 — no credits spent either way) rather than being rejected up front — see [KNOWN_ISSUES](https://github.com/ffroliva/gflow-cli/blob/main/KNOWN_ISSUES.md) (#451/#288/#630), and `count` mirror the CLI `gflow video` flags — an omitted `model` lets the transport apply its i2v veo-lite default (issue #125), and every model — `omni_flash` included — accepts i2v with a start frame and with an end frame (wire-verified 2026-09-02, issue #626); `i2v` requires `initial_frame`, `r2v` requires `reference_images` **or** `reference_entities`; `project` generates into an existing Flow project id (mirrors CLI `--project`); on an account Google has moved to `flow.google.com` (`GFLOW_CLI_FLOW_HOST`, read from the server/daemon environment, not per call — see [CONFIGURATION § GFLOW_CLI_FLOW_HOST](CONFIGURATION.md#gflow_cli_flow_host)) `project` is **required** and omitting it returns the exit-11-equivalent envelope; there the ported modes are text-to-video; image-to-video with a **local** `initial_frame` and no `end_frame` (the file is uploaded through the editor and bound on the Start chip by file name — it stays in the Flow project like any upload); and reference-to-video with local `reference_images` (each file is uploaded the same way and attached as an `@` mention in the prompt; `duration` there accepts only `8` and is pinned when omitted — Flow offers r2v at its base tier alone, and at 4 or 6 it silently drops the references and bills a text-to-video clip, so any other value returns the exit-11-equivalent envelope). A Flow media UUID as `initial_frame`, an `end_frame`, and `r2v` by `reference_entity_names` or `reference_entities` return the exit-36-equivalent envelope. `initial_frame`, `end_frame`, and `reference_images` each accept **either a local file path or the Flow image UUID of a generated asset** — pass a generated image's id straight in to chain image→video, and gflow attaches it for you. Since v0.58.0 (#529) the CLI and MCP surfaces are unified for i2v frames: the UUID keeps its identity and is enriched with the catalog's recorded display name plus an integrity-verified local fallback, so the transport prefers **selecting the exact asset in the project's media picker** (no duplicate upload) and re-uploads the recorded local file only when the tile is unreachable — and only if its byte count/SHA-256 still match. A UUID that isn't in your local asset catalog is rejected up front with a clear "Reference Not Found" error; a catalogued asset with neither a display name nor a verified local file gives a "Reference Not Usable" error (re-generate it or pass a local path). `r2v` UUID refs are resolved to the recorded local file for upload. `ui_mode` selects the Flow UI arm (#299 PR-A, mirroring CLI `--ui-mode`) and **applies to every mode of this tool, including `r2v`** — unlike the CLI, where `video r2v`/`chain` have no flag and follow the env-only path. Video generation has **only a classic driver**, so `auto` ≡ `classic`: both verify the classic editor pre-submit and abort before spending credits if it is unreachable. `ui_mode="agentic"` is rejected with a 400 problem-details envelope, because no agentic video driver exists yet. Values are matched case-insensitively. (MCP tools return envelopes, never process exit codes — the CLI equivalents of these aborts are exit 28 and exit 2 respectively.) See [CONFIGURATION § GFLOW_CLI_UI_MODE](CONFIGURATION.md#gflow_cli_ui_mode). > **Attaching a saved character (the identity axis).** Three routes reach Flow's diff --git a/website/docs/USAGE.md b/website/docs/USAGE.md index 9f673365..19fa9c27 100644 --- a/website/docs/USAGE.md +++ b/website/docs/USAGE.md @@ -183,6 +183,14 @@ Notes: Generate 1–4 images from one text prompt, or run a shell-friendly batch of 1–50 prompts through one Flow session/project. +> **Migrated `flow.google.com` accounts (#639):** T2I is supported with Nano Banana 2 +> (`nano2`) and Nano Banana Pro (`nano-pro`), the four aspects measured there (`16:9`, +> `4:3`, `1:1`, `9:16`), and count 1–4. **`--project ` is required** — a fresh project +> can only be created through the labs gallery, so without it the run exits 11. The +> migrated page owns its reCAPTCHA + `ogiZ0b` submit. Imagen 4, Agent instructions, +> character/entity references, `3:4` and `image batch` remain unavailable on that host and +> fail before submit. + ```text gflow image t2i PROMPT [PROMPT ...] [OPTIONS] gflow image t2i --prompts-file FILE [OPTIONS] @@ -322,6 +330,12 @@ A 4-image run with `--out ./logos/` produces: Generate 1–4 images by blending a text prompt with one or more reference images. Same flag set as `t2i`, plus a required `--ref` (repeatable). +> **Migrated `flow.google.com` accounts (#639):** local-file `--ref` values are supported +> and each uploaded media id is verified in the outgoing `ogiZ0b` body. **`--project ` +> is required here** (exit 11 without it). UUID refs, `@Name` / `--reference-entity`, Agent +> instructions, Imagen 4 and the `3:4` aspect remain unavailable on that host and fail +> before submit rather than silently degrading to T2I. + ```text gflow image i2i PROMPT --ref PATH_OR_UUID [--ref ...] [OPTIONS] @@ -590,7 +604,8 @@ Options: > `--initial-frame` and no `--end-frame` runs there too (see [`gflow video i2v`](#gflow-video-i2v)), > as does `r2v` from local `--ref` files (see [`gflow video r2v`](#gflow-video-r2v)); > an end frame, a frame given by UUID or `@Name`, references given by `@Name` or -> `--reference-entity`, and everything else still exit 36 on a moved account. `flow.google.com` forces the migrated composer, +> `--reference-entity`. `image t2i` and local-file `image i2i` also run on a moved +> account; UUID/entity/instruction/Imagen-4 image forms still exit 36. `flow.google.com` forces the migrated composer, > `labs.google` switches it off — see [CONFIGURATION § GFLOW_CLI_FLOW_HOST](CONFIGURATION.md#gflow_cli_flow_host). ```bash @@ -1748,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 | @@ -1770,7 +1785,7 @@ shell scripts can branch on the failure mode without parsing stderr. | `33` | — (`gflow doctor` verdict) | Doctor found warn/fail findings — a successful diagnosis, not an error class | Review the report; see [`gflow doctor`](#gflow-doctor) | | `34` | `SyncPartialError` | `gflow data sync` failed on some projects but succeeded on others — completed writes stay committed | Retryable: re-run the same command; it resumes with what is still nameless (see [`gflow data sync`](#gflow-data-sync)) | | `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` (the origin Google is migrating accounts onto) and the request could not be routed to the migrated composer: `GFLOW_CLI_FLOW_HOST=labs.google` switched it off, or the request type is not ported to that host yet (today `video t2v`; `video i2v` from a local `--initial-frame` with no end frame; and `video r2v` from local `--ref` files — all with `--project`). Not selector drift (23) | **Not retryable.** The handoff is a per-account setting applied on every load. Use `gflow video t2v --project `, `gflow video i2v --initial-frame --project ` or `gflow video r2v --ref --project ` on that host, or the REST surface (`gflow project list`, `gflow data …`); follow #639 for the rest of the matrix | +| `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 | | `130`| SIGINT | User-interrupted (Ctrl-C) | — | @@ -1810,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: