fix(stealth,hygiene): per-session stealth coverage + orphaned temp-profile sweep

Stealth coverage (the fork's core value was leaking on secondary surfaces):
- stealth scripts are registered per CDP session, so new tabs (`tab new`) and
  cross-origin iframe sessions created after the initial page had NO patches.
  Extract apply_stealth_via_mgr/apply_stealth_to_session and re-apply on
  tab_new and on iframe attach. Fixes automation markers (and FullLaunch UA)
  leaking in new tabs / cross-origin frames.

Resource hygiene (temp profiles filled the disk):
- ChromeProcess::drop already cleans the temp user-data-dir on normal exit, but
  a hard kill (kill -9 / version-mismatch restart / crash) skips Drop and leaks
  ~50MB per session. Add cleanup_orphaned_chrome_profiles() on daemon startup
  that sweeps agent-browser-chrome-* temp dirs NOT referenced by any live
  process (so an in-use profile is never deleted).
This commit is contained in:
leeguooooo
2026-06-01 12:38:57 +09:00
parent 6f4e63ba91
commit f62e204038
3 changed files with 120 additions and 28 deletions
+60 -28
View File
@@ -633,6 +633,8 @@ impl DaemonState {
.send_command_no_params("Network.enable", Some(iframe_sid.as_str()))
.await;
}
// Hide automation markers in this cross-origin iframe session too.
apply_stealth_via_mgr(mgr, iframe_sid.as_str()).await;
}
}
for sid in &drained.detached_iframe_sessions {
@@ -1758,45 +1760,62 @@ fn chrome_relaunch_hint() -> &'static str {
/// Called after every successful launch / CDP connect / auto-connect.
/// Uses `CdpAttach` mode for external connections (minimal patches) and
/// `FullLaunch` mode for newly launched Chrome (all patches).
async fn apply_stealth_to_browser(state: &DaemonState) {
if env::var("AGENT_BROWSER_STEALTH").map(|v| v == "0").unwrap_or(false) {
return; // Explicitly disabled
}
let Some(ref mgr) = state.browser else {
return;
};
let Ok(session_id) = mgr.active_session_id() else {
return;
};
/// Whether stealth is enabled (default on; `AGENT_BROWSER_STEALTH=0` disables).
fn stealth_enabled() -> bool {
!env::var("AGENT_BROWSER_STEALTH")
.map(|v| v == "0")
.unwrap_or(false)
}
// Determine mode: if we attached to an external browser, use minimal patches.
// The user's real Chrome already has a genuine fingerprint — heavy patches
// would create detectable "lies" (e.g. creepjs hasIframeProxy).
/// Apply stealth patches to ONE CDP session of the given browser.
///
/// Stealth scripts are registered per-session via
/// `Page.addScriptToEvaluateOnNewDocument`, so they do NOT carry over to new
/// tabs or cross-origin iframe sessions created after the initial page. We must
/// re-apply to every session the user can touch, otherwise automation markers
/// (and, in FullLaunch mode, the HeadlessChrome UA) leak on those surfaces.
async fn apply_stealth_via_mgr(mgr: &BrowserManager, session_id: &str) {
if !stealth_enabled() {
return;
}
// Determine mode: an external attach uses minimal patches (the user's real
// Chrome already has a genuine fingerprint — heavy patches create detectable
// "lies" like creepjs hasIframeProxy); a fresh launch uses the full set.
let mode = if mgr.is_cdp_connection() {
stealth::StealthMode::CdpAttach
} else {
stealth::StealthMode::FullLaunch
};
let locale = env::var("AGENT_BROWSER_LOCALE").ok();
if let Err(e) = stealth::apply_stealth(
&mgr.client,
session_id,
mode,
locale.as_deref(),
)
.await
{
eprintln!("[stealth] Failed to apply stealth patches: {}", e);
if let Err(e) = stealth::apply_stealth(&mgr.client, session_id, mode, locale.as_deref()).await {
eprintln!("[stealth] failed to apply patches to session {session_id}: {e}");
}
// Also inject into the current page (already loaded before our init script)
// Also inject into the current page (already loaded before our init script).
if let Err(e) =
stealth::apply_stealth_to_current_page(&mgr.client, session_id, mode, locale.as_deref()).await
stealth::apply_stealth_to_current_page(&mgr.client, session_id, mode, locale.as_deref())
.await
{
eprintln!("[stealth] Failed to patch current page: {}", e);
eprintln!("[stealth] failed to patch current page for session {session_id}: {e}");
}
}
/// Apply stealth to a specific session of the active browser (no-op if no
/// browser or stealth disabled).
async fn apply_stealth_to_session(state: &DaemonState, session_id: &str) {
if let Some(ref mgr) = state.browser {
apply_stealth_via_mgr(mgr, session_id).await;
}
}
/// Apply stealth to the active page session (initial connect/launch).
async fn apply_stealth_to_browser(state: &DaemonState) {
let session_id = match state.browser.as_ref().and_then(|m| m.active_session_id().ok()) {
Some(sid) => sid.to_string(),
None => return,
};
apply_stealth_to_session(state, &session_id).await;
}
/// If the previous daemon left a `.restore-url` sidecar (because it was killed
/// by a version-mismatch restart), navigate the freshly-connected browser to
/// that URL so `agent-browser get url` after `npm i -g` upgrade still reports
@@ -3948,13 +3967,26 @@ async fn handle_tab_list(state: &DaemonState) -> Result<Value, String> {
}
async fn handle_tab_new(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
let url = cmd.get("url").and_then(|v| v.as_str());
let label = cmd.get("label").and_then(|v| v.as_str());
state.ref_map.clear();
state.iframe_sessions.clear();
state.active_frame_id = None;
mgr.tab_new(url, label).await
let result = {
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
mgr.tab_new(url, label).await?
};
// A new tab is a new CDP session; stealth scripts registered on the prior
// session don't carry over, so patch the new tab too.
if let Some(sid) = state
.browser
.as_ref()
.and_then(|m| m.active_session_id().ok())
.map(|s| s.to_string())
{
apply_stealth_to_session(state, &sid).await;
}
Ok(result)
}
async fn handle_tab_switch(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
+56
View File
@@ -664,6 +664,62 @@ pub fn read_devtools_active_port(user_data_dir: &Path) -> Option<(u16, String)>
Some((port, ws_path))
}
/// Remove leftover Chrome temp profile directories from daemons that were
/// hard-killed. `ChromeProcess::drop` cleans these up on a normal exit, but a
/// `kill -9` (version-mismatch restart, OOM, crash) skips Drop and leaks ~50MB
/// per session under the system temp dir. On daemon startup we sweep them — but
/// ONLY dirs that no running process still references as `--user-data-dir`, so
/// a profile in active use is never deleted.
pub fn cleanup_orphaned_chrome_profiles() {
let tmp = std::env::temp_dir();
let Ok(entries) = std::fs::read_dir(&tmp) else {
return;
};
// Snapshot live process command lines once. If we can't determine them,
// skip cleanup entirely rather than risk deleting an in-use profile.
let Some(live_cmdlines) = running_process_cmdlines() else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name();
if !name.to_string_lossy().starts_with("agent-browser-chrome-") {
continue;
}
let path = entry.path();
let path_str = path.to_string_lossy();
let in_use = live_cmdlines
.iter()
.any(|cmd| cmd.contains(path_str.as_ref()));
if !in_use {
let _ = std::fs::remove_dir_all(&path);
}
}
}
#[cfg(unix)]
fn running_process_cmdlines() -> Option<Vec<String>> {
let output = std::process::Command::new("ps")
.args(["-axww", "-o", "command="])
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(
String::from_utf8_lossy(&output.stdout)
.lines()
.map(|l| l.to_string())
.collect(),
)
}
#[cfg(not(unix))]
fn running_process_cmdlines() -> Option<Vec<String>> {
// Best-effort: skip cleanup where we can't cheaply enumerate full process
// command lines, to avoid deleting a profile that is still in use.
None
}
pub async fn auto_connect_cdp() -> Result<String, String> {
let user_data_dirs = get_chrome_user_data_dirs();
+4
View File
@@ -59,6 +59,10 @@ pub async fn run_daemon(session: &str) {
}
}
// Sweep temp Chrome profiles leaked by hard-killed daemons (Drop doesn't
// run on kill -9). Only removes dirs no live process references.
super::cdp::chrome::cleanup_orphaned_chrome_profiles();
let pid_path = socket_dir.join(format!("{}.pid", session));
let _ = fs::write(&pid_path, process::id().to_string());