Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c26afbaba6 | ||
|
|
ffb386e3af | ||
|
|
947d150561 | ||
|
|
06a29251a2 | ||
|
|
0eacec9b9f | ||
|
|
7159012173 | ||
|
|
1b3d41e579 | ||
|
|
dbf272ced7 | ||
|
|
64140879d5 | ||
|
|
d3bfd76c96 | ||
|
|
47dfe760be | ||
|
|
0db6604105 | ||
|
|
007fd1b27f | ||
|
|
3d1132af90 | ||
|
|
90ba44cd38 | ||
|
|
52f8ead0f2 |
Generated
+1
-1
@@ -45,7 +45,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-browser-stealth"
|
||||
version = "0.27.0-fork.2"
|
||||
version = "0.27.0-fork.8"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-browser-stealth"
|
||||
version = "0.27.0-fork.2"
|
||||
version = "0.27.0-fork.8"
|
||||
edition = "2021"
|
||||
description = "Fast browser automation CLI for AI agents"
|
||||
license = "Apache-2.0"
|
||||
|
||||
+73
-3
@@ -614,17 +614,44 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
||||
return Ok(cmd);
|
||||
}
|
||||
|
||||
// --gone / --hidden: wait for an element to leave the DOM or
|
||||
// become invisible. Useful after a click that's supposed to
|
||||
// close a dialog, so the next command fails fast instead of
|
||||
// racing into a half-rendered UI.
|
||||
let state_override = if rest.iter().any(|&s| s == "--gone" || s == "--detached") {
|
||||
Some("detached")
|
||||
} else if rest.iter().any(|&s| s == "--hidden") {
|
||||
Some("hidden")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Default: selector or timeout
|
||||
if let Some(arg) = rest.first() {
|
||||
// First non-flag positional is selector or numeric timeout
|
||||
let positional = rest.iter().find(|&&s| !s.starts_with("--"));
|
||||
let timeout_ms = rest
|
||||
.iter()
|
||||
.position(|&s| s == "--timeout")
|
||||
.and_then(|idx| rest.get(idx + 1))
|
||||
.and_then(|s| s.parse::<u64>().ok());
|
||||
|
||||
if let Some(arg) = positional {
|
||||
if let Ok(timeout) = arg.parse::<u64>() {
|
||||
Ok(json!({ "id": id, "action": "wait", "timeout": timeout }))
|
||||
} else {
|
||||
Ok(json!({ "id": id, "action": "wait", "selector": arg }))
|
||||
let mut cmd = json!({ "id": id, "action": "wait", "selector": arg });
|
||||
if let Some(state) = state_override {
|
||||
cmd["state"] = json!(state);
|
||||
}
|
||||
if let Some(t) = timeout_ms {
|
||||
cmd["timeout"] = json!(t);
|
||||
}
|
||||
Ok(cmd)
|
||||
}
|
||||
} else {
|
||||
Err(ParseError::MissingArguments {
|
||||
context: "wait".to_string(),
|
||||
usage: "wait <selector|ms|--url|--load|--fn|--text>",
|
||||
usage: "wait <selector|ms> [--gone|--hidden] [--timeout ms]",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5185,4 +5212,47 @@ mod tests {
|
||||
let cmd = parse_command(&args("find role button"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["subaction"], "click");
|
||||
}
|
||||
|
||||
// === wait --gone / --hidden ===
|
||||
|
||||
#[test]
|
||||
fn test_wait_selector_default_visible() {
|
||||
let cmd = parse_command(&args("wait .toast"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["action"], "wait");
|
||||
assert_eq!(cmd["selector"], ".toast");
|
||||
assert!(cmd.get("state").is_none(), "default state stays implicit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wait_selector_gone_sets_detached_state() {
|
||||
let cmd = parse_command(&args("wait .toast --gone"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["selector"], ".toast");
|
||||
assert_eq!(cmd["state"], "detached");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wait_selector_hidden_sets_hidden_state() {
|
||||
let cmd = parse_command(&args("wait .toast --hidden"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["state"], "hidden");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wait_gone_with_timeout() {
|
||||
let cmd = parse_command(
|
||||
&args("wait .modal --gone --timeout 2000"),
|
||||
&default_flags(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cmd["selector"], ".modal");
|
||||
assert_eq!(cmd["state"], "detached");
|
||||
assert_eq!(cmd["timeout"], 2000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wait_numeric_timeout_still_works() {
|
||||
// `wait 500` keeps meaning "sleep 500ms", not "wait for selector 500"
|
||||
let cmd = parse_command(&args("wait 500"), &default_flags()).unwrap();
|
||||
assert_eq!(cmd["timeout"], 500);
|
||||
assert!(cmd.get("selector").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
+18
-5
@@ -822,11 +822,24 @@ fn main() {
|
||||
.collect();
|
||||
|
||||
if !ignored_flags.is_empty() && !flags.json {
|
||||
eprintln!(
|
||||
"{} {} ignored: daemon already running. Use 'agent-browser close' first to restart with new options.",
|
||||
color::warning_indicator(),
|
||||
ignored_flags.join(", ")
|
||||
);
|
||||
// Special case: --headed is irrelevant in CDP-attach mode
|
||||
// (your existing Chrome is always already visible). The
|
||||
// "agent-browser close + reopen" advice doesn't help because
|
||||
// the new daemon will attach right back to the same Chrome.
|
||||
// Don't suggest a useless workaround.
|
||||
if ignored_flags == ["--headed"] {
|
||||
eprintln!(
|
||||
"{} --headed has no effect when attached to your running Chrome (it's already visible). \
|
||||
Pass --launch to spawn a separate browser if you need to control headedness.",
|
||||
color::warning_indicator(),
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"{} {} ignored: daemon already running. Use 'agent-browser close' first to restart with new options.",
|
||||
color::warning_indicator(),
|
||||
ignored_flags.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1510,6 +1510,28 @@ async fn connect_auto_with_fresh_tab() -> Result<BrowserManager, String> {
|
||||
.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
|
||||
// alive, websocket dead) would let `connect_auto` and `tab_new` succeed,
|
||||
// we'd return Ok, the next user command would silently no-op, and
|
||||
// `agent-browser open URL` would exit 0 with the browser still on
|
||||
// about:blank. Failing here lets the caller surface the real error.
|
||||
if let Err(e) = mgr
|
||||
.client
|
||||
.send_command("Runtime.evaluate", Some(serde_json::json!({
|
||||
"expression": "1",
|
||||
"returnByValue": true,
|
||||
})), Some(&session_id))
|
||||
.await
|
||||
{
|
||||
return Err(format!(
|
||||
"CDP session is unresponsive after attaching ({}). \
|
||||
The browser may have lost its DevTools connection. \
|
||||
Try: agent-browser close, then re-run.",
|
||||
e
|
||||
));
|
||||
}
|
||||
Ok(mgr)
|
||||
}
|
||||
|
||||
@@ -1585,8 +1607,9 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
|
||||
To let agent-browser work with your existing Chrome (recommended):\n\
|
||||
{}\n\n\
|
||||
Or start a standalone browser with: agent-browser --launch open <url>\n\n\
|
||||
Tip: On Chrome 144+, you can enable CDP without restarting:\n\
|
||||
Open chrome://inspect/#remote-debugging and toggle it on.",
|
||||
Note: chrome://inspect/#remote-debugging only enables remote *target discovery* — \
|
||||
it does NOT expose the standard CDP HTTP API on /json/version. \
|
||||
A full restart with --remote-debugging-port=<port> is required.",
|
||||
chrome_relaunch_hint(),
|
||||
));
|
||||
}
|
||||
@@ -2113,8 +2136,9 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
To let agent-browser work with your existing Chrome (recommended):\n\
|
||||
{}\n\n\
|
||||
Or start a standalone browser with: agent-browser --launch open <url>\n\n\
|
||||
Tip: On Chrome 144+, you can enable CDP without restarting:\n\
|
||||
Open chrome://inspect/#remote-debugging and toggle it on.",
|
||||
Note: chrome://inspect/#remote-debugging only enables remote *target discovery* — \
|
||||
it does NOT expose the standard CDP HTTP API on /json/version. \
|
||||
A full restart with --remote-debugging-port=<port> is required.",
|
||||
chrome_relaunch_hint(),
|
||||
));
|
||||
}
|
||||
@@ -3105,6 +3129,15 @@ async fn handle_wait(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
|
||||
.get("state")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("visible");
|
||||
// @-ref support: if the selector is `@e12` style, poll the ref map +
|
||||
// accessibility tree instead of `document.querySelector`. This makes
|
||||
// `wait @e8 --gone` a usable "assert modal still mounted" primitive
|
||||
// for SPA flows where the only stable identity is the AX role+name
|
||||
// captured at snapshot time.
|
||||
if selector.starts_with('@') {
|
||||
wait_for_ref(state, selector, state_str, timeout_ms).await?;
|
||||
return Ok(json!({ "waited": "ref", "ref": selector, "state": state_str }));
|
||||
}
|
||||
wait_for_selector(&mgr.client, &session_id, selector, state_str, timeout_ms).await?;
|
||||
return Ok(json!({ "waited": "selector", "selector": selector }));
|
||||
}
|
||||
@@ -3316,6 +3349,49 @@ async fn handle_reload(state: &mut DaemonState) -> Result<Value, String> {
|
||||
// Wait helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Poll-based wait for a ref-identified element. Resolves the @-ref by
|
||||
/// re-running the ref-identity verification each iteration. The supported
|
||||
/// states mirror selector-based waits:
|
||||
///
|
||||
/// - "visible" / "attached" — succeed when the ref resolves to a node
|
||||
/// whose AX role + name still match the snapshot entry
|
||||
/// - "detached" / "hidden" — succeed when the ref no longer matches
|
||||
/// (node removed OR re-textified to something else)
|
||||
///
|
||||
/// Times out with a "ref X did not become {state}" error.
|
||||
async fn wait_for_ref(
|
||||
state: &mut DaemonState,
|
||||
ref_selector: &str,
|
||||
desired_state: &str,
|
||||
timeout_ms: u64,
|
||||
) -> Result<(), String> {
|
||||
let want_present = !matches!(desired_state, "detached" | "hidden");
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
|
||||
loop {
|
||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||
let session_id = mgr.active_session_id()?.to_string();
|
||||
let resolved = super::element::resolve_element_object_id(
|
||||
&mgr.client,
|
||||
&session_id,
|
||||
&state.ref_map,
|
||||
ref_selector,
|
||||
&state.iframe_sessions,
|
||||
)
|
||||
.await;
|
||||
let present = resolved.is_ok();
|
||||
if present == want_present {
|
||||
return Ok(());
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return Err(format!(
|
||||
"Timeout: ref {} did not become {} within {}ms",
|
||||
ref_selector, desired_state, timeout_ms
|
||||
));
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_selector(
|
||||
client: &super::cdp::client::CdpClient,
|
||||
session_id: &str,
|
||||
|
||||
@@ -163,6 +163,30 @@ pub async fn resolve_element_center(
|
||||
|
||||
// Try cached backend_node_id first (fast path)
|
||||
if let Some(backend_node_id) = entry.backend_node_id {
|
||||
// Identity check: React often re-uses the same DOM node when
|
||||
// re-rendering — backendNodeId stays the same but accessibleName
|
||||
// / role changes. Without this verification, `click @e20` (saved
|
||||
// when the button said "Add post") happily clicks the *same*
|
||||
// node that now says "Post all", silently submitting the thread.
|
||||
//
|
||||
// Set AGENT_BROWSER_VERIFY_REF=0 to skip (saves one CDP
|
||||
// roundtrip per ref-based interaction; only safe if you know
|
||||
// the page is static between snapshot and click).
|
||||
if std::env::var("AGENT_BROWSER_VERIFY_REF").as_deref() != Ok("0") {
|
||||
if let Err(e) = verify_ref_identity(
|
||||
client,
|
||||
effective_session_id,
|
||||
backend_node_id,
|
||||
&ref_id,
|
||||
&entry.role,
|
||||
&entry.name,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
let result: Result<DomGetBoxModelResult, String> = client
|
||||
.send_command_typed(
|
||||
"DOM.getBoxModel",
|
||||
@@ -177,6 +201,31 @@ pub async fn resolve_element_center(
|
||||
|
||||
if let Ok(r) = result {
|
||||
let (x, y) = box_model_center(&r.model);
|
||||
// Occlusion check: a transient overlay (X.com's "click
|
||||
// outside to close" mask, modal backdrop, sticky banner,
|
||||
// etc.) can land on top of our target between snapshot
|
||||
// and click. Coordinates are correct, but
|
||||
// `document.elementFromPoint(x, y)` returns the overlay
|
||||
// — and the click goes to the overlay's handler, not
|
||||
// ours. Catch it here so the user gets "occluded by
|
||||
// DIV[testid=mask]" instead of "modal silently closed +
|
||||
// thread submitted by accident".
|
||||
//
|
||||
// Set AGENT_BROWSER_VERIFY_CLICK_TARGET=0 to skip.
|
||||
if std::env::var("AGENT_BROWSER_VERIFY_CLICK_TARGET").as_deref() != Ok("0") {
|
||||
if let Err(e) = verify_click_target(
|
||||
client,
|
||||
effective_session_id,
|
||||
backend_node_id,
|
||||
&ref_id,
|
||||
x,
|
||||
y,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
return Ok((x, y, effective_session_id.to_string()));
|
||||
}
|
||||
// backend_node_id is stale; re-query the accessibility tree below
|
||||
@@ -230,6 +279,24 @@ pub async fn resolve_element_object_id(
|
||||
|
||||
// Try cached backend_node_id first (fast path)
|
||||
if let Some(backend_node_id) = entry.backend_node_id {
|
||||
// Same identity guard as resolve_element_center — see that
|
||||
// function for why React DOM-node-reuse breaks ref-based
|
||||
// interactions if we skip this.
|
||||
if std::env::var("AGENT_BROWSER_VERIFY_REF").as_deref() != Ok("0") {
|
||||
if let Err(e) = verify_ref_identity(
|
||||
client,
|
||||
effective_session_id,
|
||||
backend_node_id,
|
||||
&ref_id,
|
||||
&entry.role,
|
||||
&entry.name,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
let result: Result<DomResolveNodeResult, String> = client
|
||||
.send_command_typed(
|
||||
"DOM.resolveNode",
|
||||
@@ -333,6 +400,233 @@ fn resolve_frame_session<'a>(
|
||||
.unwrap_or(session_id)
|
||||
}
|
||||
|
||||
/// Verify that the cached backendNodeId still has the same accessible role
|
||||
/// and name it had when the snapshot ran. Catches the case where React (or
|
||||
/// any reconciler) reused the DOM node for a different component instance
|
||||
/// — same physical node, different semantics.
|
||||
///
|
||||
/// On mismatch, returns an actionable error naming both the snapshot label
|
||||
/// and the current label so the agent can re-snapshot intelligently.
|
||||
/// On any CDP failure (e.g. node deleted), returns Ok(()) so the caller's
|
||||
/// existing fallback (`find_node_id_by_role_name`) takes over.
|
||||
async fn verify_ref_identity(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
backend_node_id: i64,
|
||||
ref_id: &str,
|
||||
expected_role: &str,
|
||||
expected_name: &str,
|
||||
) -> Result<(), String> {
|
||||
let params = serde_json::json!({
|
||||
"backendNodeId": backend_node_id,
|
||||
"fetchRelatives": false,
|
||||
});
|
||||
// Tight 1s timeout: this is a defensive guard, not a critical path.
|
||||
// The default 30s CDP timeout was the dominant factor in the
|
||||
// "click hangs 5+ minutes" report — three CDP calls (verify +
|
||||
// resolveNode + paint-settle) at 30s each, multiplied by parallel
|
||||
// click invocations queueing on the daemon, totalled multi-minute
|
||||
// user-visible hangs. Cap our own helper so a stuck AX query
|
||||
// doesn't make `click` worse than the no-guard version was.
|
||||
let resp: Result<GetFullAXTreeResult, String> = match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
client.send_command_typed("Accessibility.getPartialAXTree", ¶ms, Some(session_id)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
// Timeout: skip identity verification rather than block the click.
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
let Ok(tree) = resp else {
|
||||
// Node likely gone; let the box-model call fail and trigger fallback.
|
||||
return Ok(());
|
||||
};
|
||||
// Find the AXNode for our backendNodeId. fetchRelatives=false still
|
||||
// returns ancestors; the target node has the matching backendNodeId.
|
||||
let Some(node) = tree
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.backend_d_o_m_node_id == Some(backend_node_id))
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let actual_role = extract_ax_string(&node.role);
|
||||
let actual_name = extract_ax_string(&node.name);
|
||||
if actual_role == expected_role && actual_name == expected_name {
|
||||
return Ok(());
|
||||
}
|
||||
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.",
|
||||
ref_id, expected_role, expected_name, actual_role, actual_name,
|
||||
))
|
||||
}
|
||||
|
||||
/// At the moment we'd dispatch the click, ask the page itself which element
|
||||
/// occupies (x, y). If it's not our target (and not a descendant or
|
||||
/// ancestor), an overlay has appeared between snapshot and click — we'd
|
||||
/// silently click the overlay otherwise. Returns Err with details about
|
||||
/// the occluding element so the caller can wait + re-snapshot.
|
||||
///
|
||||
/// Implemented as a single Runtime.callFunctionOn: resolve the cached
|
||||
/// backendNodeId to a remote object, then run a function on it that
|
||||
/// compares with elementFromPoint. The function returns null when the
|
||||
/// click is safe and a JSON string with diagnostic info when it isn't.
|
||||
async fn verify_click_target(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
backend_node_id: i64,
|
||||
ref_id: &str,
|
||||
x: f64,
|
||||
y: f64,
|
||||
) -> Result<(), String> {
|
||||
use serde::Deserialize;
|
||||
|
||||
// Resolve once. backendNodeId is stable across renders; only the
|
||||
// element under (x, y) is what changes when an overlay flickers.
|
||||
let resolve_params = DomResolveNodeParams {
|
||||
backend_node_id: Some(backend_node_id),
|
||||
node_id: None,
|
||||
object_group: Some("agent-browser-occlusion".to_string()),
|
||||
};
|
||||
let resolve_fut = client.send_command_typed::<_, serde_json::Value>(
|
||||
"DOM.resolveNode",
|
||||
&resolve_params,
|
||||
Some(session_id),
|
||||
);
|
||||
let Ok(resolve_resp) =
|
||||
tokio::time::timeout(std::time::Duration::from_millis(500), resolve_fut).await
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let Ok(resolved) = resolve_resp else { return Ok(()) };
|
||||
let Some(object_id) = resolved
|
||||
.get("object")
|
||||
.and_then(|o| o.get("objectId"))
|
||||
.and_then(|v| v.as_str())
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Auto-retry on transient occlusion. Many real-world overlays
|
||||
// (modal backdrops, focus rings, click-outside masks) blink in for
|
||||
// a frame or two during state transitions and clear on their own.
|
||||
// Without retries the user gets an "occluded" error and has to
|
||||
// wrap every click in their own retry loop. With retries the
|
||||
// common case is invisible — only persistent overlays surface.
|
||||
//
|
||||
// AGENT_BROWSER_OCCLUSION_RETRIES (default 3, 0 disables)
|
||||
// AGENT_BROWSER_OCCLUSION_RETRY_DELAY_MS (default 200)
|
||||
let max_retries: u32 = std::env::var("AGENT_BROWSER_OCCLUSION_RETRIES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(3);
|
||||
let retry_delay_ms: u64 = std::env::var("AGENT_BROWSER_OCCLUSION_RETRY_DELAY_MS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(200);
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Occluder {
|
||||
tag: Option<String>,
|
||||
testid: Option<String>,
|
||||
role: Option<String>,
|
||||
#[serde(rename = "ariaLabel")]
|
||||
aria_label: Option<String>,
|
||||
text: Option<String>,
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
||||
// function(x, y) { ... } where `this` is the target element.
|
||||
// Return null → click is safe.
|
||||
// Return JSON → describes the occluding element.
|
||||
let function_decl = "function(x, y) { \
|
||||
const at = document.elementFromPoint(x, y); \
|
||||
if (!at) return JSON.stringify({reason:'no-element-at-point'}); \
|
||||
if (at === this || this.contains(at) || at.contains(this)) return null; \
|
||||
return JSON.stringify({ \
|
||||
tag: at.tagName, \
|
||||
testid: (at.dataset && at.dataset.testid) || null, \
|
||||
role: at.getAttribute('role'), \
|
||||
ariaLabel: at.getAttribute('aria-label'), \
|
||||
text: ((at.textContent||'').trim().slice(0, 60)) \
|
||||
}); \
|
||||
}";
|
||||
|
||||
let mut last_occ: Option<Occluder> = None;
|
||||
for attempt in 0..=max_retries {
|
||||
if attempt > 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(retry_delay_ms)).await;
|
||||
}
|
||||
let call_params = serde_json::json!({
|
||||
"objectId": object_id,
|
||||
"functionDeclaration": function_decl,
|
||||
"arguments": [{"value": x}, {"value": y}],
|
||||
"returnByValue": true,
|
||||
});
|
||||
let call_fut = client.send_command_typed::<_, serde_json::Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&call_params,
|
||||
Some(session_id),
|
||||
);
|
||||
let Ok(call_resp) =
|
||||
tokio::time::timeout(std::time::Duration::from_millis(500), call_fut).await
|
||||
else {
|
||||
return Ok(()); // probe itself stalled — fall through to click
|
||||
};
|
||||
let Ok(call_result) = call_resp else {
|
||||
return Ok(());
|
||||
};
|
||||
let value = call_result.get("result").and_then(|r| r.get("value"));
|
||||
let json_str = match value {
|
||||
Some(serde_json::Value::String(s)) => s.clone(),
|
||||
// null / undefined → element at point IS our target. Safe.
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let occ: Occluder = match serde_json::from_str(&json_str) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
last_occ = Some(occ);
|
||||
}
|
||||
|
||||
// All retries exhausted — overlay is sticky. Build the descriptive error.
|
||||
let occ = last_occ.expect("loop ran at least once");
|
||||
if let Some(reason) = occ.reason {
|
||||
return Err(format!(
|
||||
"Ref {} cannot be clicked at its computed position: {}. \
|
||||
The element may have moved off-screen — re-run snapshot.",
|
||||
ref_id, reason
|
||||
));
|
||||
}
|
||||
let mut desc = occ.tag.unwrap_or_else(|| "unknown".to_string());
|
||||
if let Some(t) = occ.testid {
|
||||
desc.push_str(&format!("[testid={}]", t));
|
||||
}
|
||||
if let Some(r) = occ.role {
|
||||
desc.push_str(&format!("[role={}]", r));
|
||||
}
|
||||
if let Some(a) = occ.aria_label {
|
||||
desc.push_str(&format!("[aria-label=\"{}\"]", a));
|
||||
}
|
||||
if let Some(t) = occ.text {
|
||||
if !t.is_empty() {
|
||||
desc.push_str(&format!(" text=\"{}\"", t));
|
||||
}
|
||||
}
|
||||
let waited_ms = (max_retries as u64) * retry_delay_ms;
|
||||
Err(format!(
|
||||
"Ref {} is occluded by {} at the click point (still occluded after \
|
||||
{} retries / {}ms). A persistent overlay is in the way — \
|
||||
re-run snapshot, dismiss the overlay, or set \
|
||||
AGENT_BROWSER_VERIFY_CLICK_TARGET=0 to bypass.",
|
||||
ref_id, desc, max_retries, waited_ms,
|
||||
))
|
||||
}
|
||||
|
||||
/// Re-query the accessibility tree to find a node matching role+name+nth,
|
||||
/// returning its fresh backendDOMNodeId. This uses the same data source
|
||||
/// (Accessibility.getFullAXTree) that built the ref map during snapshot,
|
||||
|
||||
@@ -884,6 +884,46 @@ pub async fn tap_touch(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// After a click is dispatched, give the page two animation frames + a
|
||||
/// microtask boundary to let React/Vue/Svelte commit any state update
|
||||
/// scheduled by the click handler. Without this wait, follow-up commands
|
||||
/// (e.g. `inserttext` against the textbox the click was supposed to mount)
|
||||
/// race the renderer and can land on stale or wrong elements.
|
||||
///
|
||||
/// The wait is bounded to ~33ms in the common case (two RAFs at 60fps) and
|
||||
/// returns immediately on any error — never an exception path.
|
||||
///
|
||||
/// Set `AGENT_BROWSER_CLICK_WAIT_STABLE=0` to disable for perf-sensitive
|
||||
/// scripts that don't drive SPA UIs.
|
||||
async fn wait_for_paint_settled(client: &CdpClient, session_id: &str) {
|
||||
if std::env::var("AGENT_BROWSER_CLICK_WAIT_STABLE").as_deref() == Ok("0") {
|
||||
return;
|
||||
}
|
||||
let script = "new Promise(resolve => \
|
||||
requestAnimationFrame(() => \
|
||||
requestAnimationFrame(() => \
|
||||
queueMicrotask(() => resolve(true)))))";
|
||||
// Tight 500ms timeout. RAF normally fires at 16ms, two RAFs total ~33ms.
|
||||
// If the tab is hidden / throttled / page is doing something pathological
|
||||
// and RAF doesn't fire in 500ms, we'd rather return now than stall the
|
||||
// user's click. Without this cap, a stuck RAF inherited the default 30s
|
||||
// CDP timeout and was the main contributor to the "click hangs 5+ min"
|
||||
// user report.
|
||||
let _ = tokio::time::timeout(
|
||||
std::time::Duration::from_millis(500),
|
||||
client.send_command_typed::<_, Value>(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: script.to_string(),
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(true),
|
||||
},
|
||||
Some(session_id),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn dispatch_click(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
@@ -955,6 +995,7 @@ async fn dispatch_click(
|
||||
)
|
||||
.await?;
|
||||
|
||||
wait_for_paint_settled(client, session_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -20,13 +20,19 @@ services:
|
||||
|
||||
# Build both targets in parallel
|
||||
(echo "→ Linux x64" && cargo zigbuild --release --target x86_64-unknown-linux-gnu && cp /build/target/x86_64-unknown-linux-gnu/release/agent-browser /output/agent-browser-linux-x64 && chmod +x /output/agent-browser-linux-x64 && echo "✓ Linux x64 done") &
|
||||
PID1=$!
|
||||
PID1=$$!
|
||||
|
||||
(echo "→ Linux ARM64" && cargo zigbuild --release --target aarch64-unknown-linux-gnu && cp /build/target/aarch64-unknown-linux-gnu/release/agent-browser /output/agent-browser-linux-arm64 && chmod +x /output/agent-browser-linux-arm64 && echo "✓ Linux ARM64 done") &
|
||||
PID2=$!
|
||||
PID2=$$!
|
||||
|
||||
# Wait for both to complete
|
||||
wait $PID1 $PID2
|
||||
# Wait for both and check exit codes individually — without this
|
||||
# the outer script exits 0 even if one of the parallel builds
|
||||
# failed, silently leaving a stale binary in /output from the
|
||||
# previous release. Caused 0.27.0-fork.5 to ship with a stale
|
||||
# linux-x64 binary at the first publish attempt until caught
|
||||
# manually by checking the embedded version string.
|
||||
wait $$PID1 || { echo "✗ Linux x64 build failed"; exit 1; }
|
||||
wait $$PID2 || { echo "✗ Linux ARM64 build failed"; exit 1; }
|
||||
|
||||
echo ""
|
||||
echo "✓ Linux platforms built successfully!"
|
||||
@@ -65,10 +71,21 @@ services:
|
||||
environment:
|
||||
- TARGET=${TARGET:-x86_64-unknown-linux-gnu}
|
||||
- OUTPUT_NAME=${OUTPUT_NAME:-agent-browser-linux-x64}
|
||||
# NOTE: $$ escapes a literal $ for the in-container shell. A single $ is
|
||||
# interpolated by docker compose at YAML parse time against the *host*
|
||||
# environment, which silently drops script-local variables like SRC
|
||||
# (caused 0.27.0-fork.7 to ship with a stale linux-arm64 binary because
|
||||
# the cp command resolved to `cp "" "/output/"` after compose ate $SRC
|
||||
# and $OUTPUT_NAME). $TARGET / $OUTPUT_NAME are set via `environment:`
|
||||
# below — those are also passed into the container, so $$TARGET and
|
||||
# $$OUTPUT_NAME read them at script time.
|
||||
command: |
|
||||
-c '
|
||||
cargo zigbuild --release --target $TARGET
|
||||
cp /build/target/$TARGET/release/agent-browser* /output/$OUTPUT_NAME
|
||||
chmod +x /output/$OUTPUT_NAME 2>/dev/null || true
|
||||
echo "✓ Built $OUTPUT_NAME"
|
||||
set -e
|
||||
cargo zigbuild --release --target $$TARGET
|
||||
SRC="/build/target/$$TARGET/release/agent-browser"
|
||||
if [ -f "$$SRC.exe" ]; then SRC="$$SRC.exe"; fi
|
||||
cp "$$SRC" "/output/$$OUTPUT_NAME"
|
||||
chmod +x /output/$$OUTPUT_NAME 2>/dev/null || true
|
||||
echo "✓ Built $$OUTPUT_NAME"
|
||||
'
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agent-browser-stealth",
|
||||
"version": "0.27.0-fork.2",
|
||||
"version": "0.27.0-fork.8",
|
||||
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
||||
Reference in New Issue
Block a user