Skip to content

chore(release): v0.73.0 - #779

Merged
ffroliva merged 6 commits into
mainfrom
chore/release-v0.73.0
Sep 10, 2026
Merged

chore(release): v0.73.0#779
ffroliva merged 6 commits into
mainfrom
chore/release-v0.73.0

Conversation

@ffroliva

Copy link
Copy Markdown
Owner

Release PR for v0.73.0. The signed tag v0.73.0 is already pushed and the release workflow ran from it; this PR brings the bump commit and the full integration history onto main.

Merge with --merge, never --squash — the branch was cut from develop and carries the whole batch of unreleased integration commits.

What ships

Four error paths stopped lying about what went wrong. None of these change what the tool can do; they change what it says when it cannot, which is the difference between a user filing a useful bug and re-running blind.

Issue Change
#776 A click that never lands reports the actionability condition that failed, instead of a bare TimeoutError at exit 1
#756 A known Flow landing page is named, not blamed on the selector
#777 Google's auth URLs are stripped of state / code_challenge / client_id before reaching an error message
#763 / #764 The post-migration account chooser auto-selects instead of stalling (thanks @stgmt)
#774 The Bug Lane is the documented route from symptom to fix, with an offline guard

Gates

  • Nine quality gates green — 4230 passed, 92% coverage, pyright src 0 errors
  • check_release_artifacts.py green
  • Wheel + sdist build clean at 0.73.0, no duplicate archive entries
  • docs/LIVE_VERIFICATION_v0.73.0.md — 7 arms: 4 verified live, 1 in a real browser against a synthetic page, 3 recorded as not verified with named reasons

Two things the doc-review gate caught

A release blocker. ## [0.72.0] still carried content mismerged from PR #764 after the v0.72.0 tag — git show v0.72.0:CHANGELOG.md proves it never shipped there, and the same feature was about to appear under two different release headings. The section is now byte-exact with the tag and the content sits under [0.73.0] where it belongs. This is the changelog-backmerge-silent-mismerge failure mode, caught by the gate rather than by a reader.

A false claim I introduced. The changelog described both migrated-composer submit sites as "credit-spending". Only the video one is; the image one costs daily quota. Corrected.

Also added from that review: a KNOWN_ISSUES entry for #773 item 3 (an --account mismatch raises exit 38 but leaves the wrong-account profile in place — on a billed product, the wrong account paying), and a docs/SECURITY.md note recording that a typed error's detail is not hashed the way an unhandled one is.

