Compare commits

...
Author SHA1 Message Date
leeguooooo c26afbaba6 chore(release): bump to 0.27.0-fork.8 — auto-retry transient occlusion 2026-05-09 12:39:14 +09:00
leeguooooo ffb386e3af feat(click): auto-retry on transient occlusion before erroring
fork.7 caught the X mask-overlay race correctly but reported it to
the user verbatim — every transient overlay (modal backdrop, focus
ring, click-outside mask, sticky banner) became an error the user
had to wrap in their own retry loop. Most of these clear within a
frame or two on their own.

Now `verify_click_target` retries the elementFromPoint probe a few
times (default 3 × 200ms = 600ms total grace period) before failing.
Real-world overlays that blink in for a render cycle clear during
the first retry; persistent overlays still surface as errors with
the same actionable message — just qualified with "still occluded
after N retries / Mms" so the user knows we tried.

Tunable:
  AGENT_BROWSER_OCCLUSION_RETRIES         (default 3, 0 disables)
  AGENT_BROWSER_OCCLUSION_RETRY_DELAY_MS  (default 200)

DOM.resolveNode is called once outside the loop — backendNodeId is
stable across renders, only the element under (x, y) changes when
overlays flicker. Each probe is still capped at 500ms so a stuck
Runtime.callFunctionOn can't stall a click for longer than the user
expects.
2026-05-09 12:39:03 +09:00
leeguooooo 947d150561 fix(docker): escape \$ as \$\$ so docker compose doesn't eat shell vars
Real bug behind 0.27.0-fork.5 and fork.7 shipping stale linux binaries.
Docker compose interpolates \${VAR} (and \$VAR) at YAML parse time
against the host shell — including inside `command:` blocks. So:

  PID1=\$!                ← compose sees \$! → host has no `!` var → ""
  wait \$PID1 ...         ← compose sees \$PID1 → "" → becomes `wait `
  SRC="...\$TARGET..."    ← \$TARGET still works (set in `environment:`)
  cp "\$SRC" "..."        ← \$SRC eaten → empty → cp errors silently

Result: the per-PID error check I added in dbf272c never fired
because both lines were `wait` (no args) — which waits for ALL
children and exits with the LAST one's status, not each individually.
A failing arm64 build couldn't fail the script.

Fix: escape every script-local \$ as \$\$. Docker compose translates
\$\$ → literal \$ when materializing the command for the container,
and the in-container shell then expands \$VAR correctly.

