Compare commits

..
Author SHA1 Message Date
leeguooooo 36c593631c chore(release): 0.27.0-fork.43 — issue/Hermes batch 1 (silent-click, live env, eval --file, tab-list, docs)
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
2026-06-11 22:35:35 +09:00
leeguooooo b92757412d fix(click): occlusion guard for selector clicks — no more silent false success
A coordinate click resolved from a CSS selector (incl. the getByText/find path's
located node) skipped the occlusion check that @ref clicks already get, so an
overlay on top made the click land on the overlay while still reporting ✓ Done —
the worst failure mode for an agent (Hermes #1, issue #2/#3). Now: if the click
point doesn't hit the target (elementFromPoint isn't the element / a descendant /
an ancestor wrapper), dispatch through the DOM instead, which fires the real
handler. Best-effort probe (a flaky check never blocks the normal path); skipped
for strict CLICK_MODE=coord and non-left/multi-clicks.

Verified: occluded button click hits 0→1 (was silent ✓Done); normal click
unaffected.
2026-06-11 22:34:12 +09:00
leeguooooo 6ecda4d706 fix: per-invocation env (CLICK_MODE / HUMANIZE) reaches a running daemon
Root cause behind Hermes #1 (CLICK_MODE=dom "does nothing") and #2 (--humanize
"does nothing"): both are env vars the daemon reads, but the daemon's env is
frozen at spawn — set them on a command to an already-running daemon and they
were silently ignored. (Confirmed: setting CLICK_MODE=dom at daemon spawn made
dom_click fire; setting it later did not.)

Fix: the client forwards AGENT_BROWSER_CLICK_MODE / AGENT_BROWSER_HUMANIZE in the
command envelope (_clickMode/_humanize); execute_command applies them per command
— mirrors CLICK_MODE into the process env (interaction::click reads it fresh) and
sets the humanize session level. Each command is authoritative.

