fix(tools): gate browser_console eval fast-path on supervisor watching the daemon's page - #71745
Conversation
| @@ -2972,6 +2978,7 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str: | |||
| # Failed opens and blocked redirects must not retarget follow-up clicks | |||
| # or snapshots to a newly-created but irrelevant session. | |||
| _last_active_session_key[effective_task_id] = nav_session_key | |||
| _last_navigated_urls[nav_session_key] = str(final_url or "") | |||
There was a problem hiding this comment.
tl;dr: Daemon url changes not only happens on browser_navigate().
Consider this scenario (cdp, browserless):
0. There's only on tab with url https://www.example.com; both cdp & daemon bind to this tab;
- With
browser_click, a new tab is opened - daemon binds to this newly created tab, while cdp don't. - invoke
browser_console(document.title)result in ahttps:///www.example.com.
Suggestion?
quick fix: add _last_navigated_urls[nav_session_key] = str(final_url or "") to browser_click() also (though I'm not sure if a url can be obtained in that scenario...)
There was a problem hiding this comment.
Good catch — confirmed, and fixed in 07be57692. Your scenario is exactly the blind spot: after a click opens a new tab, the daemon rebinds while the supervisor keeps showing the old page, whose URL still matches the stale record, so the URL comparison alone keeps trusting the fast path.
On the suggested quick fix: browser_click's result carries no URL, so recording one there would mean probing the daemon — a subprocess call per click, and clicks are far more frequent than evals. So the commit inverts it:
browser_clickandbrowser_press("Enter")mark the session as possibly diverged (zero extra calls). Marked sessions skip the fast path and take the always-correct agent-browser subprocess path — worst case is the subprocess spawn we'd have paid anyway pre-fast-path, never a wrong-tab result.- The mark clears on the next authoritative URL:
browser_navigate, orbrowser_back— whose result does report the landing URL, so it now re-records_last_navigated_urlstoo. - Other keys and failed clicks keep the fast path.
7 new tests in test_browser_eval_supervisor_path.py::TestPageDivergenceAfterInteractions, including your new-tab scenario verbatim (test_click_then_eval_falls_through_to_subprocess); 5 fail pre-fix. Full browser suite green (555 passed).
There was a problem hiding this comment.
Here, a small problem: consider this scenario:
- only a
new tabin your browser; browser_nagivate('https://www.example.com')browser_snapshot(): likley_supervisor(3121) is not None, which will bring the wrong frame tree info (e.g.chrome-untrusted:new-tab-page/one-google-bar) toresponse.
Solution?
Quick-fix: Use _supervisor_page_matches_daemon() & set _supervisor to None if not matches. (but this will permanently disable the merging logic, since nobody fix the wrong supervisor in SUPERVISOR_REGISTRY, the _supervisor_page_matches_daemon() will never returns True once split-brain happens).
Fix permanently: ... frankly speaking I have no idea.
- remove supervisor once and for all: too aggresive!
- find solution to synchonize the url between supervisor & daemon (really hard work...)
There was a problem hiding this comment.
Agreed this is real — it's the snapshot-enrichment half of the same supervisor split-brain — but I'd argue it belongs with the supervisor re-attach work rather than this PR, which is scoped to the eval fast-path (where a wrong answer is returned as the result; the snapshot case pollutes auxiliary frame info merged onto an otherwise-correct daemon snapshot).
Your analysis of the quick fix matches why I didn't gate the merge here: nothing ever re-attaches the supervisor to the right page today, so if not _supervisor_page_matches_daemon(...): _supervisor = None degrades "sometimes wrong frame tree" into "permanently no dialog/frame merging after the first divergence" — dialogs are the supervisor's most load-bearing duty, and a pending-dialog report is correct browser-wide even when the frame tree is stale.
The durable fix is synchronizing the supervisor with the daemon's target, which is the #32685 / #32950 / #71743 family — on #32950 I proposed a split-brain guard (daemon targetId ∉ supervisor's Target.getTargets → warn/re-attach) that would fix eval, snapshot, and dialogs at the root. If maintainers prefer, I'm happy to file a follow-up issue for the snapshot-merge case specifically so it's tracked — this PR does leave _supervisor_page_matches_daemon + the new divergence mark importable by browser_snapshot, so a scoped follow-up (e.g. merge dialogs but drop frame_tree on mismatch) would be small.
|
Thanks for tracing the split-brain failure and adding focused coverage. The current-main premise is real: Problems
Suggested changes
Automated hermes-sweeper review. |
07be576 to
70015f3
Compare
|
Both points addressed in Page identity, not URL equality. Agreed — a URL match can't establish identity when two page targets show the same URL. The constraint worth stating plainly: the daemon side cannot participate in an identity handshake, because agent-browser is an external CLI that never exposes its target id in any tool result. So the commit takes the strongest identity available without daemon cooperation, plus your suggested fallback for everything else:
Residual case, disclosed rather than hidden: if a different target was already showing the same URL at the moment of navigation and the supervisor was attached to it, the binding is wrong and indistinguishable without daemon cooperation. It's narrower than the pre-commit exposure (which trusted any same-URL state at eval time, plus all unproven states) and self-heals on the next navigation; closing it fully needs the daemon to expose its target id, which is out of this repo's reach. Snapshot frame-tree merge. Already split out and tracked: #74216 consolidates that path (wrong-page |
f4a09ba to
006cd9d
Compare
3747d48 to
d2e29b5
Compare
fix(tools): gate browser_console eval fast-path on supervisor watching the daemon's page
|
29086f3 to
430cd9b
Compare
|
Thanks — 3 and 4 acted on in 1 (key-namespace mismatch). The two namespaces meet by construction: 2 (snapshot on the navigate hot path). 3 (Space) — fixed. Space activates a focused button/link, so 4 (ordering) — documented. |
07eb3b3 to
e18ba01
Compare
e18ba01 to
30e0ca2
Compare
|
Status: the triage recommendation (merge, via #71744) stands; all reviewer scenarios are regression tests (frame-identity gate |
9073141 to
ed3b50d
Compare
9b2f075 to
21c08a5
Compare
…g the daemon's page The CDPSupervisor opens its own CDP websocket and attaches to the first page target that connection sees. On Browserless-style backends every websocket gets a private browser, so the supervisor's connection can never see the page the agent-browser daemon navigated — the _browser_eval fast path 'succeeds' against the supervisor's own about:blank and returns empty/wrong results, while the subprocess fallback that would answer correctly never runs. On plain Chrome the same split occurs when the daemon drives a different tab than the one the supervisor attached. Production symptom (self-hosted Browserless v2): after browser_navigate loaded a product page, browser_console(expression="document.title") returned "" and price selectors returned null while browser_snapshot showed the fully-loaded page — misdiagnosed as target-site bot detection for ten days. Fix: record the final (post-redirect) URL of each successful browser_navigate per session key, and gate the supervisor fast path on the supervisor's live top-frame URL matching it (fragment-insensitive). Zero extra subprocess calls: both sides of the comparison are already in memory. No recorded navigation preserves legacy fast-path behaviour for /browser connect-then-eval flows; any mismatch or unreadable supervisor state falls through to the agent-browser subprocess path, which always evaluates in the daemon's session. Regression tests cover the split-brain fall-through, matching-page and fragment-only fast paths, the no-navigation legacy path, and snapshot failure; all fail before the fix.
Review feedback on the URL-match gate: a click can open a new tab — the daemon rebinds to it while the supervisor keeps showing the old page, whose URL still matches the recorded last-navigated URL, so the URL comparison alone wrongly keeps the fast path and evaluates in the old tab. Click results carry no URL, and probing the daemon would cost a subprocess call per click, so instead: - browser_click and browser_press Enter mark the session as possibly-diverged; marked sessions skip the fast path and take the always-correct agent-browser subprocess path - browser_navigate and browser_back (whose result reports the landing URL) record the daemon's authoritative URL and clear the mark - other keys and failed clicks keep the fast path
… fast-path Review follow-up: a URL match cannot establish page identity — two CDP page targets can show the same URL, and the supervisor would still evaluate in its own stored session. The gate now requires the supervisor's top frame to be the exact frame bound at the last authoritative navigation: * _record_daemon_url captures the supervisor's top-frame id at the one moment daemon and supervisor provably coincide — when the supervisor's top-frame URL equals the fresh post-navigation URL. Any doubt (no supervisor, URL mismatch as with a Browserless private browser stuck on about:blank, unreadable state) clears the binding. * _supervisor_page_matches_daemon requires bound identity + stable frame id + URL match; no binding means the fast path is never trusted — including the former no-recorded-navigation legacy trust, which is exactly the "fall back whenever identity cannot be established" case. The daemon side cannot participate directly: agent-browser is an external CLI that does not expose its target id, so identity is inferred at navigation time and required stable thereafter. New tests: same-URL/different-target (review scenario), matching URL without bound identity, navigate binds/doesn't-bind identity, plus the inverted no-recorded-navigation case. 11 fail with the source reverted.
…rged
Review follow-up: Space activates a focused button/link in browsers, so a
browser_press("Space") can navigate exactly like Enter — mark the session
possibly-diverged for it too (both "Space" and " " spellings). Also
documents why _record_daemon_url clears the divergence mark before the
identity bind (a failed bind pops the frame binding, which alone stands
the fast path down). 2 new tests, both fail pre-fix; suite 26 green.
21c08a5 to
786e363
Compare
|
Re-homed onto current PR body rewritten in the Symptom → change → behavior shape. If cherry-picking/salvaging is the faster path for this one, please go ahead — no objection to authorship going either way. |
Fixes #71744. Sibling of #71743 / #32685 (same root cause, different tool path; diffs are independent).
Summary
browser_console(expression=…)'s supervisor fast path evaluates in whatever page the supervisor's own CDP connection attached — not necessarily the page the agent-browser daemon is driving. On Browserless-style backends (private browser per CDP websocket) it never is: evals "succeed" against the supervisor'sabout:blankand return""/null, and the always-correct subprocess fallback never runs because the fast path reportedok. On plain Chrome the same split occurs when the daemon drives a different tab than the one the supervisor attached, or after a click opens a new tab.tools/browser_tool.pyrecords, per session key, the final (post-redirect) URL of each successfulbrowser_navigate(refreshed bybrowser_back's reported landing URL) and, at that moment, binds the supervisor's top-frame id iff the supervisor is provably showing that same URL._eval_supervisor_fast_pathnow runs only when the supervisor's live top frame is still that bound frame and its URL matches the recorded one (fragment-insensitive). Click / Enter / Space mark the session "possibly diverged" (a click can open a new tab whose URL still matches the stale record) until the next authoritative URL.cleanup_browserdrops the bookkeeping. Zero extra subprocess calls — both sides of every comparison are already in memory.Evidence
TestSupervisorDaemonPageSplit+TestEvalFastPathDivergence: split-brain fall-through, matching page keeps fast path, fragment-only difference keeps it, same-URL-different-target falls back (reviewer scenario, verbatim), matching URL without bound identity falls back, no recorded navigation falls back, unreadable supervisor state falls through, click/Enter/Space stand the fast path down, navigate/back re-arm it, cleanup clears state. 11/24 fail before the fix (the split-brain case returns the wrong-browser result). Also green:test_browser_console.py,test_browser_console_ssrf.py,test_browser_eval_ssrf.py.Production verification (self-hosted Browserless v2, Fedora 43):
browser_navigate→ idle →browser_console(expression="document.title")returns the real title where the ungated fast path returned""for ten days.Residual (deliberately out of scope, tracked in #74216): the supervisor's dialog bridge and snapshot frame-tree enrichment have the same wrong-page problem; this PR only fixes the eval path.
Branch state
On current
main— re-homed onto the #102117 module split on 2026-09-04 (the facade still owns navigate/click/back/press/eval;cleanup_browserhook lives inbrowser_tool_lifecycle.py; tests patchbrowser_tool_session._run_browser_command/browser_tool_eval_policy._eval_ssrf_guard_active). Two source files, one test file. Happy for this to be cherry-picked / salvaged if that's the faster path.Platforms tested
Linux (Fedora 43), Python 3.11; unit tests are mock-based (no browser needed).
scripts/check-windows-footguns.pyclean on touched files.