Skip to content

fix(tools): reuse supervisor session for browser_cdp target_id (salvage #32950) - #71743

Open
chrisyoung2005 wants to merge 3 commits into
NousResearch:mainfrom
chrisyoung2005:fix/browser-cdp-supervisor-target-reuse
Open

fix(tools): reuse supervisor session for browser_cdp target_id (salvage #32950)#71743
chrisyoung2005 wants to merge 3 commits into
NousResearch:mainfrom
chrisyoung2005:fix/browser-cdp-supervisor-target-reuse

Conversation

@chrisyoung2005

Copy link
Copy Markdown

What / Why

browser_cdp opens a fresh CDP WebSocket per call. On Browserless-style backends that spawn a private browser per connection, a target id returned by one call is invalid by the next — the documented multi-step Target.getTargetstarget_id workflow always fails (#32685).

This routes target-scoped calls through the live CDPSupervisor session when the target is already attached (the top-level page, an OOPIF frame, or an auto-attached child target), falling back to the legacy stateless attach otherwise (plain Chrome shares targets across connections, so statelessness keeps working there).

Salvage of #32950 (credit @LeonSGP43) — that branch has been conflicting since May with the review feedback unaddressed. Rebased onto current main with all three review asks applied:

  • Private-page guard: the target route runs after the shared _browser_cdp_private_guard, so it cannot become the sibling bypass fixed for frame_id in 47764f1. Regression test included (test_target_id_route_blocked_when_current_page_is_private).
  • Public target discovery: SupervisorSnapshot now carries page_target_id (surfaced in browser_snapshot output via to_dict()), and the E2E test discovers the target through it — no private-attribute reads.
  • Redaction: the supervisor-backed payload runs _redact_cdp_output like the stateless payload. Regression test included (test_target_id_route_via_supervisor_redacts_secret_result).
  • website/docs/user-guide/features/browser.md no longer claims every non-frame_id call is stateless.

Fixes #32685.

How to test

scripts/run_tests.sh tests/tools/test_browser_cdp_tool.py tests/tools/test_browser_supervisor.py

51 passed, 0 failed (the supervisor file needs a local Chrome; its two new E2E tests use only the public snapshot path). New tests fail before the fix (AttributeError on the missing snapshot field / missing route).

Production verification: self-hosted Browserless v2 (Chrome 149), Fedora 43, rootless podman — Target.getTargetsRuntime.evaluate(target_id=...) chains that previously landed in a fresh empty browser now resolve against the supervisor session (details in the #32685 / #32950 comment threads).

Platforms tested

  • Linux (Fedora 43), Python 3.11, Chrome-for-Testing 151 headless (E2E) + in-process mock CDP server (unit)
  • scripts/check-windows-footguns.py clean on all touched files

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for carrying forward the private-page guard, public snapshot field, and result-redaction fixes from the earlier review.

Problems

  • The reported Target.getTargetstarget_id workflow is still stateless. The new route is conditional on an already-present target ID at tools/browser_cdp_tool.py:582; Target.getTargets has none and falls through to a fresh _cdp_call at :593-596. That does not make the two calls share a Browserless private browser.
  • page_target_id identifies the supervisor's independently connected browser (tools/browser_supervisor.py:659-690, :770-786), not necessarily the agent-browser daemon's navigated page. The #32685 reproduction documents this split on Browserless, so mixed navigation/CDP usage can silently inspect the wrong page.

Suggested changes

  • Route target discovery through the supervisor connection too, then add a per-WebSocket-isolation test for the full discovery/evaluate chain.
  • Define and test the mixed daemon/supervisor behavior (verified shared target, fallback, or explicit error) before documenting supervisor state as canonical.

Automated hermes-sweeper review.

Comment thread tools/browser_cdp_tool.py Outdated
# routing cannot become the sibling bypass for either (the frame_id
# route follows the same boundary). Falls through to the stateless
# attach when no live supervisor tracks this target.
if target_id:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Target.getTargets has no target_id, so it bypasses this branch and still reaches the new per-call WebSocket at :593-596. On Browserless that means its returned target belongs to a different private browser; route discovery through the supervisor as well and cover the full discovery-to-evaluate sequence.

@teknium1 teknium1 added sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform area/sessions Session lifecycle, resume, persistence, history labels Jul 30, 2026
@chrisyoung2005
chrisyoung2005 force-pushed the fix/browser-cdp-supervisor-target-reuse branch 2 times, most recently from 0409ed7 to c7869e1 Compare July 30, 2026 16:17
@chrisyoung2005

Copy link
Copy Markdown
Author

Both points addressed in c7869e1f4 (rebased onto current main first).

Discovery now rides the supervisor connection. Correct — Target.getTargets had no target_id, skipped the routing branch, and still opened a fresh per-call WebSocket, so on Browserless the discovery result described a different private browser than the one a follow-up target_id call executed in. _browser_cdp_target_via_supervisor now accepts target_id=None and dispatches browser-level commands (no sessionId) on the supervisor's WebSocket — _cdp already treats session_id as optional, so this is the same connection either way — and the call site routes every post-guard call through it when a live supervisor exists. The requested per-WebSocket-isolation test is a real-Chrome integration test covering the full chain: the stateless _cdp_call is patched to fail the test if anything reaches it, then Target.getTargets (asserted to include the supervisor's own attached page — proof both calls observe one browser) followed by Runtime.evaluate on the discovered target_id, both succeeding over the supervisor connection. Unit tests cover the browser-level dispatch shape (no sessionId on the wire) and the no-supervisor stateless fallback.

Mixed daemon/supervisor behavior — defined and documented. The semantics this PR now commits to, of your three options, are verified-shared-connection with stateless fallback:

  • Live supervisor → all browser_cdp traffic (discovery + target-scoped) shares its connection, so results are mutually coherent by construction; responses carry "connection": "supervisor" so callers can see which regime they're in.
  • No supervisor, or a target_id the supervisor has no session for → legacy stateless connection, unchanged (the plain-Chrome path, where every connection sees the shared browser).

On the second half — page_target_id describing the supervisor's independently-connected browser rather than the agent-browser daemon's page: that's exact, and it's the supervisor re-attach root cause we split into #74216 (wrong-page dialog bridge + snapshot enrichment; consolidates the #32685 repro, the plain-Chrome multi-tab confirmation on #71744, and the re-attach direction — a contributor is sequencing that work after #71745). Fixing it belongs at the supervisor attach layer, not per-call in browser_cdp; until it lands, browser.md now states explicitly that the supervisor's browser is not necessarily the daemon's page and that within browser_cdp supervisor state is the canonical view, with a pointer to the tracking issue. Happy to re-scope if you'd rather this PR waits on #74216, but the eval-side companion (#71745) took the same boundary per the triage recommendation.

@chrisyoung2005
chrisyoung2005 force-pushed the fix/browser-cdp-supervisor-target-reuse branch 2 times, most recently from 41db2b6 to dcdcdbc Compare August 3, 2026 10:26
@GottZ

GottZ commented Aug 3, 2026

Copy link
Copy Markdown

This was generated by AI during triage.

Summary

Nine PRs span two distinct causes: #32950 and #71743 address #32685 by reusing a persistent CDP supervisor connection, while #4143, #6623, #27304, #30893, #33246, #34131, and #34497 address execute_code terminal exposure, environment leakage, approval gating, or lost thread context. The execute_code cluster is already consolidated in merged #34497; the remaining open issue is the Browserless per-WebSocket lifecycle handled by the two browser PRs.

Related pull requests

Duplicates

#4143 and #6623 duplicate the terminal-revocation approach. #27304, #30893, #33246, and #34131 are superseded precursors consolidated in merged #34497; #32950 and #71743 share the browser supervisor-reuse mechanism, with #71743 as the expanded salvage.

Suggested consolidation

Keep #71743 open with a salvage path: preserve its same-supervisor discovery-to-evaluate chain, guard, redaction, public snapshot field, fallback tests, and attribution, then obtain contributor re-review of the still-visible keep_open verdict. Despite the keep_open review and recorded best-fix status on #32950, close #32950 as duplicate of #71743 because its diff retains the three cited blockers while #71743 carries its core fix and addresses them; no further action is needed for the execute_code PRs because merged best-fix #34497 is their consolidated reference implementation.

Complex graph

flowchart LR
    classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
    classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
    classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
    classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
    classDef best stroke-width:3px,stroke:#b45309
    classDef target stroke-width:3px,stroke:#4338ca
    I32685(["issue #32685 (open)"])
    subgraph Dup32950 ["PRs duplicating each other"]
        P32950["PR #32950 (open)"]
        P71743["PR #71743 (open)"]
    end
    P71743 -->|best fix| I32685
    class I32685 open
    class P32950 open
    class P71743 open
    class P32950 best
    class P71743 best
    class P71743 target
    click I32685 "https://github.com/NousResearch/hermes-agent/issues/32685"
    click P32950 "https://github.com/NousResearch/hermes-agent/pull/32950"
    click P71743 "https://github.com/NousResearch/hermes-agent/pull/71743"
Loading

Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label).

Cross-PR triage: Reviewed 9 pull requests and 5 issues in this complex. Each diff was read against this issue; Assessment working set: 239 kB of PR diffs, 39 kB of issue/PR text, 23 kB of discussion (25 comments), 38 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.

@chrisyoung2005
chrisyoung2005 force-pushed the fix/browser-cdp-supervisor-target-reuse branch 7 times, most recently from 0104e8d to a666828 Compare August 10, 2026 10:26
@chrisyoung2005
chrisyoung2005 force-pushed the fix/browser-cdp-supervisor-target-reuse branch 4 times, most recently from fb1a245 to 10d66a3 Compare August 14, 2026 10:26
@chrisyoung2005
chrisyoung2005 force-pushed the fix/browser-cdp-supervisor-target-reuse branch from 10d66a3 to b25ff46 Compare August 15, 2026 10:26
@Enough1122

Copy link
Copy Markdown
Contributor

AI code review — automated review for reference, author can ignore or act on any point.

fix(tools): reuse supervisor session for browser_cdp target_id (salvage #32950)

  1. _browser_cdp_target_via_supervisor blocks the calling thread on fut.result(timeout=timeout + 2) while the CDP call runs on the supervisor's loop. If the loop is blocked or the command hangs, the tool blocks up to timeout + 2s, and a hung _cdp past its own timeout surfaces as a generic tool_error. Using the same safe_timeout value as the stateless path (and not an arbitrary +2 margin) would make the two paths' latency contracts symmetric.
  2. resolve_target_session reads _page_target_id, _frames, and _child_sessions under _state_lock — good. But the routing helper reads supervisor._loop and _cdp without the lock; mid-reconnect reads could see a torn state (caught by the try/except and falling back, so safe, but a lock-guarded read would be deterministic).
  3. Discovery shape (target_id=None) now rides the supervisor connection when one is live — a behavior change for browser-level methods on plain Chrome that previously went stateless. The docs update covers it; just confirm no caller relied on stateless isolation (different profile/context) for browser-level calls.
  4. _page_target_id is reset in _run on reconnect, and snapshot()/to_dict() expose it — good. One nit: resolve_target_session returns None when _page_target_id matches but _page_session_id is empty (partial attach), which correctly falls back to stateless; consider a brief comment documenting that partial-attach case.

@chrisyoung2005
chrisyoung2005 force-pushed the fix/browser-cdp-supervisor-target-reuse branch from b25ff46 to 8b8c643 Compare August 16, 2026 10:26
@alt-glitch alt-glitch removed sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades area/sessions Session lifecycle, resume, persistence, history sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data labels Aug 16, 2026
@chrisyoung2005
chrisyoung2005 force-pushed the fix/browser-cdp-supervisor-target-reuse branch 2 times, most recently from bcec862 to 7af85c1 Compare August 18, 2026 10:26
@chrisyoung2005

Copy link
Copy Markdown
Author

Thanks — 1 and 4 documented in 6411c34f5; 2 and 3 answered below. No behavior change; unit suite 17 green (test_browser_supervisor.py remains marker-gated as before).

1 (timeout + 2 vs safe_timeout). The two paths' latency contracts are already symmetric at the value level — both receive the same clamped safe_timeout from the tool entrypoint. The +2 is only the outer future guard: the inner supervisor._cdp(...) enforces timeout itself, so the margin exists to let the inner, more specific CDP timeout error surface instead of a generic fut.result timeout, and it only fires if the loop itself is wedged. Now stated in a comment at both call sites.

2 (unlocked _loop/_cdp reads) — declining the lock. As you note, a torn read is caught and falls back to the stateless path, so the worst case is one call taking the slow path. Acquiring _state_lock on the tool hot path would buy determinism nobody observes at the cost of contention with the supervisor's event thread.

3 (plain-Chrome browser-level behavior change). Confirmed no caller depends on stateless isolation: browser_cdp has no internal callers (only its own registry handler), and on plain Chrome targets are shared across connections, so browser-level discovery returns the same data either way. The only observable difference is on per-connection-browser backends — where riding the supervisor's connection is precisely the fix (#32685). The docs note covers it.

4 (partial attach) — comment added at the resolve_target_session early return: _page_target_id known but _page_session_id not yet ⇒ deliberate None, so callers take the stateless fallback rather than racing a half-attached session.

NousResearch#32950)

browser_cdp opens a fresh CDP WebSocket per call. On Browserless-style
backends that spawn a private browser per connection, a target id from
one call is invalid by the next — multi-step Target.getTargets ->
target_id workflows always fail (NousResearch#32685).

Route target-scoped calls through the live CDPSupervisor session when
the target is already attached (top-level page, OOPIF frame, or
auto-attached child), falling back to the stateless attach otherwise.

Salvages NousResearch#32950 (credit: @LeonSGP43) rebased onto main with the review
feedback applied:

- the target route runs after the shared private-page guard, so it
  cannot become the sibling bypass fixed for frame_id in 47764f1
  (regression test included)
- public target discovery: SupervisorSnapshot now carries
  page_target_id (surfaced in browser_snapshot output via to_dict);
  the E2E test uses it instead of private supervisor attributes
- the supervisor-backed payload redacts its result via
  _redact_cdp_output like the stateless payload (regression test
  included)
- browser.md no longer claims every non-frame_id call is stateless
…ection too

Review follow-up: Target.getTargets has no target_id, so discovery
bypassed the supervisor branch and opened a fresh per-call WebSocket —
on Browserless-style backends that enumerates a different private
browser than the one target_id-routed calls execute in, leaving the
reported discovery -> target_id workflow stateless.

_browser_cdp_target_via_supervisor now accepts target_id=None and
dispatches browser-level commands (no sessionId) on the supervisor's
WebSocket; the call site routes every post-guard call through it when a
live supervisor exists. Mixed behavior is now defined and documented:
supervisor present -> ALL browser_cdp traffic shares its connection
(responses carry connection: "supervisor"); absent, or target unknown
to the supervisor -> legacy stateless fallback (plain-Chrome path
unchanged). The daemon-vs-supervisor browser split itself is tracked
in issue 74216.

New tests: browser-level discovery routes via the supervisor (unit,
stateless path fails the test if reached), no-supervisor discovery
falls back stateless (unit), and a real-Chrome integration test proving
the full Target.getTargets -> Runtime.evaluate chain rides one
WebSocket. Both supervisor-routing tests fail with the source reverted.
…lback

Review follow-up (comment-only): the outer fut.result(timeout + 2) margin
exists so the inner _cdp call's own timeout error surfaces first — both
routing paths already share the entrypoint's clamped safe_timeout. And
resolve_target_session deliberately returns None on a partial attach
(page target known, session id not yet) so callers take the stateless
fallback instead of racing a half-attached session. No behavior change;
unit suite 17 green, test_browser_supervisor.py marker-gated as before.
@chrisyoung2005
chrisyoung2005 force-pushed the fix/browser-cdp-supervisor-target-reuse branch from 6411c34 to 77f6040 Compare August 19, 2026 10:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state tool/browser Browser automation (CDP, Playwright) type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: browser_cdp opens stateless CDP connections, breaking Browserless/BaaS targets between tool calls

5 participants