Verified by `docker compose config` showing the resolved command
contains \$\$PID1 / \$\$SRC etc (which becomes \$PID1 / \$SRC in the
container's bash).
2026-05-09 11:07:01 +09:00
5 changed files with 95 additions and 68 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ dependencies = [
[[package]] [[package]]
name = "agent-browser-stealth" name = "agent-browser-stealth"
version = "0.27.0-fork.7" version = "0.27.0-fork.8"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"async-trait", "async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "agent-browser-stealth" name = "agent-browser-stealth"
version = "0.27.0-fork.7" version = "0.27.0-fork.8"
edition = "2021" edition = "2021"
description = "Fast browser automation CLI for AI agents" description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0" license = "Apache-2.0"
+74 -52
View File
@@ -485,9 +485,8 @@ async fn verify_click_target(
) -> Result<(), String> { ) -> Result<(), String> {
use serde::Deserialize; use serde::Deserialize;
// Tight 500ms timeout — this is a defensive guard, not critical path. // Resolve once. backendNodeId is stable across renders; only the
// If it can't run quickly, fall through and let the click proceed // element under (x, y) is what changes when an overlay flickers.
// (we're no worse off than the unguarded code path).
let resolve_params = DomResolveNodeParams { let resolve_params = DomResolveNodeParams {
backend_node_id: Some(backend_node_id), backend_node_id: Some(backend_node_id),
node_id: None, node_id: None,
@@ -512,6 +511,35 @@ async fn verify_click_target(
return Ok(()); 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. // function(x, y) { ... } where `this` is the target element.
// Return null → click is safe. // Return null → click is safe.
// Return JSON → describes the occluding element. // Return JSON → describes the occluding element.
@@ -527,51 +555,46 @@ async fn verify_click_target(
text: ((at.textContent||'').trim().slice(0, 60)) \ text: ((at.textContent||'').trim().slice(0, 60)) \
}); \ }); \
}"; }";
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(());
};
let Ok(call_result) = call_resp else {
return Ok(());
};
let value = call_result let mut last_occ: Option<Occluder> = None;
.get("result") for attempt in 0..=max_retries {
.and_then(|r| r.get("value")); if attempt > 0 {
tokio::time::sleep(std::time::Duration::from_millis(retry_delay_ms)).await;
// null / undefined / missing → safe }
let json_str = match value { let call_params = serde_json::json!({
Some(serde_json::Value::String(s)) => s.clone(), "objectId": object_id,
_ => return Ok(()), "functionDeclaration": function_decl,
}; "arguments": [{"value": x}, {"value": y}],
"returnByValue": true,
#[derive(Deserialize)] });
struct Occluder { let call_fut = client.send_command_typed::<_, serde_json::Value>(
tag: Option<String>, "Runtime.callFunctionOn",
testid: Option<String>, &call_params,
role: Option<String>, Some(session_id),
#[serde(rename = "ariaLabel")] );
aria_label: Option<String>, let Ok(call_resp) =
text: Option<String>, tokio::time::timeout(std::time::Duration::from_millis(500), call_fut).await
reason: Option<String>, 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);
} }
let occ: Occluder = match serde_json::from_str(&json_str) {
Ok(v) => v,
Err(_) => return Ok(()),
};
// 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 { if let Some(reason) = occ.reason {
return Err(format!( return Err(format!(
"Ref {} cannot be clicked at its computed position: {}. \ "Ref {} cannot be clicked at its computed position: {}. \
@@ -579,7 +602,6 @@ async fn verify_click_target(
ref_id, reason ref_id, reason
)); ));
} }
let mut desc = occ.tag.unwrap_or_else(|| "unknown".to_string()); let mut desc = occ.tag.unwrap_or_else(|| "unknown".to_string());
if let Some(t) = occ.testid { if let Some(t) = occ.testid {
desc.push_str(&format!("[testid={}]", t)); desc.push_str(&format!("[testid={}]", t));
@@ -595,13 +617,13 @@ async fn verify_click_target(
desc.push_str(&format!(" text=\"{}\"", t)); desc.push_str(&format!(" text=\"{}\"", t));
} }
} }
let waited_ms = (max_retries as u64) * retry_delay_ms;
Err(format!( Err(format!(
"Ref {} is occluded by {} at the click point. \ "Ref {} is occluded by {} at the click point (still occluded after \
A transient overlay (modal backdrop, mask, sticky banner, etc.) \ {} retries / {}ms). A persistent overlay is in the way — \
appeared between snapshot and click. \ re-run snapshot, dismiss the overlay, or set \
Wait for it to clear or re-snapshot, then retry. \ AGENT_BROWSER_VERIFY_CLICK_TARGET=0 to bypass.",
Set AGENT_BROWSER_VERIFY_CLICK_TARGET=0 to bypass this guard.", ref_id, desc, max_retries, waited_ms,
ref_id, desc
)) ))
} }
+18 -13
View File
@@ -20,10 +20,10 @@ services:
# Build both targets in parallel # 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") & (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") & (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 and check exit codes individually — without this # Wait for both and check exit codes individually — without this
# the outer script exits 0 even if one of the parallel builds # the outer script exits 0 even if one of the parallel builds
@@ -31,8 +31,8 @@ services:
# previous release. Caused 0.27.0-fork.5 to ship with a stale # previous release. Caused 0.27.0-fork.5 to ship with a stale
# linux-x64 binary at the first publish attempt until caught # linux-x64 binary at the first publish attempt until caught
# manually by checking the embedded version string. # manually by checking the embedded version string.
wait $PID1 || { echo "✗ Linux x64 build failed"; exit 1; } wait $$PID1 || { echo "✗ Linux x64 build failed"; exit 1; }
wait $PID2 || { echo "✗ Linux ARM64 build failed"; exit 1; } wait $$PID2 || { echo "✗ Linux ARM64 build failed"; exit 1; }
echo "" echo ""
echo "✓ Linux platforms built successfully!" echo "✓ Linux platforms built successfully!"
@@ -71,16 +71,21 @@ services:
environment: environment:
- TARGET=${TARGET:-x86_64-unknown-linux-gnu} - TARGET=${TARGET:-x86_64-unknown-linux-gnu}
- OUTPUT_NAME=${OUTPUT_NAME:-agent-browser-linux-x64} - 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: | command: |
-c ' -c '
set -e set -e
cargo zigbuild --release --target $TARGET cargo zigbuild --release --target $$TARGET
# Copy the binary explicitly. The previous `agent-browser*` glob SRC="/build/target/$$TARGET/release/agent-browser"
# matched the binary AND its `.d` dependency file, which made cp if [ -f "$$SRC.exe" ]; then SRC="$$SRC.exe"; fi
# treat the destination as a directory and silently failed. cp "$$SRC" "/output/$$OUTPUT_NAME"
SRC="/build/target/$TARGET/release/agent-browser" chmod +x /output/$$OUTPUT_NAME 2>/dev/null || true
if [ -f "$SRC.exe" ]; then SRC="$SRC.exe"; fi echo "✓ Built $$OUTPUT_NAME"
cp "$SRC" "/output/$OUTPUT_NAME"
chmod +x /output/$OUTPUT_NAME 2>/dev/null || true
echo "✓ Built $OUTPUT_NAME"
' '
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agent-browser-stealth", "name": "agent-browser-stealth",
"version": "0.27.0-fork.7", "version": "0.27.0-fork.8",
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection", "description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
"type": "module", "type": "module",
"files": [ "files": [