#2193

fix(native): resume auto-attached targets before the first renderer command

Openk997wants to merge intofrom

fix(native): resume auto-attached targets before the first renderer command

Problem

agent-browser click on a target="_blank" link stalls for 30 seconds and then fails, tearing the whole browser down with it, whenever network controls are active (--allowed-domains or proxy credentials):

$ export AGENT_BROWSER_SOCKET_DIR=/tmp/ab-pr-verify
$ export AGENT_BROWSER_ALLOWED_DOMAINS="bilibili.com,www.bilibili.com,search.bilibili.com,space.bilibili.com"
$ agent-browser --session pr1 open "https://search.bilibili.com/all?keyword=%E7%BB%BF%E5%AF%BC%E5%B8%88%E5%8E%9F%E8%B0%85%E4%BD%A0%E4%BA%86"
$ agent-browser --session pr1 snapshot -i          # UP-card link ref, e.g. @e121
$ time agent-browser --session pr1 click @e121 --json
{"success":false,"data":null,"error":"Failed to install browser network controls: CDP command timed out: Page.enable"}
real 0m30.148s

The failure is specific to renderer-initiated opener popups. Same-tab navigation clicks (91ms), direct open (577ms) and click --new-tab (73ms) are unaffected, and a two-line local localhost popup fails identically to bilibili, so page weight is irrelevant.

Root cause

With network controls active the daemon installs browser-level Target.setAutoAttach({autoAttach: true, waitForDebuggerOnStart: true, flatten: true}) (enable_browser_auto_attach_pub, browser.rs:889). A renderer-initiated target="_blank" popup therefore attaches paused, before its navigation has started: url: "", no execution context (Target.attachedToTarget reports waitingForDebugger: true).

The attach handler (apply_drained_events → drained.attached_page_sessions, actions.rs:1445) then ran the shared setup in this order:

  1. prepare_domains (browser.rs:910): Page.enable is the first renderer-bound command.
  2. install_network_controls_for_session → install_domain_filter (now split by this change): Fetch.enable + Page.addScriptToEvaluateOnNewDocument + Runtime.evaluate.
  3. Runtime.runIfWaitingForDebugger last (browser.rs:1081).

A frozen renderer never answers Page.enable, so the command rides the hardcoded 30s timeout in cdp/client.rs (client.rs:523) and close_after_network_control_failure closes the browser. The same enable-before-resume order was shared by install_active_network_controls (actions.rs:4021), the concurrent Target.attachedToTarget handler (actions.rs:988, via prepare_auto_attached_session), and the iframe attach path.

