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:
+60
-28
@@ -633,6 +633,8 @@ impl DaemonState {
|
|||||||
.send_command_no_params("Network.enable", Some(iframe_sid.as_str()))
|
.send_command_no_params("Network.enable", Some(iframe_sid.as_str()))
|
||||||
.await;
|
.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 {
|
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.
|
/// Called after every successful launch / CDP connect / auto-connect.
|
||||||
/// Uses `CdpAttach` mode for external connections (minimal patches) and
|
/// Uses `CdpAttach` mode for external connections (minimal patches) and
|
||||||
/// `FullLaunch` mode for newly launched Chrome (all patches).
|
/// `FullLaunch` mode for newly launched Chrome (all patches).
|
||||||
async fn apply_stealth_to_browser(state: &DaemonState) {
|
/// Whether stealth is enabled (default on; `AGENT_BROWSER_STEALTH=0` disables).
|
||||||
if env::var("AGENT_BROWSER_STEALTH").map(|v| v == "0").unwrap_or(false) {
|
fn stealth_enabled() -> bool {
|
||||||
return; // Explicitly disabled
|
!env::var("AGENT_BROWSER_STEALTH")
|
||||||
}
|
.map(|v| v == "0")
|
||||||
let Some(ref mgr) = state.browser else {
|
.unwrap_or(false)
|
||||||
return;
|
}
|
||||||
};
|
|
||||||
let Ok(session_id) = mgr.active_session_id() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Determine mode: if we attached to an external browser, use minimal patches.
|
/// Apply stealth patches to ONE CDP session of the given browser.
|
||||||
// The user's real Chrome already has a genuine fingerprint — heavy patches
|
///
|
||||||
// would create detectable "lies" (e.g. creepjs hasIframeProxy).
|
/// 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() {
|
let mode = if mgr.is_cdp_connection() {
|
||||||
stealth::StealthMode::CdpAttach
|
stealth::StealthMode::CdpAttach
|
||||||
} else {
|
} else {
|
||||||
stealth::StealthMode::FullLaunch
|
stealth::StealthMode::FullLaunch
|
||||||
};
|
};
|
||||||
|
|
||||||
let locale = env::var("AGENT_BROWSER_LOCALE").ok();
|
let locale = env::var("AGENT_BROWSER_LOCALE").ok();
|
||||||
if let Err(e) = stealth::apply_stealth(
|
if let Err(e) = stealth::apply_stealth(&mgr.client, session_id, mode, locale.as_deref()).await {
|
||||||
&mgr.client,
|
eprintln!("[stealth] failed to apply patches to session {session_id}: {e}");
|
||||||
session_id,
|
}
|
||||||
mode,
|
// Also inject into the current page (already loaded before our init script).
|
||||||
locale.as_deref(),
|
if let Err(e) =
|
||||||
)
|
stealth::apply_stealth_to_current_page(&mgr.client, session_id, mode, locale.as_deref())
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
eprintln!("[stealth] Failed to apply stealth patches: {}", e);
|
eprintln!("[stealth] failed to patch current page for session {session_id}: {e}");
|
||||||
}
|
}
|
||||||
// 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
|
/// Apply stealth to a specific session of the active browser (no-op if no
|
||||||
{
|
/// browser or stealth disabled).
|
||||||
eprintln!("[stealth] Failed to patch current page: {}", e);
|
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
|
/// If the previous daemon left a `.restore-url` sidecar (because it was killed
|
||||||
/// by a version-mismatch restart), navigate the freshly-connected browser to
|
/// 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
|
/// 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> {
|
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 url = cmd.get("url").and_then(|v| v.as_str());
|
||||||
let label = cmd.get("label").and_then(|v| v.as_str());
|
let label = cmd.get("label").and_then(|v| v.as_str());
|
||||||
state.ref_map.clear();
|
state.ref_map.clear();
|
||||||
state.iframe_sessions.clear();
|
state.iframe_sessions.clear();
|
||||||
state.active_frame_id = None;
|
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> {
|
async fn handle_tab_switch(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
|
|||||||
@@ -664,6 +664,62 @@ pub fn read_devtools_active_port(user_data_dir: &Path) -> Option<(u16, String)>
|
|||||||
Some((port, ws_path))
|
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> {
|
pub async fn auto_connect_cdp() -> Result<String, String> {
|
||||||
let user_data_dirs = get_chrome_user_data_dirs();
|
let user_data_dirs = get_chrome_user_data_dirs();
|
||||||
|
|
||||||
|
|||||||
@@ -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 pid_path = socket_dir.join(format!("{}.pid", session));
|
||||||
let _ = fs::write(&pid_path, process::id().to_string());
|
let _ = fs::write(&pid_path, process::id().to_string());
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user