fix(connect): single consenting WebSocket — drop throwaway verify probe
auto-connect resolved the DevToolsActivePort URL by first opening a verification WebSocket (verify_ws_endpoint: connect, Browser.getVersion, close) and only then opening the real connection. On Chrome 136+ the "Allow remote debugging?" consent is granted per-connection, so the user's single Allow click was consumed by the throwaway probe and the real connection (opened afterwards) asked again — surfacing as repeated prompts or a hung command after the user had already clicked Allow. resolve_cdp_from_active_port now gates the direct DevToolsActivePort URL on a consent-free TCP liveness check (tcp_port_alive) instead of a WebSocket probe, so the real connection is the single WebSocket the user consents to. A bare TCP connect does not trigger the consent flow (that fires on the CDP upgrade), and the real connect_async has no client-side timeout, so it waits for the user to click Allow at their own pace. verify_ws_endpoint removed; discovery-order tests updated, plus a guard test that resolution opens no WebSocket. Verified live: single prompt on a real Chrome attach, then open + eval + scroll x2 + eval with zero re-prompts and no freeze.
This commit is contained in:
@@ -690,52 +690,52 @@ pub async fn auto_connect_cdp() -> Result<String, String> {
|
|||||||
|
|
||||||
/// Resolve a CDP WebSocket URL from a DevToolsActivePort entry.
|
/// Resolve a CDP WebSocket URL from a DevToolsActivePort entry.
|
||||||
///
|
///
|
||||||
/// Tries the exact WebSocket path from DevToolsActivePort first (single
|
/// Returns the exact browser WebSocket URL from DevToolsActivePort, gated only
|
||||||
/// prompt on M144+), then falls back to legacy HTTP discovery for older
|
/// by a consent-free TCP liveness check. Falls back to HTTP discovery on the
|
||||||
/// Chrome versions. This order avoids triggering duplicate remote-debugging
|
/// same port for older Chrome layouts.
|
||||||
/// permission prompts (#1210, #1206).
|
///
|
||||||
|
/// Crucially, this does NOT open a throwaway verification WebSocket. On
|
||||||
|
/// Chrome 136+ the "Allow remote debugging?" consent is granted *per
|
||||||
|
/// connection*: a probe WebSocket we then close would consume the user's one
|
||||||
|
/// Allow click, leaving the real connection (opened afterwards) unconsented —
|
||||||
|
/// which manifests as an endless prompt loop or a hung command. By skipping the
|
||||||
|
/// probe, the real connection is the single WebSocket the user consents to.
|
||||||
|
/// (Background: #1210, #1206 duplicate-prompt reports.)
|
||||||
async fn resolve_cdp_from_active_port(port: u16, ws_path: &str) -> Result<String, String> {
|
async fn resolve_cdp_from_active_port(port: u16, ws_path: &str) -> Result<String, String> {
|
||||||
let ws_url = format!("ws://127.0.0.1:{}{}", port, ws_path);
|
// Consent-free liveness: a bare TCP connect does not trigger the
|
||||||
if verify_ws_endpoint(&ws_url).await {
|
// remote-debugging consent flow (that fires on the CDP/WebSocket upgrade),
|
||||||
return Ok(ws_url);
|
// so we can tell "Chrome is listening" from "stale DevToolsActivePort"
|
||||||
|
// without burning a prompt.
|
||||||
|
if tcp_port_alive(port).await {
|
||||||
|
return Ok(format!("ws://127.0.0.1:{}{}", port, ws_path));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-M144 fallback: HTTP endpoints (/json/version, /json/list, etc.)
|
// Port isn't accepting connections (stale file / different layout). Fall
|
||||||
|
// back to HTTP discovery for older Chrome before giving up.
|
||||||
if let Ok(ws_url) = discover_cdp_url("127.0.0.1", port, None).await {
|
if let Ok(ws_url) = discover_cdp_url("127.0.0.1", port, None).await {
|
||||||
return Ok(ws_url);
|
return Ok(ws_url);
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(format!(
|
Err(format!(
|
||||||
"Cannot connect to Chrome on port {}: both direct WebSocket and HTTP discovery failed",
|
"Cannot connect to Chrome on port {}: port not reachable and HTTP discovery failed",
|
||||||
port
|
port
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify that a WebSocket endpoint is a live CDP server by sending
|
/// Consent-free check that something is accepting TCP connections on
|
||||||
/// `Browser.getVersion` and checking for a valid response.
|
/// `127.0.0.1:port`. Unlike a CDP/WebSocket probe, a bare TCP connect does not
|
||||||
async fn verify_ws_endpoint(ws_url: &str) -> bool {
|
/// trigger Chrome's "Allow remote debugging?" consent prompt, so it is safe to
|
||||||
use futures_util::{SinkExt, StreamExt};
|
/// use for liveness before handing the URL to the single real connection.
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
async fn tcp_port_alive(port: u16) -> bool {
|
||||||
|
let timeout = Duration::from_secs(1);
|
||||||
let timeout = Duration::from_secs(2);
|
matches!(
|
||||||
let result = tokio::time::timeout(timeout, async {
|
tokio::time::timeout(
|
||||||
let (mut ws, _) = tokio_tungstenite::connect_async(ws_url).await.ok()?;
|
timeout,
|
||||||
let cmd = r#"{"id":1,"method":"Browser.getVersion"}"#;
|
tokio::net::TcpStream::connect(("127.0.0.1", port)),
|
||||||
ws.send(Message::Text(cmd.into())).await.ok()?;
|
)
|
||||||
while let Some(Ok(msg)) = ws.next().await {
|
.await,
|
||||||
if let Message::Text(text) = msg {
|
Ok(Ok(_))
|
||||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
|
)
|
||||||
if v.get("id").and_then(|id| id.as_u64()) == Some(1) {
|
|
||||||
let _ = ws.close(None).await;
|
|
||||||
return Some(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
matches!(result, Ok(Some(())))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the default Chrome user-data directory paths for the current platform.
|
/// Returns the default Chrome user-data directory paths for the current platform.
|
||||||
@@ -1904,83 +1904,60 @@ mod tests {
|
|||||||
// auto_connect_cdp discovery-order tests (#1210, #1206)
|
// auto_connect_cdp discovery-order tests (#1210, #1206)
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
/// When DevToolsActivePort provides a ws_path and the port is reachable,
|
/// When the port is live, `resolve_cdp_from_active_port` returns the exact
|
||||||
/// `resolve_cdp_from_active_port` should return the exact ws_path URL
|
/// DevToolsActivePort ws_path URL via a consent-free TCP check — it does NOT
|
||||||
/// WITHOUT calling HTTP discovery first.
|
/// probe with a verification WebSocket (which would burn Chrome 136+'s
|
||||||
|
/// per-connection remote-debugging consent on a throwaway socket).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_resolve_cdp_from_active_port_prefers_ws_path() {
|
async fn test_resolve_cdp_from_active_port_returns_ws_path_without_probe() {
|
||||||
use futures_util::{SinkExt, StreamExt};
|
// A bound listener makes the port TCP-reachable. We do NOT accept/serve
|
||||||
use tokio_tungstenite::tungstenite::Message as WsMsg;
|
// any WebSocket — resolve must succeed from the bare TCP check alone.
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let port = listener.local_addr().unwrap().port();
|
let port = listener.local_addr().unwrap().port();
|
||||||
let ws_path = "/devtools/browser/test-uuid-1234".to_string();
|
let ws_path = "/devtools/browser/test-uuid-1234";
|
||||||
|
|
||||||
let server = tokio::spawn(async move {
|
let result = resolve_cdp_from_active_port(port, ws_path).await;
|
||||||
// accept: verify_ws_endpoint() WebSocket handshake
|
assert!(result.is_ok(), "should succeed when port is live: {:?}", result);
|
||||||
let (stream, _) = listener.accept().await.unwrap();
|
assert_eq!(
|
||||||
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
|
result.unwrap(),
|
||||||
if let Some(Ok(WsMsg::Text(text))) = ws.next().await {
|
format!("ws://127.0.0.1:{}{}", port, ws_path),
|
||||||
let req: serde_json::Value = serde_json::from_str(&text).unwrap();
|
"should return the exact DevToolsActivePort URL untouched"
|
||||||
let id = req.get("id").unwrap();
|
|
||||||
let reply = format!(
|
|
||||||
r#"{{"id":{},"result":{{"protocolVersion":"1.3","product":"Chrome/147"}}}}"#,
|
|
||||||
id
|
|
||||||
);
|
|
||||||
ws.send(WsMsg::Text(reply)).await.unwrap();
|
|
||||||
}
|
|
||||||
let _ = ws.close(None).await;
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = resolve_cdp_from_active_port(port, &ws_path).await;
|
|
||||||
assert!(result.is_ok(), "should succeed: {:?}", result);
|
|
||||||
let url = result.unwrap();
|
|
||||||
assert!(
|
|
||||||
url.contains("test-uuid-1234"),
|
|
||||||
"should use exact ws_path from DevToolsActivePort, got: {}",
|
|
||||||
url
|
|
||||||
);
|
);
|
||||||
assert_eq!(url, format!("ws://127.0.0.1:{}{}", port, ws_path));
|
drop(listener);
|
||||||
server.await.unwrap();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// When the exact ws_path connection fails, `resolve_cdp_from_active_port`
|
/// Regression guard for the consent storm: resolving the URL must only do a
|
||||||
/// should fall back to HTTP discovery.
|
/// bare TCP connect, never a WebSocket/CDP handshake. On Chrome 136+ a
|
||||||
|
/// handshake on a throwaway socket consumes the user's one "Allow remote
|
||||||
|
/// debugging?" click, leaving the real connection unconsented (endless
|
||||||
|
/// prompts / hang).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_resolve_cdp_from_active_port_falls_back_to_http_discovery() {
|
async fn test_resolve_cdp_from_active_port_does_not_open_websocket() {
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let port = listener.local_addr().unwrap().port();
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
|
||||||
let server = tokio::spawn(async move {
|
let server = tokio::spawn(async move {
|
||||||
// 1st accept: verify_ws_endpoint() ws_path probe — reject (just close)
|
let (mut stream, _) = listener.accept().await.unwrap();
|
||||||
let (s1, _) = listener.accept().await.unwrap();
|
// The liveness check connects then drops without writing anything.
|
||||||
drop(s1);
|
// Assert we receive no WebSocket upgrade bytes (EOF / no data).
|
||||||
|
let mut buf = [0u8; 128];
|
||||||
// 2nd accept: HTTP /json/version from discover_cdp_url()
|
let read = tokio::time::timeout(
|
||||||
let (mut s2, _) = listener.accept().await.unwrap();
|
Duration::from_millis(500),
|
||||||
let mut buf = [0u8; 2048];
|
stream.read(&mut buf),
|
||||||
let _ = s2.read(&mut buf).await;
|
)
|
||||||
let body = format!(
|
.await;
|
||||||
r#"{{"webSocketDebuggerUrl":"ws://127.0.0.1:{}/devtools/browser/fallback-uuid"}}"#,
|
match read {
|
||||||
port
|
Ok(Ok(n)) => assert_eq!(n, 0, "resolve must not send a WS/CDP handshake"),
|
||||||
);
|
Ok(Err(_)) | Err(_) => {} // closed or nothing sent — both fine
|
||||||
let resp = format!(
|
}
|
||||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n{}",
|
|
||||||
body.len(),
|
|
||||||
body
|
|
||||||
);
|
|
||||||
s2.write_all(resp.as_bytes()).await.unwrap();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = resolve_cdp_from_active_port(port, "/devtools/browser/nonexistent-uuid").await;
|
let result = resolve_cdp_from_active_port(port, "/devtools/browser/abc").await;
|
||||||
assert!(result.is_ok(), "should fall back to HTTP: {:?}", result);
|
assert_eq!(
|
||||||
let url = result.unwrap();
|
result.unwrap(),
|
||||||
assert!(
|
format!("ws://127.0.0.1:{}/devtools/browser/abc", port)
|
||||||
url.contains("fallback-uuid"),
|
|
||||||
"should use HTTP discovery fallback, got: {}",
|
|
||||||
url
|
|
||||||
);
|
);
|
||||||
server.await.unwrap();
|
server.await.unwrap();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user