stgmt and others added 6 commits September 9, 2026 21:30
…ser (#764)

When Google hands the session to flow.google.com and redirects to an account
chooser, FlowApiClient now auto-selects the profile's recorded account from
.gflow_account instead of stalling into an opaque RecaptchaError/exit 1.

- Exact, case-insensitive row match on both tiers; anchored so the chooser's
  "Remove <email>" / "Sign out of <email>" rows can never be clicked.
- A chooser is identified positively (chooser path or account rows), so an
  ordinary expired session still classifies as AuthExpiredError (exit 3).
- Unselectable cases raise a typed, non-retryable FlowAccountChooserError
  (exit 38) naming the URL the session actually landed on.
- gflow auth login --account <email> asserts login authenticated as the
  required account, failing closed on mismatch.
- .gflow_account is treated as untrusted input, fixing an untyped failure in
  the selector and a crash in gflow auth list on a damaged file.

Closes #763. Follow-up hardening tracked in #773.
…to fix (#774)

Formalises the chain a bug travels here — spike -> systematic-debugging ->
BDD -> TDD -> fix -> e2e — canonically in skills/issue-resolve/SKILL.md, cited
from AGENTS.md, skills/spike, skills/scenario, docs/E2E_TESTING.md and
docs/INDEX.md. Written once; nothing restates it, because a duplicated
checklist drifts.

Gated by surface, not severity: steps 0-2 are skippable when the cause is
already proven and the blast radius is one line, but a skip is a claim and
has to be said out loud. Steps 3-5 have no gate.

Removes a contradiction that no gate could see. issue-resolve step 3 read
"the test is the closest browser-free proxy" while AGENTS.md's Iron Law read
"if no e2e test covers the change, write one" and listed "covered by unit
tests" among the excuses that are NOT blockers. Disjoint files, clean merge,
and an agent following either one was compliant with the other's opposite.
"Browser-free" is no longer a blocker; only a named external blocker is.

Wires BDD to e2e with no new machinery: pytest-bdd converts Gherkin tags into
pytest markers (verified on 8.1 — plugin.py:137), so a Feature tagged
@e2e @e2e_auth is filtered by the existing addopts and selected by the
existing -m <tier>. Feature files stay in tests/features/; the step module
lives in tests/e2e/ so it inherits that suite's profile-gating fixtures.

tests/features/test_e2e_binding_guard.py enforces it offline, in hosted CI,
without a browser — orphan @e2e Gherkin, a missing cost tier, and the inverse
hazard of a feature bound from tests/e2e/ but left untagged, which escapes
addopts and makes hosted CI try to drive Chrome. It carries its own fire-test,
so green means "no orphans" rather than "never looked". Proving these tests
PASS stays the nightly canary's job, on a machine with a warm profile.
…ector (#775)

* fix(transports): name a known Flow landing instead of blaming the selector

Closes #756. Also fixes the 2026-09-10 RED nightly canary (#559), which is the
same defect one URL apart.

flow_host_kind() classifies the ORIGIN. /about, /project/<id> and
/fx/api/auth/signin?error=Callback all share one, so when a readiness wait timed
out it had nothing left to blame but its own anchor — sending the operator to
"check for a newer release, then file a bug" over a session state no release
changes. Reported at three sites and special-cased three times before (#721
credits, #749 agent mode, FlowAppError's own crash page), so it is fixed once,
in the shared place, rather than a fourth time.

- flow_landing_kind() joins flow_host_kind() in api/transports/_common.py and
  names a known non-app landing ("signin" / "public" / None).
  raise_for_known_landing() converts the diagnosis at the raise site.
- Consulted ONLY inside an already-failed branch. Not ahead of the probe, which
  would delete the DOM evidence that corrects a wrong absence claim (#739 shipped
  one that way), and not as a bounded wait after goto, which reads page.url
  before Flow's client-side redirect lands (#639).
- flow.google.com/about instead of the project -> FlowAppError (exit 31), naming
  the landing and the project it did not open, and deliberately not why: #756
  measured the redirect and not its cause, and auth status reports the session
  verified while it happens.
- labs .../auth/signin?error=Callback instead of the gallery -> AuthExpiredError
  (exit 3). This is the exact page behind the RED canary.
- auth/internal_chromium.py drops its private _NEXTAUTH_ROUTE_PREFIX and reuses
  the shared classifier. The knowledge existed there since #767; no transport
  could reach it.

Retryability was measured, not assumed. Routing /about to exit 31 would have
flipped it from non-retryable to retryable purely as a side effect. The spike
(scripts/dev/spike_about_redirect_stability.py, $0, ci-probe) came back 0/5 —
the redirect stopped reproducing between 2026-09-08 and today — which is equally
consistent with "transient" and with "a session state changed", and so settles
nothing. The reading was pre-registered in the script before the run. Therefore
GFlowError gains a per-instance `retryable` override (same class-default /
instance-override shape as remediation_hint) and the /about raise site passes
retryable=False to PRESERVE its previous exit-23 answer, not to claim a retry
fails. is_retryable() pins the override with isinstance(..., bool): a MagicMock
answers every attribute with a truthy child, so a truthiness test would report
every mocked error as retryable with nothing noticing.

Tests: tests/features/landing_state_diagnosis.feature bound from
tests/e2e/test_landing_state_diagnosis_bdd.py — the first BDD-bound e2e. Three
scenarios against a real browser over route interception, zero credits: both
landings, plus an A/B control proving genuine drift still reports as drift. RED
first (the canary's exact RuntimeError, reproduced deterministically), then
green. Plus classifier unit tests and five retryability tests.

tests/test_marker_registry.py now resolves a BDD module's cost tier through its
Gherkin tags. It read Python source only, so it saw no pytest.mark and failed a
correctly-tiered file; copying the tier into a pytestmark would have satisfied it
and then drifted from the Feature.

* fix(review): apply the branch-council findings

12-dimension council on 63fb5bb: 3 GREEN (correctness, auth, BDD), 9 YELLOW,
0 RED, ~19 must-fix. Two dimensions found defects in the workflow hardening
itself rather than in the fix.

Security (D3): the landing URL is stripped to scheme+host+path before it reaches
either the message or the log. The NextAuth family the "signin" arm matches
includes /fx/api/auth/callback/google?state=...&code=..., and because
AuthExpiredError is capture-exempt that message is the ONLY artifact — so it is
exactly what a user pastes into an issue. redact_error_detail was already
imported in this module and used 300 lines below; the new raise skipped it.

Truthfulness (D2, D15, D9): the message said "its sign-in page" for a family that
also contains the callback and /session. docs/MCP.md still told agents
FlowAppError is unconditionally retryable and that RETRYABLE_ERRORS "is the whole"
list; docs/USAGE.md's exit-3 row still described only the 401/403 trigger; and a
docstring claimed the helper "never adds a failure", which is false at
migrated_composer.ensure_editor, whose except branch has a recovery path.

Reach (D6): a THIRD site. _enter_editor(project_id=...) has no readiness gate, so
a landing page reaches _locate_prompt_box and raises a bare RuntimeError, which
observability.py hashes because it is not a GFlowError — the operator saw
"Unexpected error" with even the URL destroyed. Worse than the report #756 is
about, and the guard was not consulted there.

Scope (D14, D1): retryable moves off GFlowError onto FlowAppError, the one class
that needs it — is_retryable reads it by getattr, so a base field bought no typing
and no test-double visibility, only a field on every error in the project. The
FlowApiError pass-through it required is deleted. flow_landing_kind's "public" arm
is gated to the migrated host, the only one where /about was measured.

Instrument (D13): the spike re-implemented the predicate it was measuring
(endswith("/about") vs the production classifier), which would score /about?hl=en
as a non-reproduction — the #743 shape its own docstring cites. It now calls
flow_landing_kind. except BaseException became except Exception: the former
swallowed Ctrl-C, recorded it as a non-/about sample and advanced the loop, so an
interrupted run could print "DOES NOT REPRODUCE" assembled from interrupts.

Guards (D4, D12): the binding guard could pass on an empty scan — every check is
assert-not-<list>, so a mis-resolved _FEATURES_DIR made all of them vacuous while
the fire-test still passed. It now asserts it scanned something. The "one feature,
one binding module" rule stated in docs was the one rule nothing enforced; it is
enforced now. Feature-name matching is anchored on the path separator. The
"signin" branch had zero offline coverage (only the e2e reached it, and addopts
excludes that), so the 4194-pass run never executed it.

Memory (D5): docs/superpowers/memory/ui-selector-drift-error-exit-23.md keeps a
carve-out ledger for exceptions to exit 23 — exits 36, 37 and 38 each recorded
when introduced. Carve-out 4 is now recorded, with the transferable lesson the
branch is actually about: flow_host_kind classifies the ORIGIN, not the page.

Hardening defects the council found in the first commit (D9): the lane said
"steps 3-5 have no gate" beside "step 3 = that Gherkin RED", which is
unsatisfiable when step 2 is skipped — the gate is red-before-green, never
Gherkin-before-green. And the continuation table routed 1 -> 1b -> 3, orphaning
Phase 2 (predict) while the routing table still mandates it for exactly this kind
of change.

Declined, with reasons: excluding /about from incident capture (FlowAppError also
covers the React crash page, where the DOM helps, and a class cannot exclude one
shape), and moving the guard after _capture_debug_screenshot (D3 showed that
placement removes an existing screenshot of a signed-in page; D9's answer, taken,
is to document the capture change in DEBUGGING.md rather than revert it).

Verified: 4197 passed, 92% coverage; e2e 3/3 in a real browser at $0; ruff,
format, doc-links, website mirror, council-memory and hygiene gates all green.

* fix(transports): recognise Google's auth host as a known landing

Found by live-verifying the previous commit instead of trusting it. Same
profile, same test, real Google, $0:

  BEFORE  RuntimeError: Could not find 'New project' CTA on Flow gallery.
          URL: https://accounts.google.com/v3/signin/accountchooser
               ?client_id=...&code_challenge=...&state=PKOA6qjxDh...

  AFTER   FlowAccountChooserError: Google's account chooser is displayed
          (https://accounts.google.com/v3/signin/accountchooser) instead of
          the Flow gallery - the session needs a person to pick an account.
          Not selector drift.

`flow_landing_kind` returned None for accounts.google.com by design, and a unit
test asserted it, on the reasoning "the chooser has its own handler". That is
true at BOOTSTRAP - `client._handle_account_chooser` - and false for a hop that
happens after it, which is what profile `denon82` did on 2026-09-10: it left the
`?error=Callback` state the 02:00 canary reported and moved to Google's chooser,
where `_enter_editor` swept eighteen selectors for a "+ New project" CTA on
Google's sign-in page and blamed the anchor. So the previous commit's claim to
fix the RED canary was false for the state the profile is actually in.

The classifier now answers for Google's auth host too: "chooser" for a
`/v3/signin/accountchooser` path -> FlowAccountChooserError (38, the class #763
and #764 already established for this), "signin" for its other sign-in surfaces
-> AuthExpiredError (3). The bot-rejection hop (`/v3/signin/rejected`) keeps
returning None; it has its own error and must never read as a missing account.
Path-tested rather than importing GOOGLE_REJECTED_BROWSER_ROUTE, because
`_common` -> `auth` is a real import cycle - which is why `internal_chromium`
imports this module deferred.

`_is_safe_to_probe_session` is unaffected, verified: its `flow_host_kind(url) is
None` short-circuit returns False for accounts.google.com before this function
is consulted, so the cookie-rotation gate the council proved equivalent over 393
cases still behaves identically.

D3's redaction finding is no longer hypothetical - that live message carried the
OAuth `state`, `code_challenge` and `client_id`, and this is the artifact users
paste into issues. The stripping added in 7e29be4 removes them.

Not in scope, and recorded rather than smuggled in: the mid-run chooser now
DIAGNOSES but does not RECOVER. #764 taught the bootstrap path to autoselect the
recorded account; that lives on FlowApiClient and is not reachable from a
transport helper. Teaching this hop to autoselect would rescue the run instead of
naming the failure.

Verified: 4202 passed, 92% coverage; the live A/B above; ruff, format,
doc-links, website mirror and hygiene gates green.

* docs(changelog): fold the duplicate Added heading the rebase created

Rebasing onto develop after #774 squash-merged stacked this branch's Added
section above the one that arrived with the hardening, leaving two under
[Unreleased]. Keep a Changelog wants one per type; content is unchanged.
…777)

* fix(security): strip OAuth query params from chooser error messages

Found while live-verifying v0.73.0's release gate, in code this release did not
touch. `client._handle_account_chooser` interpolates `page.url` verbatim at three
raise sites, and Google's auth URLs carry `state`, `code_challenge`, `client_id`
and challenge tokens. That text is the artifact users are asked to paste into a
GitHub issue.

Measured on the real path, not theorised. `gflow image t2i --profile <name>`
against a profile Google had put behind a password challenge:

  BEFORE  exit 38, 5 secret matches -
          accounts.google.com/v3/signin/challenge/pwd?TL=ACv9tzFkh8ZJ...
          plus the OAuth state and client_id
  AFTER   exit 38, 0 secret matches, same landing still named

`safe_page_url()` joins `flow_host_kind` / `flow_landing_kind` in `_common.py` and
keeps scheme+host+path. All FOUR raise sites route through it - the three in
client.py and `raise_if_known_landing`, which had been stripping inline. One
helper rather than four copies: the council flagged this class of leak in the new
code (D3) and it applied identically to the raise site next door, which is the
tell that it belonged in a shared function.

The landing stays in the message. Knowing WHERE the session stopped is the whole
value of the diagnosis; only the credentials are gone.

Regression test asserts both halves - the URL is still named AND none of
client_id / code_challenge / state= / TL= survive - so a future edit that
re-inlines the URL goes red instead of quietly leaking again.

Verified: ruff, format, pyright 0 errors; 8 chooser tests, 86 in the affected
modules; the live A/B above.

* test: cover the new lines SonarCloud flagged (new_coverage 70% < 80%)

Zero issues; the gate failed purely on coverage of lines this PR added or changed.
Two real gaps, both legitimate:

- `safe_page_url` edge branches. I had verified None / "" / non-URL / "https://[bad"
  by running them in a terminal and reading the output. That is evidence for one
  person once; it is not a test, so nothing re-checks it and the lines were exactly
  as untested as the gate said. Every branch is pinned now.
- `client.py`'s "no account is recorded in this profile" raise had NO test at all —
  every chooser test writes an ACCOUNT_FILE first, which is how the line stayed
  uncovered while the two raise sites beside it were exercised. It is a real path:
  a profile authenticated before .gflow_account existed, or one whose file was
  removed, has nothing to auto-select with.

No coverage exclusions widened, no assertions weakened — the skill forbids both, and
either would turn the gate green while leaving the lines untested.
* spike(776): measure whether the migrated settings trigger is ever visible-but-unclickable

0/3 on the overlay hypothesis — body{pointer-events:none} never occurred and the
click landed in 65-130ms every run, so this settles nothing about #776 and is
recorded as unmeasured, not as transience.

Three things it did measure:
- _dismiss_dialog runs before Angular mounts the composer, 3/3 (trigger appears
  1479-3143ms after domcontentloaded, the check fires at ~0ms). The miss is
  structural, not flaky.
- a healthy click on this control costs 65-130ms against a 5000ms budget, so
  #776's expiry is a control that never became actionable.
- the locale-settle error the reporter asked about reproduced on all 3 runs while
  the click still landed - it is #643, and it is not sufficient to cause #776.

Rung 1 also found #593's pointer-events measurement was taken on labs.google;
migrated_composer.py:744 calling the migrated dialog '#593's twin' is asserted,
not measured.

Refs #776

* fix(migrated): say what was true when a click never lands (#776)

video r2v on flow.google.com reached migrated.editor_ready and died 5.039s later
as a bare Playwright TimeoutError - exit 1, no locator, no cause, no MP4. By
elimination that is _open_pane's trigger.click(timeout=5000): the wait_for(visible)
one line above IS guarded and would have raised exit 23, so the control was visible
and the click expired.

#752 finding #7 predicted this at this exact function before #776 was filed. Its
count()->visibility half was fixed; the click half was not, leaving a comment that
describes the failure the next line went on producing.

It reads, it does not diagnose. Two causes were live and neither could be measured:
Flow's announcement overlay (#593, measured on labs.google, never on this host -
0/3 in today's spike) and a mid-run agent-mode flip. A guard built on either would
answer confidently and be wrong half the time (#770 is the precedent). So on a
timeout the driver reads Playwright's four actionability conditions back and reports
the ones that fired; when every reading is healthy it says so, which eliminates
three and leaves 'stable' rather than inventing a fourth.

- costs nothing when healthy: the read runs only in the except branch, the rule
  raise_if_known_landing already states
- MCP gains more than the CLI: a non-GFlowError on the queued path shipped
  'detail: sha256:...'; the typed error routes it to the Problem Details branch
- four sites with a named reason each, not all nineteen
- the occluder report is a closed allowlist (tag + <=3 framework class tokens);
  typing the error moves the text from hashed telemetry to a message printed raw,
  and a signed-in Flow page carries the account email on exactly those elements
- retryable unchanged: preserved, not measured

Closes #776

* test+docs(776): cover every reading offline, and fix the docstring the council caught

Council D9 found a Blocker I had put on my own predict list and then dropped:
UiSelectorDriftError's docstring still said 'finds no matching element', which the
new raise site makes false - it now also fires when the selector DOES match and the
element will not take the interaction.

- errors.py: the class covers two shapes, and the detail has to say which
- KNOWN_ISSUES: the lookup table documented 6 of the 8 messages the code can emit;
  the missing two were 'not rendered' and 'answers no hit test'
- E2E_TESTING: a second worked example, chosen because it fails DIFFERENTLY from the
  first - a client-side redirect vs Playwright's actionability gate
- tests: one offline case per reading. CI's coverage run excludes -m e2e, so a branch
  proven only in the browser reads as dead code to Sonar's new-code gate - the exact
  way PR #777 went red at 70%
- tests/worker: the MCP twin A/B - a bare TimeoutError reaches an agent as
  'detail: sha256:...', the typed one as problem details with exit 23

Refs #776

* refactor(776): cut the prose the council measured, and merge one fact told twice

Council D14 measured 64.5% of this change's added lines as non-code prose, against
the 35% issue #759 already named as a problem in this same file. Fair hit - three of
the passages were the CHANGELOG's content retold at the call site, and one was an
exact duplicate of the comment 80 lines above it.

- _click's docstring: the #593/#752/#770 archaeology is the CHANGELOG's job
- the redaction comment above the redact_sensitive_text return restated the
  module-level one verbatim
- the _open_pane call site told the #752 story a third time
- SUBMIT_BUTTON's three-line comment for a one-line constant

D1 also found a real over-report: the JS only hit-tests when the box has a nonzero
width and height, so an unrendered element ALWAYS comes back hit_testable=false and
occluder=null. Two independent ifs then said "it is not rendered" and "it answers no
hit test" about one fact. Now one chain for "can a pointer reach it", ordered most
specific first, with enabled-ness and a page-wide block kept as the separate axes
they are - chaining those would have hidden a disabled control behind whatever
covered it.

Two tests added for the invariants the real JS enforces, which the earlier
parametrized cases violated by holding hit_testable=True on an unrendered element.

Refs #776

* docs(776): correct a truncation claim that was wrong, and flag a fake that no longer works

Council D4 caught a false statement in my own docstring: it said the queued MCP path
raw-slices detail to 500 chars "while the CLI path does not". It does. redact_sensitive_text
IS redact_error_detail, which truncates at the raise site, so every surface sees the same
cap. Locator-first ordering is still right, for the simpler reason that the cut happens at
the source - fixed in both the method docstring and the test that repeated it.

Also:
- tests/worker: _fail_r2v_with enqueues a t2v task. The name described the reporter's
  command, not the fixture.
- tests/api/transports/test_migrated_composer.py defines a local PlaywrightTimeoutError
  that shadows the real class for that whole 2000-line file. Since #776 the driver
  catches the REAL one, so a future click-timeout test written with that file's dominant
  fake would silently exercise nothing and say so nowhere. Documented at the definition,
  pointing at the file that does it correctly.

Refs #776
@coderabbitai

coderabbitai Bot commented Sep 10, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: f663243d-7201-4653-939e-3bdf5fb775e2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ffroliva
ffroliva merged commit c232f3a into main Sep 10, 2026
17 checks passed
@ffroliva
ffroliva deleted the chore/release-v0.73.0 branch September 10, 2026 18:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants