Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31ef0d7e6a | ||
|
|
42560b56fc | ||
|
|
0c7534d9b2 | ||
|
|
ad4fb14ed9 | ||
|
|
a976287f03 | ||
|
|
649fa4ce94 | ||
|
|
3ac69e822a | ||
|
|
d7a0ed85f9 | ||
|
|
9eaa5495ae | ||
|
|
68734fcb36 | ||
|
|
4b33dbadb4 | ||
|
|
fc73ee6c90 |
@@ -225,7 +225,7 @@ When connected to your real Chrome, we inject **zero** JavaScript patches. Your
|
||||
|
||||
`0% stealth` on CreepJS is the key number: because the connect path patches **nothing**, there is no override for a lie-detector to catch. (Dashboards that read `navigator.languages` order or IP geolocation may show a soft "navigator"/"location" flag — that tracks *your real Chrome's* language list and network, not an automation tell.)
|
||||
|
||||
When using `--launch` mode (standalone browser), a full suite of stealth patches is applied instead, and it still passes the suite above.
|
||||
When using `--launch` mode (standalone browser), a full suite of stealth patches is applied instead, and it passes the suite above — with one caveat: CreepJS reports **~20% stealth** because the srcdoc-iframe `contentWindow` patch trips its `hasIframeProxy` probe (the proxy that hides automation is itself a tell). Everything else is clean (`0% headless`, sannysoft/browserscan green, Cloudflare passed). Set **`AGENT_BROWSER_DISABLE_IFRAME_PROXY=1`** to drop that patch for a clean **0% stealth** (trades the niche srcdoc-iframe masking). The **extension-connect path** (your real Chrome) injects zero JS and is unaffected — it's the genuine 0% path.
|
||||
|
||||
### Human-like input (behavioural stealth)
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -45,7 +45,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-browser-stealth"
|
||||
version = "0.27.0-fork.44"
|
||||
version = "0.27.0-fork.49"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-browser-stealth"
|
||||
version = "0.27.0-fork.44"
|
||||
version = "0.27.0-fork.49"
|
||||
edition = "2021"
|
||||
description = "Fast browser automation CLI for AI agents"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -418,6 +418,33 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
||||
})?;
|
||||
Ok(json!({ "id": id, "action": "type", "selector": sel, "text": rest[1..].join(" ") }))
|
||||
}
|
||||
"pick" => {
|
||||
// pick <selector|@ref> --option "<text>" — atomic combobox select:
|
||||
// open the control, wait for options (incl. portal menus), match by
|
||||
// text, fire the right event sequence, verify. Covers native <select>,
|
||||
// ARIA combobox/listbox, and react-select.
|
||||
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "pick".to_string(),
|
||||
usage: "pick <selector> --option \"<text>\"",
|
||||
})?;
|
||||
let opt_pos = rest.iter().position(|a| *a == "--option" || *a == "-o");
|
||||
let option = match opt_pos {
|
||||
Some(p) => rest[p + 1..].join(" "),
|
||||
None => {
|
||||
return Err(ParseError::MissingArguments {
|
||||
context: "pick".to_string(),
|
||||
usage: "pick <selector> --option \"<text>\"",
|
||||
})
|
||||
}
|
||||
};
|
||||
if option.is_empty() {
|
||||
return Err(ParseError::MissingArguments {
|
||||
context: "pick".to_string(),
|
||||
usage: "pick <selector> --option \"<text>\"",
|
||||
});
|
||||
}
|
||||
Ok(json!({ "id": id, "action": "pick", "selector": sel, "option": option }))
|
||||
}
|
||||
"hover" => {
|
||||
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "hover".to_string(),
|
||||
@@ -904,6 +931,15 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
||||
Ok(json!({ "id": id, "action": "evaluate", "script": script }))
|
||||
}
|
||||
|
||||
// === Stealth self-check ===
|
||||
"stealth" => {
|
||||
// `stealth [status]` — local stealth self-check: mode, live probes
|
||||
// (navigator.webdriver, window.chrome, plugins, UA), and the list of
|
||||
// active overrides. --json for a stable machine-readable shape.
|
||||
// (Distinct from `doctor`, which checks install/env/Chrome health.)
|
||||
Ok(json!({ "id": id, "action": "stealth_status" }))
|
||||
}
|
||||
|
||||
// === Close ===
|
||||
"close" | "quit" | "exit" => Ok(json!({ "id": id, "action": "close" })),
|
||||
|
||||
|
||||
@@ -1316,6 +1316,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
||||
"content" => handle_content(state).await,
|
||||
"evaluate" => handle_evaluate(cmd, state).await,
|
||||
"close" => handle_close(state).await,
|
||||
"stealth_status" => handle_stealth_status(state).await,
|
||||
"snapshot" => handle_snapshot(cmd, state).await,
|
||||
"screenshot" => handle_screenshot(cmd, state).await,
|
||||
"click" => handle_click(cmd, state).await,
|
||||
@@ -1323,6 +1324,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
||||
"fill" => handle_fill(cmd, state).await,
|
||||
"type" => handle_type(cmd, state).await,
|
||||
"press" => handle_press(cmd, state).await,
|
||||
"pick" => handle_pick(cmd, state).await,
|
||||
"hover" => handle_hover(cmd, state).await,
|
||||
"scroll" => handle_scroll(cmd, state).await,
|
||||
"select" => handle_select(cmd, state).await,
|
||||
@@ -2679,6 +2681,89 @@ async fn handle_evaluate(cmd: &Value, state: &DaemonState) -> Result<Value, Stri
|
||||
Ok(json!({ "result": result, "origin": url }))
|
||||
}
|
||||
|
||||
/// Local stealth self-check: reports the active mode, live fingerprint probes,
|
||||
/// and the list of applied overrides — so an agent (or human) can confirm
|
||||
/// stealth is working without driving an external detector, and audit exactly
|
||||
/// what's patched on this path (issue #5).
|
||||
async fn handle_stealth_status(state: &DaemonState) -> Result<Value, String> {
|
||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||
let connect = mgr.is_cdp_connection();
|
||||
|
||||
let probe_js = r#"(() => {
|
||||
const ua = navigator.userAgent || '';
|
||||
return {
|
||||
webdriver: navigator.webdriver === true,
|
||||
hasWindowChrome: typeof window.chrome === 'object' && window.chrome !== null,
|
||||
plugins: navigator.plugins ? navigator.plugins.length : 0,
|
||||
languages: navigator.languages || [],
|
||||
platform: navigator.platform || '',
|
||||
headlessUA: /Headless/i.test(ua),
|
||||
};
|
||||
})()"#;
|
||||
let p = mgr.evaluate(probe_js, None).await.unwrap_or(Value::Null);
|
||||
let webdriver = p.get("webdriver").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
let has_chrome = p
|
||||
.get("hasWindowChrome")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let plugins = p.get("plugins").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let headless_ua = p
|
||||
.get("headlessUA")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let checks = json!([
|
||||
{ "name": "navigator.webdriver is false", "pass": !webdriver },
|
||||
{ "name": "window.chrome present", "pass": has_chrome },
|
||||
{ "name": "navigator.plugins non-empty", "pass": plugins > 0, "value": plugins },
|
||||
{ "name": "userAgent has no 'Headless'", "pass": !headless_ua },
|
||||
]);
|
||||
let ok = !webdriver && has_chrome && plugins > 0 && !headless_ua;
|
||||
|
||||
let overrides = if connect {
|
||||
json!([
|
||||
"navigator.webdriver=false via Emulation.setAutomationOverride (native CDP — no JS lie)",
|
||||
"Runtime.enable OFF unless console/error capture is opted in (no rebrowser runtime leak)",
|
||||
"zero JS patches injected — the browser's real fingerprint is used as-is",
|
||||
])
|
||||
} else {
|
||||
let iframe_proxy =
|
||||
std::env::var("AGENT_BROWSER_DISABLE_IFRAME_PROXY").as_deref() != Ok("1");
|
||||
json!([
|
||||
"navigator.webdriver removed; navigator.languages/locale normalized",
|
||||
"window.chrome / chrome.runtime shimmed; navigator.platform fixed",
|
||||
"WebGL vendor/renderer, plugins, permissions normalized",
|
||||
format!(
|
||||
"srcdoc-iframe contentWindow proxy: {} (CreepJS hasIframeProxy)",
|
||||
if iframe_proxy {
|
||||
"ON — set AGENT_BROWSER_DISABLE_IFRAME_PROXY=1 for clean 0%"
|
||||
} else {
|
||||
"off"
|
||||
}
|
||||
),
|
||||
format!(
|
||||
"canvas/audio noise: {} (AGENT_BROWSER_HIDE_CANVAS)",
|
||||
if std::env::var("AGENT_BROWSER_HIDE_CANVAS").as_deref() == Ok("1") {
|
||||
"on"
|
||||
} else {
|
||||
"off (opt-in)"
|
||||
}
|
||||
),
|
||||
"Chrome flags: --disable-blink-features=AutomationControlled, ANGLE GL",
|
||||
])
|
||||
};
|
||||
|
||||
Ok(json!({
|
||||
"stealthStatus": {
|
||||
"mode": if connect { "connect (your real Chrome — strongest)" } else { "launch (standalone)" },
|
||||
"ok": ok,
|
||||
"checks": checks,
|
||||
"overrides": overrides,
|
||||
"probe": p,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_close(state: &mut DaemonState) -> Result<Value, String> {
|
||||
if let Some(ref mgr) = state.browser {
|
||||
if let Some(ref session_name) = state.session_name {
|
||||
@@ -3072,6 +3157,103 @@ async fn handle_type(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
|
||||
Ok(json!({ "typed": text }))
|
||||
}
|
||||
|
||||
/// Atomic combobox select: `pick <selector> --option "<text>"`. Opens the control
|
||||
/// (so a portal-rendered menu mounts), polls for the option by visible text, then
|
||||
/// fires the full pointer/mouse event sequence on it — covering native `<select>`,
|
||||
/// ARIA combobox/listbox, and react-select, which a bare `click`+`press Enter`
|
||||
/// can't do reliably. Runs as one in-page async routine so the open→render→pick
|
||||
/// dance happens without round-trips that let the menu collapse between commands.
|
||||
async fn handle_pick(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||
let session_id = mgr.active_session_id()?.to_string();
|
||||
let selector = cmd
|
||||
.get("selector")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing 'selector' parameter")?;
|
||||
let option = cmd
|
||||
.get("option")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing 'option' parameter")?;
|
||||
|
||||
let (object_id, effective_session_id) = super::element::resolve_element_object_id(
|
||||
&mgr.client,
|
||||
&session_id,
|
||||
&state.ref_map,
|
||||
selector,
|
||||
&state.iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let func = format!(
|
||||
r#"async function() {{
|
||||
const want = {opt};
|
||||
const norm = s => (s || '').replace(/\s+/g, ' ').trim();
|
||||
const matches = el => norm(el.textContent).toLowerCase().includes(want.toLowerCase());
|
||||
const el = this;
|
||||
const fire = (n, t) => n.dispatchEvent(new MouseEvent(t, {{ bubbles: true, cancelable: true, view: window }}));
|
||||
|
||||
// Native <select>: set the matching option and dispatch input/change.
|
||||
if (el.tagName === 'SELECT') {{
|
||||
const opt = [...el.options].find(matches);
|
||||
if (!opt) return {{ ok: false, error: 'no <option> matched ' + JSON.stringify(want) }};
|
||||
el.value = opt.value;
|
||||
el.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
||||
el.dispatchEvent(new Event('change', {{ bubbles: true }}));
|
||||
return {{ ok: true, picked: norm(opt.textContent), value: el.value, kind: 'select' }};
|
||||
}}
|
||||
|
||||
// Custom widget: open it.
|
||||
(el.focus && el.focus());
|
||||
['pointerdown', 'mousedown', 'mouseup', 'click'].forEach(t => fire(el, t));
|
||||
|
||||
// Poll for the option to render anywhere in the document (portals
|
||||
// mount the menu outside the trigger), then click it.
|
||||
const sel = '[role=option], [role=listbox] [role=option], li[role=option], [class*=option], [class*=item]';
|
||||
const find = () => [...document.querySelectorAll(sel)].find(o => o.offsetParent !== null && matches(o));
|
||||
const deadline = Date.now() + 2500;
|
||||
let opt = find();
|
||||
while (!opt && Date.now() < deadline) {{
|
||||
await new Promise(r => setTimeout(r, 80));
|
||||
opt = find();
|
||||
}}
|
||||
if (!opt) return {{ ok: false, error: 'option ' + JSON.stringify(want) + ' did not appear after opening the control' }};
|
||||
(opt.scrollIntoView && opt.scrollIntoView({{ block: 'center' }}));
|
||||
['pointermove', 'pointerover', 'mouseover', 'pointerdown', 'mousedown', 'mouseup', 'click'].forEach(t => fire(opt, t));
|
||||
return {{ ok: true, picked: norm(opt.textContent), kind: 'custom' }};
|
||||
}}"#,
|
||||
opt = serde_json::to_string(option).unwrap_or_default(),
|
||||
);
|
||||
|
||||
let result: super::cdp::types::EvaluateResult = mgr
|
||||
.client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&super::cdp::types::CallFunctionOnParams {
|
||||
function_declaration: func,
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(true),
|
||||
},
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(ref ex) = result.exception_details {
|
||||
return Err(format!("pick failed: {}", ex.text));
|
||||
}
|
||||
let val = result.result.value.unwrap_or(Value::Null);
|
||||
if val.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
Ok(json!({ "picked": val.get("picked"), "selector": selector }))
|
||||
} else {
|
||||
Err(val
|
||||
.get("error")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("pick failed")
|
||||
.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_press(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||
let session_id = mgr.active_session_id()?.to_string();
|
||||
|
||||
@@ -315,6 +315,13 @@ pub struct BrowserManager {
|
||||
/// browser after it ends. Only ever holds tabs we created — never the user's
|
||||
/// existing tabs or other sessions' tabs — so closing them is always safe.
|
||||
created_targets: HashSet<String>,
|
||||
/// The session's *intended* active tab, pinned by stable target_id rather
|
||||
/// than the fragile `active_page_index`. Set on every explicit open / tab new
|
||||
/// / tab switch. `active_session_id` resolves through this so a foreign tab
|
||||
/// opening (passive discovery), a tab closing, or list reordering can't drift
|
||||
/// the session's commands onto the wrong page — the wrong-origin-fetch hazard
|
||||
/// in the dogfood reports. Falls back to the index if the pinned tab is gone.
|
||||
active_target_id: Option<String>,
|
||||
next_tab_id: u32,
|
||||
/// Whether to enable the CDP `Runtime` domain (console / error / exception capture).
|
||||
/// OFF by default for stealth: a live `Runtime.enable` is a detectable CDP signal
|
||||
@@ -440,6 +447,7 @@ impl BrowserManager {
|
||||
ignore_https_errors,
|
||||
visited_origins: HashSet::new(),
|
||||
created_targets: HashSet::new(),
|
||||
active_target_id: None,
|
||||
next_tab_id: 1,
|
||||
capture_console: console_capture_enabled(),
|
||||
};
|
||||
@@ -531,6 +539,7 @@ impl BrowserManager {
|
||||
ignore_https_errors: false,
|
||||
visited_origins: HashSet::new(),
|
||||
created_targets: HashSet::new(),
|
||||
active_target_id: None,
|
||||
next_tab_id: 1,
|
||||
capture_console: console_capture_enabled(),
|
||||
};
|
||||
@@ -547,6 +556,7 @@ impl BrowserManager {
|
||||
target_type: "page".to_string(),
|
||||
});
|
||||
manager.active_page_index = 0;
|
||||
manager.pin_active_target();
|
||||
manager.enable_domains_direct().await?;
|
||||
} else {
|
||||
manager.discover_and_attach_targets().await?;
|
||||
@@ -621,6 +631,7 @@ impl BrowserManager {
|
||||
target_type: "page".to_string(),
|
||||
});
|
||||
self.active_page_index = 0;
|
||||
self.pin_active_target();
|
||||
self.enable_domains(&attach_result.session_id).await?;
|
||||
} else {
|
||||
for target in &page_targets {
|
||||
@@ -650,6 +661,7 @@ impl BrowserManager {
|
||||
}
|
||||
|
||||
self.active_page_index = 0;
|
||||
self.pin_active_target();
|
||||
let session_id = self.pages[0].session_id.clone();
|
||||
self.enable_domains(&session_id).await?;
|
||||
}
|
||||
@@ -736,9 +748,31 @@ impl BrowserManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Index of the session's active page, resolved through the pinned
|
||||
/// `active_target_id` (stable across reorder/removal/passive discovery) and
|
||||
/// falling back to `active_page_index` when nothing is pinned or the pin is
|
||||
/// gone. This is what keeps commands on the tab the agent actually opened.
|
||||
fn resolved_active_index(&self) -> usize {
|
||||
if let Some(tid) = &self.active_target_id {
|
||||
if let Some(i) = self.pages.iter().position(|p| &p.target_id == tid) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
self.active_page_index
|
||||
}
|
||||
|
||||
/// Pin the current active page by target_id so later commands stick to it.
|
||||
/// Call after any explicit open / tab new / tab switch.
|
||||
fn pin_active_target(&mut self) {
|
||||
self.active_target_id = self
|
||||
.pages
|
||||
.get(self.active_page_index)
|
||||
.map(|p| p.target_id.clone());
|
||||
}
|
||||
|
||||
pub fn active_session_id(&self) -> Result<&str, String> {
|
||||
self.pages
|
||||
.get(self.active_page_index)
|
||||
.get(self.resolved_active_index())
|
||||
.map(|p| p.session_id.as_str())
|
||||
.ok_or_else(|| "No active page".to_string())
|
||||
}
|
||||
@@ -991,7 +1025,7 @@ impl BrowserManager {
|
||||
|
||||
pub fn active_target_id(&self) -> Result<&str, String> {
|
||||
self.pages
|
||||
.get(self.active_page_index)
|
||||
.get(self.resolved_active_index())
|
||||
.map(|p| p.target_id.as_str())
|
||||
.ok_or_else(|| "No active page".to_string())
|
||||
}
|
||||
@@ -1219,6 +1253,7 @@ impl BrowserManager {
|
||||
target_type: "page".to_string(),
|
||||
});
|
||||
self.active_page_index = index;
|
||||
self.pin_active_target();
|
||||
|
||||
Ok(json!({
|
||||
"tabId": format_tab_id(tab_id),
|
||||
@@ -1238,6 +1273,7 @@ impl BrowserManager {
|
||||
}
|
||||
|
||||
self.active_page_index = index;
|
||||
self.pin_active_target();
|
||||
let session_id = self.pages[index].session_id.clone();
|
||||
self.enable_domains(&session_id).await?;
|
||||
|
||||
@@ -1581,6 +1617,7 @@ impl BrowserManager {
|
||||
let index = self.pages.len();
|
||||
self.pages.push(page);
|
||||
self.active_page_index = index;
|
||||
self.pin_active_target();
|
||||
}
|
||||
|
||||
/// Add a passively-discovered page WITHOUT changing the active tab.
|
||||
@@ -1783,6 +1820,7 @@ async fn initialize_lightpanda_manager(
|
||||
ignore_https_errors: false,
|
||||
visited_origins: HashSet::new(),
|
||||
created_targets: HashSet::new(),
|
||||
active_target_id: None,
|
||||
next_tab_id: 1,
|
||||
capture_console: console_capture_enabled(),
|
||||
};
|
||||
|
||||
@@ -556,8 +556,12 @@ async fn verify_ref_identity(
|
||||
Err(format!(
|
||||
"Ref {} no longer matches its snapshot. Was [{} \"{}\"], now [{} \"{}\"].\n\
|
||||
The DOM mutated between snapshot and interaction (typical with React/Vue \
|
||||
reusing nodes during re-render). Take a fresh snapshot, then re-target.\n\
|
||||
To bypass this guard set AGENT_BROWSER_VERIFY_REF=0.",
|
||||
reusing nodes during re-render). Fix: take a fresh `snapshot` and re-target \
|
||||
with the new ref. For SPAs where refs churn every interaction, drive the \
|
||||
element directly with `eval` (e.g. `eval \"document.querySelector(...).click()\"`), \
|
||||
which doesn't depend on refs.\n\
|
||||
(Last resort: AGENT_BROWSER_VERIFY_REF=0 disables this safety check — only \
|
||||
if you accept clicks may land on a re-rendered/wrong node.)",
|
||||
ref_id, expected_role, expected_name, actual_role, actual_name,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1305,6 +1305,39 @@ fn render_tree(
|
||||
}
|
||||
}
|
||||
|
||||
/// True if a snapshot line names an interactive ARIA role. Compaction keeps
|
||||
/// these even without a `ref=`/`": "` marker, so a clickable control never gets
|
||||
/// dropped from `-c` output (the dogfood reports saw a button present in the full
|
||||
/// snapshot vanish from compact, leaving the agent clicking an empty ref).
|
||||
fn is_interactive_line(line: &str) -> bool {
|
||||
const ROLES: &[&str] = &[
|
||||
"button",
|
||||
"link",
|
||||
"textbox",
|
||||
"checkbox",
|
||||
"radio",
|
||||
"combobox",
|
||||
"listbox",
|
||||
"menuitem",
|
||||
"menuitemcheckbox",
|
||||
"menuitemradio",
|
||||
"option",
|
||||
"switch",
|
||||
"slider",
|
||||
"spinbutton",
|
||||
"searchbox",
|
||||
"tab ",
|
||||
"clickable",
|
||||
"focusable",
|
||||
"editable",
|
||||
];
|
||||
let t = line.trim_start();
|
||||
// Lines look like `- button "Label" [ref=e1]`; match the role token after the
|
||||
// leading "- " marker.
|
||||
let t = t.strip_prefix("- ").unwrap_or(t);
|
||||
ROLES.iter().any(|r| t.starts_with(r))
|
||||
}
|
||||
|
||||
fn compact_tree(tree: &str, interactive: bool) -> String {
|
||||
let lines: Vec<&str> = tree.lines().collect();
|
||||
if lines.is_empty() {
|
||||
@@ -1314,7 +1347,7 @@ fn compact_tree(tree: &str, interactive: bool) -> String {
|
||||
let mut keep = vec![false; lines.len()];
|
||||
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if line.contains("ref=") || line.contains(": ") {
|
||||
if line.contains("ref=") || line.contains(": ") || is_interactive_line(line) {
|
||||
keep[i] = true;
|
||||
// Mark ancestors
|
||||
let my_indent = count_indent(line);
|
||||
|
||||
@@ -51,18 +51,19 @@ pub fn build_stealth_script(mode: StealthMode, locale: Option<&str>) -> String {
|
||||
vec![locale, base_lang]
|
||||
};
|
||||
let config_line = format!(
|
||||
r#"const __abStealth = {{ locale: "{}", languages: {}, allowWebGLContextFallback: false, hideCanvas: {}, canvasSeed: {} }};"#,
|
||||
r#"const __abStealth = {{ locale: "{}", languages: {}, allowWebGLContextFallback: false, hideCanvas: {}, canvasSeed: {}, disableIframeProxy: {} }};"#,
|
||||
locale,
|
||||
serde_json::to_string(&languages).unwrap_or_else(|_| r#"["en-US","en"]"#.to_string()),
|
||||
hide_canvas_enabled(),
|
||||
canvas_noise_seed(),
|
||||
disable_iframe_proxy_enabled(),
|
||||
);
|
||||
|
||||
// NB: this prefix MUST match the first line of stealth_scripts.js verbatim,
|
||||
// otherwise the fallback below prepends a SECOND `const __abStealth`
|
||||
// declaration and the whole script dies with a redeclaration SyntaxError.
|
||||
if let Some(rest) = STEALTH_SCRIPTS_RAW.strip_prefix(
|
||||
r#"const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false, hideCanvas: false, canvasSeed: 0 };"#,
|
||||
r#"const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false, hideCanvas: false, canvasSeed: 0, disableIframeProxy: false };"#,
|
||||
) {
|
||||
format!("{}{}", config_line, rest)
|
||||
} else {
|
||||
@@ -81,6 +82,18 @@ fn hide_canvas_enabled() -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether to DROP the srcdoc-iframe `contentWindow` Proxy patch (FullLaunch).
|
||||
/// That patch masks automation in srcdoc iframes, but the JS `Proxy` is itself a
|
||||
/// fingerprintable tell (CreepJS `hasIframeProxy` → ~20% stealth). Off by default
|
||||
/// (keep the patch); `AGENT_BROWSER_DISABLE_IFRAME_PROXY=1` drops it for a clean
|
||||
/// 0% CreepJS at the cost of that niche srcdoc-iframe masking.
|
||||
fn disable_iframe_proxy_enabled() -> bool {
|
||||
std::env::var("AGENT_BROWSER_DISABLE_IFRAME_PROXY")
|
||||
.ok()
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// A per-process seed so canvas/audio noise is STABLE within a session (a real
|
||||
/// device returns the same hash on repeated reads) but differs from the
|
||||
/// headless-stable default. 0 is avoided so the JS can treat it as "unset".
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false, hideCanvas: false, canvasSeed: 0 };
|
||||
const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false, hideCanvas: false, canvasSeed: 0, disableIframeProxy: false };
|
||||
// Redefine a navigator property on its PROTOTYPE (Navigator / WorkerNavigator),
|
||||
// the way real Chrome exposes these — as prototype getters, NOT instance own
|
||||
// properties. Adding an own property to the `navigator` instance is itself a
|
||||
@@ -289,6 +289,10 @@ const __abRedefineNavProto = (name, getterImpl) => {
|
||||
})();
|
||||
(function(){
|
||||
if (typeof document === 'undefined' || typeof document.createElement !== 'function') return;
|
||||
// The srcdoc-iframe contentWindow Proxy below is itself a fingerprintable tell
|
||||
// (CreepJS `hasIframeProxy`). Honor the opt-out so callers can trade the niche
|
||||
// srcdoc masking for a clean 0% CreepJS fingerprint.
|
||||
if (typeof __abStealth !== 'undefined' && __abStealth.disableIframeProxy) return;
|
||||
const nativeCreateElement = document.createElement.bind(document);
|
||||
const nativeSrcdocDescriptor =
|
||||
typeof HTMLIFrameElement !== 'undefined'
|
||||
|
||||
+48
-1
@@ -352,6 +352,39 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
println!("{}", enabled);
|
||||
return;
|
||||
}
|
||||
// Stealth self-check (`stealth status` / `doctor`)
|
||||
if let Some(s) = data.get("stealthStatus") {
|
||||
let ok = s.get("ok").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let mode = s.get("mode").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
println!(
|
||||
"{} stealth: {} · mode: {}",
|
||||
if ok {
|
||||
color::success_indicator().to_string()
|
||||
} else {
|
||||
color::cyan("•")
|
||||
},
|
||||
if ok {
|
||||
"all checks pass"
|
||||
} else {
|
||||
"some checks need attention"
|
||||
},
|
||||
mode
|
||||
);
|
||||
if let Some(checks) = s.get("checks").and_then(|v| v.as_array()) {
|
||||
for c in checks {
|
||||
let pass = c.get("pass").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let name = c.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
println!(" {} {}", if pass { "✓" } else { "✗" }, name);
|
||||
}
|
||||
}
|
||||
if let Some(ovs) = s.get("overrides").and_then(|v| v.as_array()) {
|
||||
println!(" applied overrides:");
|
||||
for o in ovs.iter().filter_map(|v| v.as_str()) {
|
||||
println!(" {}", color::dim(&format!("· {o}")));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(checked) = data.get("checked").and_then(|v| v.as_bool()) {
|
||||
println!("{}", checked);
|
||||
return;
|
||||
@@ -761,7 +794,12 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
color::green(path)
|
||||
);
|
||||
if let Some(annotations) = data.get("annotations").and_then(|v| v.as_array()) {
|
||||
for ann in annotations {
|
||||
// Cap the printed legend on dense pages (it can be
|
||||
// hundreds of lines and flood the terminal). The image
|
||||
// still shows every marker; --json returns the full list.
|
||||
const LEGEND_CAP: usize = 40;
|
||||
let total = annotations.len();
|
||||
for ann in annotations.iter().take(LEGEND_CAP) {
|
||||
let num = ann.get("number").and_then(|n| n.as_u64()).unwrap_or(0);
|
||||
let ref_id = ann.get("ref").and_then(|r| r.as_str()).unwrap_or("");
|
||||
let role = ann.get("role").and_then(|r| r.as_str()).unwrap_or("");
|
||||
@@ -783,6 +821,15 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
);
|
||||
}
|
||||
}
|
||||
if total > LEGEND_CAP {
|
||||
println!(
|
||||
" {}",
|
||||
color::dim(&format!(
|
||||
"… and {} more markers (shown in the image; --json for the full list)",
|
||||
total - LEGEND_CAP
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
"pdf" => println!(
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agent-browser-stealth",
|
||||
"version": "0.27.0-fork.44",
|
||||
"version": "0.27.0-fork.49",
|
||||
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.1.3",
|
||||
|
||||
@@ -219,8 +219,16 @@ agent-browser press Enter # press a key at current focus
|
||||
agent-browser press Control+a # key combination
|
||||
agent-browser check @e3 # check checkbox
|
||||
agent-browser uncheck @e3 # uncheck
|
||||
agent-browser select @e4 "option-value" # select dropdown option
|
||||
agent-browser select @e4 "option-value" # native <select> only
|
||||
agent-browser select @e4 "a" "b" # select multiple
|
||||
agent-browser pick @e4 --option "Europe" # ANY combobox (react-select / ARIA /
|
||||
# native): opens it, waits for the menu
|
||||
# (incl. portal-rendered), matches by
|
||||
# visible text, fires the right events,
|
||||
# and ERRORS if the option never shows
|
||||
# (no silent no-op). Use this for custom
|
||||
# dropdowns where `select` returns ✓ but
|
||||
# changes nothing.
|
||||
agent-browser upload @e5 file1.pdf # upload file(s)
|
||||
agent-browser scroll down 500 # scroll page (up/down/left/right)
|
||||
agent-browser scrollintoview @e1 # scroll element into view
|
||||
@@ -443,6 +451,14 @@ agent-browser --session b fill @e1 "bob@test.com"
|
||||
`AGENT_BROWSER_SESSION=myapp` sets the default session for the current
|
||||
shell.
|
||||
|
||||
**Concurrent agents MUST each use a distinct `--session <name>`.** Within one
|
||||
session, commands are pinned to the tab you opened (by target_id, so a foreign
|
||||
tab can't drift your `eval`/`screenshot`). But two agents sharing the *same*
|
||||
session (e.g. both on the bare default) share one daemon and one active tab —
|
||||
they will clobber each other's tab/eval context. A unique session per agent gives
|
||||
each its own isolated tab group (own cookies, tabs, pin) on the one real Chrome,
|
||||
with no cross-talk.
|
||||
|
||||
### Mock network requests
|
||||
|
||||
```bash
|
||||
@@ -512,6 +528,10 @@ agent-browser doctor # full diagnosis (env, Chrome, daemons,
|
||||
agent-browser doctor --offline --quick # fast, local-only
|
||||
agent-browser doctor --fix # also run destructive repairs (reinstall Chrome, purge old state, ...)
|
||||
agent-browser doctor --json # structured output for programmatic consumption
|
||||
agent-browser stealth status # stealth self-check: mode + live probes
|
||||
agent-browser stealth status --json # (webdriver/chrome/plugins/UA) + applied
|
||||
# overrides. Gate a sensitive flow on this
|
||||
# instead of driving an external detector.
|
||||
```
|
||||
|
||||
`doctor` auto-cleans stale socket/pid/version sidecar files on every run.
|
||||
|
||||
Reference in New Issue
Block a user