fix(stealth): 修复 headed 模式下 stealth 失效并统一策略

- 修复 launch 协议未透传 stealth 导致 --headed 下补丁失效的问题\n- 在 BrowserManager 引入 StealthPolicy,统一 local/CDP/provider 能力决策\n- 增加 launch 返回 stealth 状态并在 --debug 输出连接类型与能力\n- 补充 local/CDP 回归测试与 bot.sannysoft.com 自动检查脚本\n- 同步 README、CLI help、技能文档与 CDP 文档中的 stealth 能力矩阵
This commit is contained in:
leeguooooo
2026-02-24 12:18:47 +09:00
parent 2fe7394dbe
commit 8932f28926
15 changed files with 985 additions and 89 deletions
+16 -4
View File
@@ -220,6 +220,8 @@ pub fn ensure_daemon(
provider: Option<&str>,
device: Option<&str>,
session_name: Option<&str>,
stealth: bool,
debug: bool,
) -> Result<DaemonResult, String> {
// Check if daemon is running AND responsive
if is_daemon_running(session) && daemon_ready(session) {
@@ -364,6 +366,11 @@ pub fn ensure_daemon(
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
}
cmd.env("AGENT_BROWSER_STEALTH", if stealth { "1" } else { "0" });
if debug {
cmd.env("AGENT_BROWSER_DEBUG", "1");
}
// Create new process group and session to fully detach
unsafe {
cmd.pre_exec(|| {
@@ -375,8 +382,8 @@ pub fn ensure_daemon(
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.stderr(Stdio::null());
cmd.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?;
}
@@ -447,6 +454,11 @@ pub fn ensure_daemon(
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
}
cmd.env("AGENT_BROWSER_STEALTH", if stealth { "1" } else { "0" });
if debug {
cmd.env("AGENT_BROWSER_DEBUG", "1");
}
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
@@ -454,8 +466,8 @@ pub fn ensure_daemon(
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.stderr(Stdio::null());
cmd.spawn()
.map_err(|e| format!("Failed to start daemon: {}", e))?;
}
+79 -43
View File
@@ -52,6 +52,14 @@ fn parse_proxy(proxy_str: &str) -> serde_json::Value {
})
}
fn print_stealth_debug(resp: &connection::Response) {
if let Some(data) = &resp.data {
if let Some(stealth) = data.get("stealth") {
eprintln!("[DEBUG] stealth: {}", stealth);
}
}
}
fn run_session(args: &[String], session: &str, json_mode: bool) {
let subcommand = args.get(1).map(|s| s.as_str());
@@ -226,6 +234,8 @@ fn main() {
flags.provider.as_deref(),
flags.device.as_deref(),
flags.session_name.as_deref(),
flags.stealth,
flags.debug,
) {
Ok(result) => result,
Err(e) => {
@@ -281,6 +291,7 @@ fn main() {
},
flags.ignore_https_errors.then_some("--ignore-https-errors"),
flags.cli_allow_file_access.then_some("--allow-file-access"),
flags.cli_stealth.then_some("--stealth"),
]
.into_iter()
.flatten()
@@ -448,22 +459,31 @@ fn main() {
launch_cmd["colorScheme"] = json!(cs);
}
let err = match send_command(launch_cmd, &flags.session) {
Ok(resp) if resp.success => None,
Ok(resp) => Some(
resp.error
.unwrap_or_else(|| "CDP connection failed".to_string()),
),
Err(e) => Some(e.to_string()),
};
if let Some(msg) = err {
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
match send_command(launch_cmd, &flags.session) {
Ok(resp) => {
if !resp.success {
let msg = resp
.error
.unwrap_or_else(|| "CDP connection failed".to_string());
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
if flags.debug {
print_stealth_debug(&resp);
}
}
Err(e) => {
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, e);
} else {
eprintln!("{} {}", color::error_indicator(), e);
}
exit(1);
}
exit(1);
}
}
@@ -479,22 +499,31 @@ fn main() {
launch_cmd["colorScheme"] = json!(cs);
}
let err = match send_command(launch_cmd, &flags.session) {
Ok(resp) if resp.success => None,
Ok(resp) => Some(
resp.error
.unwrap_or_else(|| "Provider connection failed".to_string()),
),
Err(e) => Some(e.to_string()),
};
if let Some(msg) = err {
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
match send_command(launch_cmd, &flags.session) {
Ok(resp) => {
if !resp.success {
let msg = resp
.error
.unwrap_or_else(|| "Provider connection failed".to_string());
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, msg);
} else {
eprintln!("{} {}", color::error_indicator(), msg);
}
exit(1);
}
if flags.debug {
print_stealth_debug(&resp);
}
}
Err(e) => {
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, e);
} else {
eprintln!("{} {}", color::error_indicator(), e);
}
exit(1);
}
exit(1);
}
}
@@ -506,7 +535,10 @@ fn main() {
|| flags.proxy.is_some()
|| flags.args.is_some()
|| flags.user_agent.is_some()
|| flags.ignore_https_errors
|| flags.allow_file_access
|| flags.cli_stealth
|| flags.debug
|| flags.color_scheme.is_some())
&& flags.cdp.is_none()
&& flags.provider.is_none()
@@ -569,22 +601,29 @@ fn main() {
launch_cmd["allowFileAccess"] = json!(true);
}
launch_cmd["stealth"] = json!(flags.stealth);
if let Some(ref cs) = flags.color_scheme {
launch_cmd["colorScheme"] = json!(cs);
}
match send_command(launch_cmd, &flags.session) {
Ok(resp) if !resp.success => {
// Launch command failed (e.g., invalid state file, profile error)
let error_msg = resp
.error
.unwrap_or_else(|| "Browser launch failed".to_string());
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, error_msg);
} else {
eprintln!("{} {}", color::error_indicator(), error_msg);
Ok(resp) => {
if !resp.success {
// Launch command failed (e.g., invalid state file, profile error)
let error_msg = resp
.error
.unwrap_or_else(|| "Browser launch failed".to_string());
if flags.json {
println!(r#"{{"success":false,"error":"{}"}}"#, error_msg);
} else {
eprintln!("{} {}", color::error_indicator(), error_msg);
}
exit(1);
}
if flags.debug {
print_stealth_debug(&resp);
}
exit(1);
}
Err(e) => {
if flags.json {
@@ -598,9 +637,6 @@ fn main() {
}
exit(1);
}
Ok(_) => {
// Launch succeeded
}
}
}
+48 -26
View File
@@ -39,15 +39,11 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
return;
}
Some("diff_url") => {
if let Some(snap_data) =
obj.get("snapshot").and_then(|v| v.as_object())
{
if let Some(snap_data) = obj.get("snapshot").and_then(|v| v.as_object()) {
println!("{}", color::bold("Snapshot diff:"));
print_snapshot_diff(snap_data);
}
if let Some(ss_data) =
obj.get("screenshot").and_then(|v| v.as_object())
{
if let Some(ss_data) = obj.get("screenshot").and_then(|v| v.as_object()) {
println!("\n{}", color::bold("Screenshot diff:"));
print_screenshot_diff(ss_data);
}
@@ -310,11 +306,7 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
}
_ => {
if let Some(path) = data.get("path").and_then(|v| v.as_str()) {
println!(
"{} Recording started: {}",
color::success_indicator(),
path
);
println!("{} Recording started: {}", color::success_indicator(), path);
} else {
println!("{} Recording started", color::success_indicator());
}
@@ -497,7 +489,10 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
let filename = file.get("filename").and_then(|v| v.as_str()).unwrap_or("");
let size = file.get("size").and_then(|v| v.as_i64()).unwrap_or(0);
let modified = file.get("modified").and_then(|v| v.as_str()).unwrap_or("");
let encrypted = file.get("encrypted").and_then(|v| v.as_bool()).unwrap_or(false);
let encrypted = file
.get("encrypted")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let size_str = if size > 1024 {
format!("{:.1}KB", size as f64 / 1024.0)
} else {
@@ -505,7 +500,11 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
};
let date_str = modified.split('T').next().unwrap_or(modified);
let enc_str = if encrypted { " [encrypted]" } else { "" };
println!(" {} {}", filename, color::dim(&format!("({}, {}){}", size_str, date_str, enc_str)));
println!(
" {} {}",
filename,
color::dim(&format!("({}, {}){}", size_str, date_str, enc_str))
);
}
}
return;
@@ -515,13 +514,22 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
if let Some(true) = data.get("renamed").and_then(|v| v.as_bool()) {
let old_name = data.get("oldName").and_then(|v| v.as_str()).unwrap_or("");
let new_name = data.get("newName").and_then(|v| v.as_str()).unwrap_or("");
println!("{} Renamed {} -> {}", color::success_indicator(), old_name, new_name);
println!(
"{} Renamed {} -> {}",
color::success_indicator(),
old_name,
new_name
);
return;
}
// State clear
if let Some(cleared) = data.get("cleared").and_then(|v| v.as_i64()) {
println!("{} Cleared {} state file(s)", color::success_indicator(), cleared);
println!(
"{} Cleared {} state file(s)",
color::success_indicator(),
cleared
);
return;
}
@@ -529,7 +537,10 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
if let Some(summary) = data.get("summary") {
let cookies = summary.get("cookies").and_then(|v| v.as_i64()).unwrap_or(0);
let origins = summary.get("origins").and_then(|v| v.as_i64()).unwrap_or(0);
let encrypted = data.get("encrypted").and_then(|v| v.as_bool()).unwrap_or(false);
let encrypted = data
.get("encrypted")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let enc_str = if encrypted { " (encrypted)" } else { "" };
println!("State file summary{}:", enc_str);
println!(" Cookies: {}", cookies);
@@ -539,7 +550,11 @@ pub fn print_response(resp: &Response, json_mode: bool, action: Option<&str>) {
// State clean
if let Some(cleaned) = data.get("cleaned").and_then(|v| v.as_i64()) {
println!("{} Cleaned {} old state file(s)", color::success_indicator(), cleaned);
println!(
"{} Cleaned {} old state file(s)",
color::success_indicator(),
cleaned
);
return;
}
@@ -1017,13 +1032,14 @@ Examples:
r##"
agent-browser wait - Wait for condition
Usage: agent-browser wait <selector|ms|option>
Usage: agent-browser wait <selector|ms|min-max|option>
Waits for an element to appear, a timeout, or other conditions.
Modes:
<selector> Wait for element to appear
<ms> Wait for specified milliseconds
<min>-<max> Wait for random time between min and max ms
--url <pattern> Wait for URL to match pattern
--load <state> Wait for load state (load, domcontentloaded, networkidle)
--fn <expression> Wait for JavaScript expression to be truthy
@@ -1040,6 +1056,7 @@ Global Options:
Examples:
agent-browser wait "#loading-spinner"
agent-browser wait 2000
agent-browser wait 2000-5000 # Random wait between 2-5 seconds
agent-browser wait --url "**/dashboard"
agent-browser wait --load networkidle
agent-browser wait --fn "window.appReady === true"
@@ -2011,7 +2028,7 @@ Core Commands:
download <sel> <path> Download file by clicking element
scroll <dir> [px] Scroll (up/down/left/right)
scrollintoview <sel> Scroll element into view
wait <sel|ms> Wait for element or time
wait <sel|ms|min-max> Wait for element, time, or random range
screenshot [path] Take screenshot
pdf <path> Save as PDF
snapshot Accessibility tree with refs (for AI)
@@ -2097,6 +2114,7 @@ Options:
e.g., --proxy-bypass "localhost,*.internal.com"
--ignore-https-errors Ignore HTTPS certificate errors
--allow-file-access Allow file:// URLs to access local files (Chromium only)
--stealth Stealth mode (default: on): local=launch args+init scripts, CDP/provider=init scripts
-p, --provider <name> Browser provider: ios, browserbase, kernel, browseruse
--device <name> iOS device name (e.g., "iPhone 15 Pro")
--json JSON output
@@ -2108,7 +2126,7 @@ Options:
--color-scheme <scheme> Color scheme: dark, light, no-preference (or AGENT_BROWSER_COLOR_SCHEME)
--session-name <name> Auto-save/restore session state (cookies, localStorage)
--config <path> Use a custom config file (or AGENT_BROWSER_CONFIG env)
--debug Debug output
--debug Debug output (includes stealth connection type + capabilities)
--version, -V Show version
Configuration:
@@ -2147,6 +2165,7 @@ Environment:
AGENT_BROWSER_PROVIDER Browser provider (ios, browserbase, kernel, browseruse)
AGENT_BROWSER_AUTO_CONNECT Auto-discover and connect to running Chrome
AGENT_BROWSER_ALLOW_FILE_ACCESS Allow file:// URLs to access local files
AGENT_BROWSER_STEALTH Stealth mode (default: on, set to 0/false to disable)
AGENT_BROWSER_COLOR_SCHEME Color scheme preference (dark, light, no-preference)
AGENT_BROWSER_DEFAULT_TIMEOUT Default Playwright timeout in ms (default: 25000)
AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name
@@ -2232,10 +2251,7 @@ fn print_screenshot_diff(data: &serde_json::Map<String, serde_json::Value>) {
.get("mismatchPercentage")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let is_match = data
.get("match")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let is_match = data.get("match").and_then(|v| v.as_bool()).unwrap_or(false);
let dim_mismatch = data
.get("dimensionMismatch")
.and_then(|v| v.as_bool())
@@ -2246,7 +2262,10 @@ fn print_screenshot_diff(data: &serde_json::Map<String, serde_json::Value>) {
color::error_indicator()
);
} else if is_match {
println!("{} Images match (0% difference)", color::success_indicator());
println!(
"{} Images match (0% difference)",
color::success_indicator()
);
} else {
println!(
"{} {:.2}% pixels differ",
@@ -2257,7 +2276,10 @@ fn print_screenshot_diff(data: &serde_json::Map<String, serde_json::Value>) {
if let Some(diff_path) = data.get("diffPath").and_then(|v| v.as_str()) {
println!(" Diff image: {}", color::green(diff_path));
}
let total = data.get("totalPixels").and_then(|v| v.as_i64()).unwrap_or(0);
let total = data
.get("totalPixels")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let different = data
.get("differentPixels")
.and_then(|v| v.as_i64())