Verified on an already-running daemon: CLICK_MODE=dom now fires dom_click
(hits 0→1); --humanize human typing applies.
2026-06-11 22:29:39 +09:00
leeguooooo 123510db2b feat: eval --file, tab-list URL truncation, skill doc fixes (issue #2/#3 + Hermes)
- `eval --file <path>`: read JS from a file, sent verbatim — avoids shell-mangling
  of non-ASCII identifiers/strings (Chinese), quotes, and large scripts (issue #3).
- `tab list`: truncate multi-KB URLs (JWT/OTP login links) middle-out with a char
  count so the list stays readable (issue #3).
- skill: fix the snapshot example to match real output
  (`- role "name" [ref=eN]`, not `@e1 [role]`); document that eval runs in the
  page MAIN world with persistent state (top-level `const` collides — use IIFE /
  window / unique names) and to prefer --file/--stdin/-b for non-ASCII or big JS.
2026-06-11 22:22:07 +09:00
leeguooooo abb65c632b chore(release): 0.27.0-fork.42 — close session-owned tabs on exit (no tab leak)
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
2026-06-11 21:54:44 +09:00
leeguooooo e803bffbbb fix(tabs): close the session's own tabs on exit (stop leaking into the user's Chrome)
When connected to the user's real Chrome, mgr.close() disconnected but never
closed the tabs the session opened — so every session (especially one that
failed before calling close, or a forgotten one) left its tabs piling up in the
user's browser. Idle-timeout and shutdown have the same exit path.

Track the target_ids this session creates via Target.createTarget in
`created_targets` (only ever our own tabs — never the user's existing tabs, which
the raw-CDP path attaches to, nor other sessions'). On close(), for the connected
path (not a launched browser, which Browser.close handles wholesale), close each
of those targets — the extension maps Target.closeTarget → chrome.tabs.remove.

Verified against a throwaway --cdp Chrome: open + 2 `tab new` → 3 pages; `close`
→ back to 1 (our 2 closed, the pre-existing tab untouched).
2026-06-11 21:54:42 +09:00
10 changed files with 188 additions and 20 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ dependencies = [
[[package]]
name = "agent-browser-stealth"
version = "0.27.0-fork.41"
version = "0.27.0-fork.43"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-browser-stealth"
version = "0.27.0-fork.41"
version = "0.27.0-fork.43"
edition = "2021"
description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0"
+20 -6
View File
@@ -841,17 +841,31 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
// === Eval ===
"eval" => {
// Check for flags: -b/--base64 or --stdin
let (is_base64, is_stdin, script_parts): (bool, bool, &[&str]) =
// Check for flags: -b/--base64, --stdin, or --file <path>
let (is_base64, is_stdin, is_file, script_parts): (bool, bool, bool, &[&str]) =
if rest.first() == Some(&"-b") || rest.first() == Some(&"--base64") {
(true, false, &rest[1..])
(true, false, false, &rest[1..])
} else if rest.first() == Some(&"--stdin") {
(false, true, &rest[1..])
(false, true, false, &rest[1..])
} else if rest.first() == Some(&"--file") {
(false, false, true, &rest[1..])
} else {
(false, false, rest.as_slice())
(false, false, false, rest.as_slice())
};
let script = if is_stdin {
let script = if is_file {
// Read the script from a file. Avoids shell-mangling of inline JS
// (non-ASCII identifiers/strings, quotes, large scripts) — the file
// is read as UTF-8 and sent verbatim.
let path = script_parts.first().ok_or(ParseError::InvalidValue {
message: "eval --file requires a path".to_string(),
usage: "eval --file <path>",
})?;
std::fs::read_to_string(path).map_err(|e| ParseError::InvalidValue {
message: format!("eval --file: cannot read {path}: {e}"),
usage: "eval --file <path>",
})?
} else if is_stdin {
// Read script from stdin
let stdin = io::stdin();
let lines: Vec<String> = stdin
+15 -1
View File
@@ -821,7 +821,21 @@ fn connect(session: &str) -> Result<Connection, String> {
}
}
pub fn send_command(cmd: Value, session: &str) -> Result<Response, String> {
pub fn send_command(mut cmd: Value, session: &str) -> Result<Response, String> {
// Forward per-invocation env to the daemon. The daemon's environment is
// frozen at spawn, so settings like AGENT_BROWSER_CLICK_MODE /
// AGENT_BROWSER_HUMANIZE (incl. the --humanize flag, which sets the latter)
// are otherwise silently ignored on an already-running daemon. Carry them in
// the envelope so they apply to THIS command.
if let Some(obj) = cmd.as_object_mut() {
if let Ok(m) = std::env::var("AGENT_BROWSER_CLICK_MODE") {
obj.insert("_clickMode".to_string(), Value::String(m));
}
if let Ok(h) = std::env::var("AGENT_BROWSER_HUMANIZE") {
obj.insert("_humanize".to_string(), Value::String(h));
}
}
// Retry logic for transient errors (EAGAIN/EWOULDBLOCK/connection issues)
const MAX_RETRIES: u32 = 5;
const RETRY_DELAY_MS: u64 = 200;
+17
View File
@@ -1160,6 +1160,23 @@ impl Drop for DaemonState {
pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
let action = cmd.get("action").and_then(|v| v.as_str()).unwrap_or("");
// Apply per-invocation overrides the client forwarded (the daemon's own env
// is frozen at spawn). CLICK_MODE is read fresh from the process env by
// interaction::click, so mirror it here — set when this command provided it,
// clear otherwise, so a value from an earlier command never leaks forward.
match cmd.get("_clickMode").and_then(|v| v.as_str()) {
Some(m) if !m.is_empty() => std::env::set_var("AGENT_BROWSER_CLICK_MODE", m),
_ => std::env::remove_var("AGENT_BROWSER_CLICK_MODE"),
}
// Humanize: set the session level from the client's --humanize / env. Only
// set when provided (don't clear — the adaptive per-navigation detector also
// owns this level between explicit overrides).
if let Some(h) = cmd.get("_humanize").and_then(|v| v.as_str()) {
if let Some(level) = super::humanize::HumanizeLevel::parse(h) {
super::humanize::set_detected_level(level);
}
}
let id = cmd
.get("id")
.and_then(|v| v.as_str())
+33
View File
@@ -309,6 +309,12 @@ pub struct BrowserManager {
pub ignore_https_errors: bool,
/// Origins visited during this session, used by save_state to collect cross-origin localStorage.
visited_origins: HashSet<String>,
/// Target IDs of tabs THIS session created via `Target.createTarget`. When
/// connected to the user's real Chrome (not a launched browser), these are
/// closed on `close()` so the session's tabs don't pile up in the user's
/// 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>,
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
@@ -433,6 +439,7 @@ impl BrowserManager {
download_path: download_path.clone(),
ignore_https_errors,
visited_origins: HashSet::new(),
created_targets: HashSet::new(),
next_tab_id: 1,
capture_console: console_capture_enabled(),
};
@@ -523,6 +530,7 @@ impl BrowserManager {
download_path: None,
ignore_https_errors: false,
visited_origins: HashSet::new(),
created_targets: HashSet::new(),
next_tab_id: 1,
capture_console: console_capture_enabled(),
};
@@ -586,6 +594,8 @@ impl BrowserManager {
None,
)
.await?;
// We created this tab — own it so close() can clean it up.
self.created_targets.insert(result.target_id.clone());
let attach_result: AttachToTargetResult = self
.client
@@ -892,6 +902,24 @@ impl BrowserManager {
.client
.send_command_no_params("Browser.close", None)
.await;
} else {
// Connected to the user's real Chrome: we must NOT close their
// browser, but we DO own the tabs this session created. Close them so
// they don't pile up in the user's window (in their per-session tab
// group) every time a session ends, idles out, or the daemon shuts
// down. `created_targets` only holds tabs we made via
// Target.createTarget — never the user's existing tabs or other
// sessions' — so this is always safe. Best-effort per tab.
for target_id in self.created_targets.drain() {
let _ = self
.client
.send_command_typed::<_, Value>(
"Target.closeTarget",
&CloseTargetParams { target_id },
None,
)
.await;
}
}
if let Some(mut process) = self.browser_process.take() {
@@ -993,6 +1021,8 @@ impl BrowserManager {
None,
)
.await?;
// We created this tab — own it so close() can clean it up.
self.created_targets.insert(result.target_id.clone());
let attach_result: AttachToTargetResult = self
.client
@@ -1158,6 +1188,8 @@ impl BrowserManager {
None,
)
.await?;
// We created this tab — own it so close() can clean it up.
self.created_targets.insert(result.target_id.clone());
let attach: AttachToTargetResult = self
.client
@@ -1750,6 +1782,7 @@ async fn initialize_lightpanda_manager(
download_path: None,
ignore_https_errors: false,
visited_origins: HashSet::new(),
created_targets: HashSet::new(),
next_tab_id: 1,
capture_console: console_capture_enabled(),
};
+64 -1
View File
@@ -4,7 +4,7 @@ use serde_json::Value;
use super::cdp::client::CdpClient;
use super::cdp::types::*;
use super::element::{resolve_element_center, resolve_element_object_id, RefMap};
use super::element::{parse_ref, resolve_element_center, resolve_element_object_id, RefMap};
use super::humanize;
pub async fn click(
@@ -56,6 +56,33 @@ pub async fn click(
match resolved {
Ok((cx, cy, w, h, effective_session_id)) => {
// Occlusion guard for the CSS-selector path. `@ref` clicks are already
// occlusion-checked in resolve_element_center, but a plain selector
// resolves to coordinates without that check — so an overlay (modal
// backdrop, sticky banner, the getByText located node sitting under a
// full-screen layer) would make the coordinate click land on the
// overlay and still report success. If the click point doesn't hit the
// target, dispatch through the DOM instead (targets the element
// directly). Skipped for strict `coord` mode and non-left/multi-clicks.
if mode != "coord"
&& button == "left"
&& click_count == 1
&& parse_ref(selector_or_ref).is_none()
&& point_misses_element(client, &effective_session_id, selector_or_ref).await
{
eprintln!(
"[click] target occluded at its click point; dispatching through \
the DOM (set AGENT_BROWSER_CLICK_MODE=coord to disable)"
);
return dom_click(
client,
session_id,
ref_map,
selector_or_ref,
iframe_sessions,
)
.await;
}
// Land on a jittered point inside the element rather than its exact
// centre (Fast/Human). Zero size or Off → exact centre.
let (tx, ty) = humanize::landing_point(
@@ -92,6 +119,42 @@ pub async fn click(
}
}
/// True if a coordinate click at the selector's centre would land on something
/// OTHER than the element (an overlay on top), i.e. the element is occluded.
/// `false` when not occluded, the element is missing, or the probe fails (so we
/// never block a click on a flaky probe — the normal coordinate path runs).
async fn point_misses_element(client: &CdpClient, session_id: &str, selector: &str) -> bool {
let js = format!(
r#"(() => {{
const el = document.querySelector({sel});
if (!el) return false;
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) return false;
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
if (!hit) return false;
// Not occluded if the hit is the element, a descendant, or an ancestor
// wrapper (clicking those still reaches the element's handlers).
return !(hit === el || el.contains(hit) || hit.contains(el));
}})()"#,
sel = serde_json::to_string(selector).unwrap_or_default()
);
match client
.send_command_typed::<_, EvaluateResult>(
"Runtime.evaluate",
&EvaluateParams {
expression: js,
return_by_value: Some(true),
await_promise: Some(false),
},
Some(session_id),
)
.await
{
Ok(r) => r.result.value.and_then(|v| v.as_bool()).unwrap_or(false),
Err(_) => false,
}
}
/// Best-effort scroll-into-view before a coordinate click. Uses Chrome's
/// `scrollIntoViewIfNeeded` (only scrolls when not already fully visible),
/// falling back to centered `scrollIntoView`. Resolution failures are ignored —
+17
View File
@@ -130,6 +130,20 @@ fn format_stream_status_text(action: Option<&str>, data: &serde_json::Value) ->
}
}
/// Shorten an over-long string by keeping its head and tail and eliding the
/// middle, with a char count. Used so multi-KB URLs (JWT/OTP login links) don't
/// flood `tab list`.
fn truncate_middle(s: &str, max: usize) -> String {
let n = s.chars().count();
if n <= max {
return s.to_string();
}
let keep = max.saturating_sub(1) / 2;
let head: String = s.chars().take(keep).collect();
let tail: String = s.chars().skip(n - keep).collect();
format!("{head}{tail} [{n} chars]")
}
pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &OutputOptions) {
if opts.json {
if opts.content_boundaries {
@@ -422,6 +436,9 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
.and_then(|v| v.as_str())
.unwrap_or("Untitled");
let url = tab.get("url").and_then(|v| v.as_str()).unwrap_or("");
// Truncate very long URLs (e.g. multi-KB JWT/OTP login links) so
// the list stays readable instead of flooding the terminal.
let url = truncate_middle(url, 120);
let active = tab.get("active").and_then(|v| v.as_bool()).unwrap_or(false);
let marker = if active {
color::cyan("")
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agent-browser-stealth",
"version": "0.27.0-fork.41",
"version": "0.27.0-fork.43",
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
"type": "module",
"packageManager": "pnpm@11.1.3",
+19 -9
View File
@@ -182,14 +182,17 @@ Snapshot output looks like:
Page: Example - Log in
URL: https://example.com/login
@e1 [heading] "Log in"
@e2 [form]
@e3 [input type="email"] placeholder="Email"
@e4 [input type="password"] placeholder="Password"
@e5 [button type="submit"] "Continue"
@e6 [link] "Forgot password?"
- heading "Log in" [level=1, ref=e1]
- textbox "Email" [ref=e2]
- textbox "Password" [ref=e3]
- button "Continue" [ref=e4]
- link "Forgot password?" [ref=e5]
```
Each line is `- <role> "<accessible name>" [<attrs>, ref=eN]`, indented by nesting
depth. You pass the ref to commands as `@eN` (e.g. `click @e4`). Refs are
assigned fresh on every snapshot.
For unstructured reading (no refs needed):
```bash
@@ -386,9 +389,16 @@ Array.from(rows).map(r => ({
EOF
```
Prefer `eval --stdin` (heredoc) or `eval -b <base64>` for any JS with
quotes or special characters. Inline `agent-browser eval "..."` works
only for simple expressions.
Prefer `eval --stdin` (heredoc), `eval --file <path>`, or `eval -b <base64>`
for any JS with quotes, **non-ASCII identifiers/strings (e.g. Chinese)**, or
large scripts — inline `agent-browser eval "..."` is shell-mangled and works
only for simple ASCII expressions.
**`eval` runs in the page's MAIN world and state persists across calls**, so a
top-level `const x`/`let x`/`var x` in one call collides with the next
(`SyntaxError: Identifier 'x' has already been declared`). Either use unique
names, assign to `window.x`, or wrap the body in an IIFE
(`(() => { const x = …; return x; })()`).
### Screenshot