feat(stealth): silent operation — never steal the user's foreground tab

Driving the user's real Chrome should not yank their view around. Now the agent
operates entirely in the background:
- New tabs are created with `background: true` (CreateTargetParams) so opening
  one never foregrounds it (the ab-connect extension already used active:false;
  this covers the raw-CDP path too).
- Dropped the two AUTO `Page.bringToFront` calls (auto-connect fresh tab, and the
  internal active-page switch). The explicit `bringToFront` command is untouched —
  surfacing a tab stays opt-in.
- enable_domains now sets `Emulation.setFocusEmulationEnabled(true)` so a
  backgrounded agent tab still renders (screenshots work), isn't render-throttled,
  and reports document.hasFocus()/visibilityState='visible' — which also removes
  the "tab is hidden the whole session" bot tell.

Verified headless: hasFocus=true/visible while backgrounded; click + screenshot
still work. Default behaviour, no flag.
This commit is contained in:
leeguooooo
2026-06-11 20:11:22 +09:00
parent 1c2e594003
commit 5a61a64559
4 changed files with 31 additions and 9 deletions
+3 -4
View File
@@ -1511,12 +1511,11 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
/// subsequent navigations don't hijack the user's existing tabs.
async fn connect_auto_with_fresh_tab() -> Result<BrowserManager, String> {
let mut mgr = BrowserManager::connect_auto().await?;
// tab_new creates the tab in the background (CreateTargetParams.background),
// so attaching to the user's Chrome never steals their foreground tab. We
// deliberately do NOT bring it to front — silent operation.
mgr.tab_new(None, None).await?;
let session_id = mgr.active_session_id()?.to_string();
let _ = mgr
.client
.send_command("Page.bringToFront", None, Some(&session_id))
.await;
// Liveness probe: confirm the CDP session can actually round-trip
// before returning success. Without this, a zombie CDP socket (process
+21 -5
View File
@@ -581,6 +581,7 @@ impl BrowserManager {
&CreateTargetParams {
url: "about:blank".to_string(),
agent_group,
background: None,
},
None,
)
@@ -687,6 +688,20 @@ impl BrowserManager {
Some(session_id),
)
.await;
// Silent operation: agent tabs are driven in the background (we never
// force them to the foreground), so emulate focus. Without this a
// backgrounded tab is render-throttled and reports `document.hidden` /
// `!document.hasFocus()` — which both breaks timing-sensitive pages and
// is itself a bot signal (a real user looks at the page). Best-effort;
// ignored on engines without Emulation support.
let _ = self
.client
.send_command(
"Emulation.setFocusEmulationEnabled",
Some(json!({ "enabled": true })),
Some(session_id),
)
.await;
Ok(())
}
@@ -973,6 +988,7 @@ impl BrowserManager {
&CreateTargetParams {
url: "about:blank".to_string(),
agent_group,
background: None,
},
None,
)
@@ -1137,6 +1153,7 @@ impl BrowserManager {
&CreateTargetParams {
url: target_url.to_string(),
agent_group,
background: Some(true),
},
None,
)
@@ -1192,11 +1209,10 @@ impl BrowserManager {
let session_id = self.pages[index].session_id.clone();
self.enable_domains(&session_id).await?;
// Bring tab to front
let _ = self
.client
.send_command("Page.bringToFront", None, Some(&session_id))
.await;
// Silent: switching the agent's *internal* active page must not yank the
// user's foreground tab. The page is driven in the background (focus is
// emulated in enable_domains); the explicit `bringToFront` command is the
// only way a tab is deliberately surfaced.
let url = self.get_url().await.unwrap_or_default();
let title = self.get_title().await.unwrap_or_default();
+6
View File
@@ -153,6 +153,12 @@ pub struct CreateTargetParams {
/// endpoint never receives an unknown parameter.
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_group: Option<String>,
/// Create the tab in the background so opening it never steals the user's
/// foreground tab (silent operation). Standard CDP param; the ab-connect
/// extension creates its tabs `active: false` regardless, so this only
/// affects the raw-CDP (no extension) path.
#[serde(skip_serializing_if = "Option::is_none")]
pub background: Option<bool>,
}
#[derive(Debug, Deserialize)]
+1
View File
@@ -121,6 +121,7 @@ async fn collect_storage_via_temp_target(
url: "about:blank".to_string(),
// Transient internal target (storage collection) — never grouped.
agent_group: None,
background: None,
},
None,
)