Compare commits

...
Author SHA1 Message Date
leeguooooo 7159012173 chore(release): bump to 0.27.0-fork.6 — defensive-guard timeouts + accurate CDP tip 2026-05-09 10:04:25 +09:00
leeguooooo 1b3d41e579 fix(timeout): cap defensive CDP guards so click can't hang multi-minute
Reported: a single `click @ref` could hang 5+ minutes, with multiple
queued click invocations adding up to 7+ minutes — worst case 30s
timeout × 3 CDP calls × N parallel processes:

  - verify_ref_identity (Accessibility.getPartialAXTree)  →  default 30s
  - resolveNode / getBoxModel                              →  default 30s
  - wait_for_paint_settled (Runtime.evaluate awaitPromise) →  default 30s

The latter two are best-effort defenses added in fork.3-5 to fix SPA
race / DOM-reuse bugs. They should never block a real click for
30s — the unguarded code path was always faster than the guarded
path-that-hangs.

  - verify_ref_identity   capped at 1s   (skips check on timeout)
  - wait_for_paint_settled capped at 500ms (skips wait on timeout)

Both skip-on-timeout intentionally: the worst case is the click
behaves like fork.2 (race-prone but fast), which is strictly better
than the user pkilling stuck processes.

Also rewrites the misleading "Chrome 144+ chrome://inspect tip" in
the auto-connect failure message — the toggle exposes target
discovery only, not the /json/version HTTP API the auto-connect
flow expects (verified by user: lsof shows :9222 listening but
curl /json/version returns 404).
2026-05-09 10:04:14 +09:00
leeguooooo dbf272ced7 fix(docker): catch parallel-build failures + stop using glob in cp
Two latent bugs in the release pipeline that conspired to ship a stale
linux-x64 binary in 0.27.0-fork.5 (only caught by manually grepping
the embedded version string):

1. build-linux ran x64 and arm64 in parallel and used a single
   `wait $PID1 $PID2` to join them. That command waits for both, but
   its exit code is the LAST waited pid only — so if x64 silently
   broke and arm64 succeeded, the outer script exited 0 and shipped
   whatever was already in /output from the previous release. Now we
   wait on each pid individually and exit 1 on either failure.

2. build-single's cp used `agent-browser*` which globs to BOTH the
   binary and its `.d` dependency file. When two sources are passed,
   cp requires the destination to be a directory. We weren't, so cp
   exited non-zero with "Not a directory" and the build script
   shrugged it off because the next line was `chmod ... || true`.
   Now we resolve a single explicit source path.
2026-05-09 04:30:20 +09:00
leeguooooo 64140879d5 chore(release): bump to 0.27.0-fork.5 — attach-mode UX + zombie-CDP probe + wait @ref 2026-05-09 04:11:09 +09:00
leeguooooo d3bfd76c96 fix(connect): liveness probe + wait @ref support
Two changes that pair with each other:

1. connect_auto_with_fresh_tab now does a Runtime.evaluate "1"
   round-trip after creating the fresh tab. This catches the zombie
   CDP socket case (process alive, websocket dead) where every step
   up to that point reports success but the next user command would
   silently no-op against a dead session. Failing here lets the
   caller surface a proper "CDP session unresponsive" error instead
   of returning Ok and letting `agent-browser open URL` exit 0 with
   a still-blank tab.

2. handle_wait now recognizes @ref selectors (e.g. `wait @e8 --gone`).
   It polls resolve_element_object_id, which already runs the
   verify_ref_identity check from 007fd1b — so:
     - `wait @e8`             succeeds while the original element is
                              still mounted with its snapshot role+name
     - `wait @e8 --gone`      succeeds when the ref's identity changes
                              (modal closed, button re-textified, etc.)
   This gives users the "assert modal still open" primitive that
   prior versions could only approximate with screenshots.
2026-05-09 04:10:48 +09:00
leeguooooo 47dfe760be fix(cli): better message when only --headed is ignored in attach mode
In CDP-attach mode (the default since 0.24.0-fork.1), --headed has no
effect — the user's existing Chrome is already visible, and the
generic "use 'agent-browser close' first to restart" advice doesn't
help (the new daemon attaches right back). Explicitly say --headed is
moot and point to --launch as the actual escape hatch.

Other ignored flags (--profile, --proxy, etc.) keep the existing
"close + reopen" message because for those it IS the right advice.
2026-05-09 04:10:46 +09:00
8 changed files with 145 additions and 22 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ dependencies = [
[[package]]
name = "agent-browser-stealth"
version = "0.27.0-fork.4"
version = "0.27.0-fork.6"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-browser-stealth"
version = "0.27.0-fork.4"
version = "0.27.0-fork.6"
edition = "2021"
description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0"
+18 -5
View File
@@ -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(", ")
);
}
}
}
+80 -4
View File
@@ -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,
+17 -3
View File
@@ -396,9 +396,23 @@ async fn verify_ref_identity(
"backendNodeId": backend_node_id,
"fetchRelatives": false,
});
let resp: Result<GetFullAXTreeResult, String> = client
.send_command_typed("Accessibility.getPartialAXTree", &params, Some(session_id))
.await;
// 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", &params, 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(());
+12 -4
View File
@@ -903,8 +903,15 @@ async fn wait_for_paint_settled(client: &CdpClient, session_id: &str) {
requestAnimationFrame(() => \
requestAnimationFrame(() => \
queueMicrotask(() => resolve(true)))))";
let _ = client
.send_command_typed::<_, Value>(
// 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(),
@@ -912,8 +919,9 @@ async fn wait_for_paint_settled(client: &CdpClient, session_id: &str) {
await_promise: Some(true),
},
Some(session_id),
)
.await;
),
)
.await;
}
async fn dispatch_click(
+15 -3
View File
@@ -25,8 +25,14 @@ services:
(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=$!
# 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!"
@@ -67,8 +73,14 @@ services:
- OUTPUT_NAME=${OUTPUT_NAME:-agent-browser-linux-x64}
command: |
-c '
set -e
cargo zigbuild --release --target $TARGET
cp /build/target/$TARGET/release/agent-browser* /output/$OUTPUT_NAME
# Copy the binary explicitly. The previous `agent-browser*` glob
# matched the binary AND its `.d` dependency file, which made cp
# treat the destination as a directory and silently failed.
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
View File
@@ -1,6 +1,6 @@
{
"name": "agent-browser-stealth",
"version": "0.27.0-fork.4",
"version": "0.27.0-fork.6",
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
"type": "module",
"files": [