Raw-CDP confirmation against Chrome for Testing 155: on the real-click popup Page.enable gets no answer at all, while Runtime.runIfWaitingForDebugger answers in 4ms and every later command (including the filter's Runtime.evaluate) then succeeds. A popup created with Target.createTarget answers Page.enable in 1ms because it is never paused this way, which is why only the opener-popup path breaks.

Related: #1651 (same install path, Page.enable fails when the session vanishes), #2031 (same root cause; the author recommends resuming before installing controls), #1528 (a renderer-bound command sent to a renderer that cannot answer).

Fix

Three coordinated changes, all in the shared setup helpers:

  • BrowserManager::prepare_domains (browser.rs:910) now sends Runtime.runIfWaitingForDebugger (fire-and-forget) before Page.enable. One change covers the popup handler, install_active_network_controls, the iframe handler and enable_domains. The command is a no-op on a target that is not waiting, and it is sent without waiting for a response so a renderer that cannot answer at all (a discarded tab, #1528) still fails on the command that matters instead of stalling on the resume.
  • install_network_controls_for_session (actions.rs:3919) is reordered into install_fetch_interception → prepare_domains → install_domain_filter_script_for_session. Fetch.enable must land before the resume: the browser process answers it even while the renderer is frozen (verified: 1ms on a waitingForDebugger popup session), and a renderer-initiated popup issues its first navigation request the moment it is resumed. With interception installed first, that first request is paused and resolved by resolve_fetch_paused, so a disallowed host stays fail-closed. The in-page filter script still follows the resume because Page.addScriptToEvaluateOnNewDocument and Runtime.evaluate need a live renderer.
  • The concurrent Target.attachedToTarget handler (actions.rs:988) runs the same ordered setup on its own task. Inline, its Page.enable await blocked the fetch event loop against its own Fetch.requestPaused resolver: once interception is installed before the resume, the paused first request can only be resolved by that same loop, so the setup deadlocked with itself until the 30s timeout. The handler still resumes attached targets immediately when no controls are active, exactly as before (that resume is what keeps cross-origin iframes loading during a navigation).

waitForDebuggerOnStart stays enabled. Pausing each auto-attached target before its first request is the containment guarantee the network controls are built on; disabling it would trade a hang for a containment hole.

Trade-off

Resuming before Page.addScriptToEvaluateOnNewDocument is installed leaves a narrow window in which the popup's first document exists before the in-page half of the filter runs. What is not widened: every request, including the popup's first navigation, is still intercepted by Fetch.enable (installed before the resume) and resolved by resolve_fetch_paused, so a disallowed host is blocked before any bytes leave the machine, and addScriptToEvaluateOnNewDocument still covers the popup's next document. The window only affects the JS API patching (fetch/XHR/WebSocket/EventSource/sendBeacon/RTCPeerConnection) in that first document, and Runtime.evaluate re-applies the patch to the current context as soon as one exists. Flagging it explicitly in case a follow-up wants to close it entirely, e.g. by waiting for Runtime.executionContextCreated before resuming.

Verification

Tested against agent-browser 0.39.1 on Linux (Chrome for Testing 155), built from main at 468c0526 and from this branch.

Real bilibili target="_blank" click, native CLI, before and after this patch:

before: real 0m30.148s  {"success":false,"error":"Failed to install browser network controls: CDP command timed out: Page.enable"}
after:  real 0m0.187s   {"success":true,"clicked":"@e121"}
after:  tab list -> [t2] space.bilibili.com/202224425 - https://space.bilibili.com/202224425

Local fixture server (127.0.0.1:8787, landing page with a target="_blank" link, AGENT_BROWSER_ALLOWED_DOMAINS=localhost):

  • allowed-host popup click: real 0m0.085s, success, popup registered at http://localhost:8787/popup;
  • disallowed-host popup click (http://127.0.0.1:8787/leak): real 0m0.086s, success, popup shows the block page ("Navigation to 127.0.0.1 is not allowed by domain filter"), the fixture server logged no /leak request, and the browser stayed up.

Tests:

  • new unit test prepare_domains_resumes_paused_target_before_page_enable pins the command order on a paused session against a fake CDP server that models the frozen renderer (and answers Fetch.enable while frozen, like Chrome);
  • new unit test network_controls_install_fetch_interception_before_resume pins the full order: Fetch.enable, Runtime.runIfWaitingForDebugger, Page.enable, Runtime.enable, Network.enable, WebMCP.enable, Target.setAutoAttach, Page.addScriptToEvaluateOnNewDocument, Runtime.evaluate;
  • new ignored e2e test e2e_domain_filter_target_blank_popup_click_resumes_first does a real target="_blank" click under --allowed-domains and asserts sub-second completion, popup registration and navigation, plus the disallowed-host popup staying blocked fail-closed. It fails on main with the 30s Page.enable timeout and passes in ~1.1s with the fix;
  • cargo test (default suite, --profile ci as in CI): 1495 passed, 0 failed, including all e2e_domain_filter_*, e2e_a11y_*, and auto-attach/new-tab e2e tests;
  • cargo clippy -- -D warnings: no new findings (the two findings on this toolchain, chrome.rs:386 and client.rs:1139, reproduce on unpatched main).

One caveat for reproducibility: during development, e2e_domain_filter intermittently failed with a 30s Page.navigate timeout when the batch ran while leftover Chrome processes from an aborted run were still alive (machine had ~17 chrome processes). With a clean process table the batch passes repeatably (5/5 on main, 4/4 on this branch after cleanup, plus the full-suite runs above); the failure did not reproduce in isolation and is attributed to machine load, but flagging it in case it is seen in CI.

Activity

3
vercel[bot]8d ago

@k997 is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

vercel-security-reviewer[bot]8d ago