Compare commits

...
Author SHA1 Message Date
leeguooooo 0cf7de2dd6 style: rustfmt the issue #7 regression tests (CI format gate)
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-12 01:42:36 +09:00
leeguooooo 658bf4226f chore(release): 0.27.0-fork.51 — --launch Illegal-invocation fix, tab-pin hardening (#7), field-report ergonomics + observability (#8)
- fix(stealth): bind iframe contentWindow proxy methods to the real window
  (--launch "Illegal invocation" on srcdoc-iframe pages)
- fix(tabs): re-pin active target when the pinned page is removed (issue #7)
- feat(cli): coordinate click (click <x> <y> / --coords), tabs/get-text
  aliases, clearer find error (issue #8.4)
- fix(observability): screenshot/network stamp @ <url>; network --clear
  enables capture immediately (issues #8.1/#8.3)
- fix(ab-connect 0.4.2): stale sessionId fails loudly instead of routing to a
  random tab (issue #8.1) — needs a Chrome Web Store republish
- restart notice now flags in-memory context reset (issue #8.2)
2026-06-12 01:39:29 +09:00
leeguooooo 37cd9b91e1 fix(ab-connect): fail loudly on a stale sessionId instead of routing to a random tab (issue #8.1)
handleForwardCdpCommand fell through to anyConnectedTab() when a
daemon-supplied sessionId didn't map to an attached tab, so eval/screenshot/
network silently ran on an arbitrary tab — the root of "eval ran on the
wrong page, no warning" and the blank-screenshot-after-restart symptom.

Now: a provided sessionId/targetId MUST resolve to a real tab or the command
throws an actionable error ("stale sessionId … re-open your target URL").
anyConnectedTab() is only used for genuinely browser-level commands that
specify neither. Manifest 0.4.1 → 0.4.2 (needs a Chrome Web Store republish
for installed users to pick this up).
2026-06-12 01:34:15 +09:00
leeguooooo b2c4aa0004 fix(observability): stamp page URL on screenshot/network; enable capture on --clear (issue #8)
Field report #8: in extension-relay sessions, reads (eval/screenshot/network)
could silently run against whatever tab drifted into focus, with no signal,
and network capture was intermittently empty.

- #8.1: screenshot and `network requests` now print `screenshot @ <url>` /
  `network @ <url>` to stderr (mirrors the existing `eval @ <url>`), and the
  responses carry `origin`. A read against the wrong/drifted tab — and the
  "0 captured" vs "wrong page" ambiguity — is now obvious.
- #8.3: `network requests --clear` now enables Network capture immediately
  instead of lazily on the next read, so requests fired between `--clear` and
  the following read are tracked (fixes the "No requests captured" on first
  try, works on retry" race). Extracted enable_request_tracking helper.
- #8.2: the daemon version-mismatch restart notice now spells out that
  in-memory context (active tab, refs, captured requests) is reset and tells
  the user to re-open the target URL if the next read looks blank/wrong.

Verified on a launched browser: coordinate clicks land, screenshot/network
stamps appear, and a fetch after --clear is captured on the first read.
2026-06-12 01:34:15 +09:00
leeguooooo ec8d01ef4c feat(cli): coordinate click + command aliases + clearer find error (issue #8.4)
Field-report ergonomics fixes so agents stop wasting a round on a wrong guess:

- Coordinate click is now first-class: `click <x> <y>`, `click <x>,<y>`,
  and `click --coords <x>,<y>` dispatch a raw viewport-point click (no
  element resolution), reusing the humanize trajectory + press dwell. Was
  previously only reachable via eval(elementFromPoint(...).click()).
- Aliases: `tabs` (plural) → the `tab` subcommand tree; `get-text`/`get_text`
  → `get text <selector>`.
- `find <value> <action>` with a bare value (no locator keyword), e.g.
  `find "I'm not a robot" click`, now errors with the corrected command
  (`find text "I'm not a robot" click`) plus concrete examples, instead of
  a bare "Valid options: role, text, ..." list.

Adds parse-layer regression tests for every form.
2026-06-12 01:21:39 +09:00
leeguooooo 0e5409a81e fix(tabs): re-pin active target when the pinned page is removed (issue #7)
remove_page_by_target_id left active_target_id dangling when the pinned
page itself was removed, so resolved_active_index silently fell back to
active_page_index — which after a passive about:blank discovery can point
at a blank tab. That matches issue #7's intermittent symptom: `wait` then
eval/snapshot landing on about:blank in a --launch session.

Re-pin to the surviving active page after removing the pinned target so
the pin is never left pointing at a target that no longer exists. Adds
pure regression tests for the re-anchor invariant (BrowserManager needs a
live CDP client, so the method can't be unit-constructed directly).
2026-06-12 01:05:33 +09:00
leeguooooo 3ded30c210 fix(stealth): bind iframe contentWindow proxy methods to the real window
The srcdoc-iframe contentWindow Proxy returned native window methods
unbound, so iframe.contentWindow.getComputedStyle()/addEventListener()/
setTimeout() ran with the Proxy as `this` and threw "Illegal invocation"
on any page that uses a srcdoc iframe under --launch (FullLaunch). The
sibling matchMedia proxy already bound its methods; this one did not.

Wrap each function in an apply/construct trap that swaps the Proxy
receiver for the real window while passing .prototype/.name/.toString/
identity straight through (a plain .bind() drops .prototype and breaks
instanceof/constructors). Cached in a WeakMap for stable identity.

Verified before/after on a launched stealth browser: getComputedStyle,
addEventListener, setTimeout all OK; .prototype preserved.
2026-06-12 01:05:33 +09:00
leeguooooo 6e50f0ecab chore(release): 0.27.0-fork.50 — tab-title truncation + multi-agent/eval/type 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 23:55:01 +09:00
leeguooooo 6f71f4e1ff fix: truncate tab-list title too; doc raw --cdp isolation limit + eval/type notes
From Hermes's fork.49 re-dogfood (9/11 fixes confirmed PASS):
- tab list: a page can set its title to a multi-KB string (= a giant URL); cap
  the title column like the URL so the row stays readable.
- skill: clarify that true multi-agent isolation needs the extension-connect path
  (per-session tab groups) — raw `--cdp` shares the browser, so a sibling
  session's `open` can navigate your tab. Use the extension for concurrent agents.
- skill: prefer `eval --json` for array/object results (plain render is
  multi-line / pipe-hostile); note type/fill don't fire keydown (use `keyboard
  type` when key events are required).

(Hermes's "find-text click bypasses humanize" was a false alarm — verified both
paths curve; the apparent 1-vs-12 was cursor continuity on the same target.)
2026-06-11 23:55:00 +09:00
13 changed files with 374 additions and 45 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.49" version = "0.27.0-fork.51"
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.49" version = "0.27.0-fork.51"
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"
+126 -18
View File
@@ -375,12 +375,25 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
// === Core Actions === // === Core Actions ===
"click" => { "click" => {
let new_tab = rest.contains(&"--new-tab"); let new_tab = rest.contains(&"--new-tab");
// Coordinate click as a first-class form (issue #8.4): when the only
// handle is a pixel position, no element/selector is needed.
// click <x> <y> e.g. click 449 320
// click <x>,<y> e.g. click 449,320
// click --coords <x>,<y> | --coords <x> <y>
let coord_args: Vec<&str> = rest
.iter()
.copied()
.filter(|a| *a != "--new-tab" && *a != "--coords")
.collect();
if let Some((x, y)) = parse_coords(&coord_args) {
return Ok(json!({ "id": id, "action": "click", "x": x, "y": y }));
}
let sel = rest let sel = rest
.iter() .iter()
.find(|arg| **arg != "--new-tab") .find(|arg| **arg != "--new-tab")
.ok_or_else(|| ParseError::MissingArguments { .ok_or_else(|| ParseError::MissingArguments {
context: "click".to_string(), context: "click".to_string(),
usage: "click <selector> [--new-tab]", usage: "click <selector> | click <x> <y> | click --coords <x>,<y> [--new-tab]",
})?; })?;
if new_tab { if new_tab {
Ok(json!({ "id": id, "action": "click", "selector": sel, "newTab": true })) Ok(json!({ "id": id, "action": "click", "selector": sel, "newTab": true }))
@@ -1208,6 +1221,15 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
parse_get(&get_args, &id) parse_get(&get_args, &id)
} }
// Hyphen/underscore aliases for `get text <selector>` — agents naturally
// guess `get-text` / `get_text` (issue #8.4).
"get-text" | "get_text" => {
let mut get_args: Vec<&str> = Vec::with_capacity(rest.len() + 1);
get_args.push("text");
get_args.extend_from_slice(&rest);
parse_get(&get_args, &id)
}
// === Is (state checks) === // === Is (state checks) ===
"is" => parse_is(&rest, &id), "is" => parse_is(&rest, &id),
@@ -1389,7 +1411,9 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
} }
// === Tabs === // === Tabs ===
"tab" => { // `tabs` (plural) is a natural guess for the `tab` subcommand tree —
// alias it so `tabs` / `tabs list` / `tabs new` all work (issue #8.4).
"tab" | "tabs" => {
match rest.first().copied() { match rest.first().copied() {
Some("new") => { Some("new") => {
// Accepted forms: // Accepted forms:
@@ -2313,19 +2337,6 @@ fn parse_is(rest: &[&str], id: &str) -> Result<Value, ParseError> {
} }
fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> { fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
const VALID: &[&str] = &[
"role",
"text",
"label",
"placeholder",
"alt",
"title",
"testid",
"first",
"last",
"nth",
];
let locator = rest.first().ok_or_else(|| ParseError::MissingArguments { let locator = rest.first().ok_or_else(|| ParseError::MissingArguments {
context: "find".to_string(), context: "find".to_string(),
usage: "find <locator> <value> [action] [text]", usage: "find <locator> <value> [action] [text]",
@@ -2495,13 +2506,37 @@ fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
} }
Ok(cmd) Ok(cmd)
} }
_ => Err(ParseError::UnknownSubcommand { _ => Err(ParseError::InvalidValue {
subcommand: locator.to_string(), // The user passed a value where a locator keyword was expected — the
valid_options: VALID, // classic `find "I'm not a robot" click` mistake (issue #8.4). Lead
// with the corrected command using their own value, then the menu.
message: format!(
"`{loc}` is not a find locator. To match by visible text, name the locator:\n \
agent-browser find text \"{loc}\" click\n\n\
Locators: role, text, label, placeholder, alt, title, testid, first, last, nth\n\
Examples:\n \
agent-browser find text \"Sign in\" click\n \
agent-browser find role button --name \"Submit\" click\n \
agent-browser find label \"Email\" fill you@example.com",
loc = locator,
),
usage: "find <locator> <value> [action] [text]",
}), }),
} }
} }
/// Parse a coordinate pair from `["449","320"]`, `["449,320"]`, or `["449, 320"]`.
/// Returns None if the args aren't a clean numeric pair (so callers fall back to
/// treating the argument as a selector). Used by first-class coordinate `click`.
fn parse_coords(args: &[&str]) -> Option<(f64, f64)> {
let (a, b) = match args {
[one] => one.split_once(',')?,
[a, b] => (*a, *b),
_ => return None,
};
Some((a.trim().parse().ok()?, b.trim().parse().ok()?))
}
fn parse_mouse(rest: &[&str], id: &str) -> Result<Value, ParseError> { fn parse_mouse(rest: &[&str], id: &str) -> Result<Value, ParseError> {
const VALID: &[&str] = &["move", "down", "up", "wheel"]; const VALID: &[&str] = &["move", "down", "up", "wheel"];
@@ -3550,6 +3585,79 @@ mod tests {
assert_eq!(cmd["action"], "reload"); assert_eq!(cmd["action"], "reload");
} }
// === issue #8.4: CLI ergonomics ===
#[test]
fn test_click_coords_two_args() {
let cmd = parse_command(&args("click 449 320"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "click");
assert_eq!(cmd["x"], 449.0);
assert_eq!(cmd["y"], 320.0);
assert!(cmd.get("selector").is_none());
}
#[test]
fn test_click_coords_comma() {
let cmd = parse_command(&args("click 449,320"), &default_flags()).unwrap();
assert_eq!(cmd["x"], 449.0);
assert_eq!(cmd["y"], 320.0);
}
#[test]
fn test_click_coords_flag() {
let cmd = parse_command(&args("click --coords 449,320"), &default_flags()).unwrap();
assert_eq!(cmd["x"], 449.0);
assert_eq!(cmd["y"], 320.0);
}
#[test]
fn test_click_selector_not_coords() {
let cmd = parse_command(&args("click button.submit"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "click");
assert_eq!(cmd["selector"], "button.submit");
assert!(cmd.get("x").is_none());
}
#[test]
fn test_tabs_alias_lists() {
assert_eq!(
parse_command(&args("tabs"), &default_flags()).unwrap()["action"],
"tab_list"
);
assert_eq!(
parse_command(&args("tabs list"), &default_flags()).unwrap()["action"],
"tab_list"
);
assert_eq!(
parse_command(&args("tabs new"), &default_flags()).unwrap()["action"],
"tab_new"
);
}
#[test]
fn test_get_text_hyphen_and_underscore_aliases() {
for verb in ["get-text", "get_text"] {
let cmd = parse_command(&args(&format!("{verb} .price")), &default_flags()).unwrap();
assert_eq!(cmd["action"], "gettext", "{verb}");
assert_eq!(cmd["selector"], ".price", "{verb}");
}
}
#[test]
fn test_find_bare_value_suggests_text_locator() {
// `find "I'm not a robot" click` — value where a locator keyword was
// expected. Error must steer to the corrected `find text ...` form.
let input: Vec<String> = vec![
"find".to_string(),
"I'm not a robot".to_string(),
"click".to_string(),
];
let err = parse_command(&input, &default_flags()).unwrap_err();
let msg = err.format();
assert!(msg.contains("find text"), "got: {msg}");
assert!(msg.contains("I'm not a robot"), "got: {msg}");
}
// === Core Actions === // === Core Actions ===
#[test] #[test]
+4 -1
View File
@@ -625,7 +625,10 @@ pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult
// version (e.g. after an upgrade), kill it and start a fresh one. // version (e.g. after an upgrade), kill it and start a fresh one.
if !daemon_version_matches(session) { if !daemon_version_matches(session) {
eprintln!( eprintln!(
"{} Daemon version mismatch detected, restarting...", "{} Daemon version mismatch detected, restarting... \
In-memory context (active tab, refs, captured requests) is reset. \
If the next read looks blank or lands on the wrong page, re-open \
your target URL before retrying (issue #8.2).",
crate::color::warning_indicator() crate::color::warning_indicator()
); );
// Best-effort: ask the old daemon for its current URL so the // Best-effort: ask the old daemon for its current URL so the
+57 -12
View File
@@ -2981,11 +2981,32 @@ async fn handle_screenshot(cmd: &Value, state: &mut DaemonState) -> Result<Value
response["annotations"] = serde_json::to_value(&result.annotations) response["annotations"] = serde_json::to_value(&result.annotations)
.map_err(|e| format!("Failed to serialize annotations: {}", e))?; .map_err(|e| format!("Failed to serialize annotations: {}", e))?;
} }
// Stamp which page was captured so a screenshot of the wrong tab is obvious
// (issue #8.1: relay sessions can drift to whatever tab the user activated).
if let Ok(url) = mgr.get_url().await {
if !url.is_empty() {
response["origin"] = json!(url);
}
}
Ok(response) Ok(response)
} }
async fn handle_click(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> { async fn handle_click(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
// First-class coordinate click (issue #8.4): click a raw viewport point with
// no element resolution. Parsed from `click <x> <y>` / `click --coords x,y`.
if let (Some(x), Some(y)) = (
cmd.get("x").and_then(|v| v.as_f64()),
cmd.get("y").and_then(|v| v.as_f64()),
) {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
let session_id = mgr.active_session_id()?.to_string();
let button = cmd.get("button").and_then(|v| v.as_str()).unwrap_or("left");
let click_count = cmd.get("clickCount").and_then(|v| v.as_i64()).unwrap_or(1) as i32;
interaction::click_at_point(&mgr.client, &session_id, x, y, button, click_count).await?;
return Ok(json!({ "clicked": { "x": x, "y": y } }));
}
let selector = cmd let selector = cmd
.get("selector") .get("selector")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@@ -7900,23 +7921,40 @@ pub fn matches_status_filter(status: Option<i64>, filter: &str) -> bool {
false false
} }
async fn enable_request_tracking(state: &mut DaemonState) {
if state.request_tracking {
return;
}
state.request_tracking = true;
if let Some(ref mgr) = state.browser {
if let Ok(session_id) = mgr.active_session_id() {
let _ = mgr
.client
.send_command_no_params("Network.enable", Some(session_id))
.await;
}
}
}
async fn handle_requests(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> { async fn handle_requests(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
if cmd.get("clear").and_then(|v| v.as_bool()).unwrap_or(false) { if cmd.get("clear").and_then(|v| v.as_bool()).unwrap_or(false) {
state.tracked_requests.clear(); state.tracked_requests.clear();
// Enable Network capture NOW, on `--clear`, not lazily on the next read.
// `--clear` is the canonical "start capturing fresh" call, so requests
// fired between it and the following `requests` read must be tracked.
// Lazy-enabling only on read missed exactly those → intermittent
// "No requests captured" on the first try, fine on retry (issue #8.3).
enable_request_tracking(state).await;
return Ok(json!({ "cleared": true })); return Ok(json!({ "cleared": true }));
} }
if !state.request_tracking { enable_request_tracking(state).await;
state.request_tracking = true; // Current page URL, so a `requests` read on a drifted/wrong tab is obvious
if let Some(ref mgr) = state.browser { // and "0 captured" can't be confused with "wrong page" (issues #8.1/#8.3).
if let Ok(session_id) = mgr.active_session_id() { let origin = match state.browser.as_ref() {
let _ = mgr Some(mgr) => mgr.get_url().await.ok().filter(|u| !u.is_empty()),
.client None => None,
.send_command_no_params("Network.enable", Some(session_id)) };
.await;
}
}
}
let filter = cmd.get("filter").and_then(|v| v.as_str()); let filter = cmd.get("filter").and_then(|v| v.as_str());
let type_filter = cmd.get("type").and_then(|v| v.as_str()); let type_filter = cmd.get("type").and_then(|v| v.as_str());
@@ -7953,7 +7991,14 @@ async fn handle_requests(cmd: &Value, state: &mut DaemonState) -> Result<Value,
}) })
.collect(); .collect();
Ok(json!({ "requests": requests })) // NB: do NOT add a top-level `count` field here — the human formatter treats
// any `{count}` as a `get count` result and prints just the number, which
// would swallow the request list. The list length is self-evident.
let mut response = json!({ "requests": requests });
if let Some(o) = origin {
response["origin"] = json!(o);
}
Ok(response)
} }
async fn handle_request_detail(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> { async fn handle_request_detail(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
+76
View File
@@ -1641,8 +1641,18 @@ impl BrowserManager {
pub fn remove_page_by_target_id(&mut self, target_id: &str) { pub fn remove_page_by_target_id(&mut self, target_id: &str) {
if let Some(pos) = self.pages.iter().position(|p| p.target_id == target_id) { if let Some(pos) = self.pages.iter().position(|p| p.target_id == target_id) {
let removed_was_pinned = self.active_target_id.as_deref() == Some(target_id);
self.pages.remove(pos); self.pages.remove(pos);
self.update_active_page_after_removal(pos); self.update_active_page_after_removal(pos);
// If we just removed the pinned active target, the pin now dangles and
// `resolved_active_index` silently falls back to `active_page_index`.
// After a passive about:blank discovery that index can point at a blank
// tab, so `wait` → eval/snapshot lands on about:blank (issue #7). Re-pin
// to the surviving active page so the pin is never left pointing at a
// target that no longer exists.
if removed_was_pinned {
self.pin_active_target();
}
} }
} }
@@ -2105,6 +2115,72 @@ mod tests {
assert_eq!(active_page_index_after_removal(0, 0, 0), 0); assert_eq!(active_page_index_after_removal(0, 0, 0), 0);
} }
// issue #7: removing the pinned active target must re-anchor the pin to a
// surviving page. Models `remove_page_by_target_id`'s index + re-pin steps
// purely (BrowserManager needs a live CDP client, so the method itself can't
// be unit-constructed). The invariant: after removal the pin never dangles
// and never silently resolves to a passively-discovered about:blank tab.
fn simulate_remove(
target_ids: &[&str],
active_index: usize,
pinned: &str,
remove_id: &str,
) -> (Vec<String>, usize, Option<String>) {
let pos = target_ids.iter().position(|t| *t == remove_id).unwrap();
let removed_was_pinned = pinned == remove_id;
let mut pages: Vec<String> = target_ids.iter().map(|s| s.to_string()).collect();
pages.remove(pos);
let new_active = active_page_index_after_removal(active_index, pos, pages.len());
let new_pin = if removed_was_pinned {
pages.get(new_active).cloned()
} else {
Some(pinned.to_string())
};
(pages, new_active, new_pin)
}
fn resolve_active<'a>(
pages: &'a [String],
active_index: usize,
pin: &Option<String>,
) -> &'a str {
if let Some(tid) = pin {
if let Some(p) = pages.iter().find(|p| *p == tid) {
return p;
}
}
pages.get(active_index).map(|s| s.as_str()).unwrap_or("")
}
#[test]
fn test_removing_unpinned_blank_keeps_pin_on_real_page() {
// pages = [creepjs(pinned, active), about:blank]; a passive blank closes.
let (pages, active, pin) = simulate_remove(&["creepjs", "blank"], 0, "creepjs", "blank");
assert_eq!(resolve_active(&pages, active, &pin), "creepjs");
}
#[test]
fn test_removing_pinned_page_repins_to_survivor_not_dangling() {
// pages = [blank, creepjs(pinned, active)]; the pinned page itself closes.
let (pages, active, pin) = simulate_remove(&["blank", "creepjs"], 1, "creepjs", "creepjs");
// pin must point at a page that still exists (no dangling fallback).
let resolved = resolve_active(&pages, active, &pin);
assert!(
pages.iter().any(|p| p == resolved),
"resolved a dangling target"
);
assert_eq!(resolved, "blank");
}
#[test]
fn test_resolve_falls_back_cleanly_when_pin_dangles() {
// A stale pin (target already gone) must resolve to a real surviving page,
// never panic or return the missing id.
let pages = vec!["creepjs".to_string(), "blank".to_string()];
let pin = Some("gone".to_string());
assert_eq!(resolve_active(&pages, 0, &pin), "creepjs");
}
#[test] #[test]
fn test_validate_launch_options_extensions_and_cdp() { fn test_validate_launch_options_extensions_and_cdp() {
let ext = vec!["/path/to/ext".to_string()]; let ext = vec!["/path/to/ext".to_string()];
+14
View File
@@ -1143,6 +1143,20 @@ async fn wait_for_paint_settled(client: &CdpClient, session_id: &str) {
.await; .await;
} }
/// Click at a raw viewport coordinate, bypassing element/selector resolution
/// (issue #8.4 first-class coordinate click). Honors the humanize trajectory and
/// press dwell exactly like a selector click — it shares `dispatch_click`.
pub async fn click_at_point(
client: &CdpClient,
session_id: &str,
x: f64,
y: f64,
button: &str,
click_count: i32,
) -> Result<(), String> {
dispatch_click(client, session_id, x, y, button, click_count).await
}
async fn dispatch_click( async fn dispatch_click(
client: &CdpClient, client: &CdpClient,
session_id: &str, session_id: &str,
+28 -1
View File
@@ -308,12 +308,39 @@ const __abRedefineNavProto = (name, getterImpl) => {
try { try {
if (iframe.contentWindow) return; if (iframe.contentWindow) return;
} catch {} } catch {}
// Native window methods are bound to the real Window via an internal slot;
// calling them with the Proxy as `this` throws "Illegal invocation". Wrap
// each function in an apply/construct trap that swaps the Proxy receiver for
// the real window, while passing `.prototype`/`.name`/`.toString`/identity
// straight through (a plain `.bind()` would drop `.prototype` and break
// `instanceof`). Cached so repeated reads return the same function.
const fnProxyCache = new WeakMap();
const bindToRealWindow = (fn) => {
let wrapped = fnProxyCache.get(fn);
if (wrapped) return wrapped;
try {
wrapped = new Proxy(fn, {
apply(target, thisArg, args) {
return Reflect.apply(target, thisArg === proxy ? window : thisArg, args);
},
construct(target, args, newTarget) {
return Reflect.construct(target, args, newTarget);
},
});
} catch {
wrapped = fn;
}
fnProxyCache.set(fn, wrapped);
return wrapped;
};
const proxy = new Proxy(window, { const proxy = new Proxy(window, {
get(target, key) { get(target, key) {
if (key === 'self') return proxy; if (key === 'self') return proxy;
if (key === 'frameElement') return iframe; if (key === 'frameElement') return iframe;
if (key === '0') return undefined; if (key === '0') return undefined;
return Reflect.get(target, key, target); const value = Reflect.get(target, key, target);
if (typeof value === 'function') return bindToRealWindow(value);
return value;
}, },
}); });
iframeProxyMap.set(iframe, proxy); iframeProxyMap.set(iframe, proxy);
+23
View File
@@ -478,6 +478,11 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
.get("title") .get("title")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("Untitled"); .unwrap_or("Untitled");
// A page can set its title to a multi-KB string (e.g. equal to a
// giant JWT/OTP URL); truncate it like the URL so the row stays
// readable.
let title = truncate_middle(title, 120);
let title = title.as_str();
let url = tab.get("url").and_then(|v| v.as_str()).unwrap_or(""); 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 // Truncate very long URLs (e.g. multi-KB JWT/OTP login links) so
// the list stays readable instead of flooding the terminal. // the list stays readable instead of flooding the terminal.
@@ -590,6 +595,15 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
} }
// Network requests // Network requests
if let Some(requests) = data.get("requests").and_then(|v| v.as_array()) { if let Some(requests) = data.get("requests").and_then(|v| v.as_array()) {
// Stamp the page these requests were read from, mirroring `eval @ url`,
// so a read against a drifted/wrong tab is obvious (issue #8.1).
if let Some(o) = data
.get("origin")
.and_then(|v| v.as_str())
.filter(|o| !o.is_empty())
{
eprintln!("network @ {o}");
}
if requests.is_empty() { if requests.is_empty() {
println!("No requests captured"); println!("No requests captured");
} else { } else {
@@ -793,6 +807,15 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
color::success_indicator(), color::success_indicator(),
color::green(path) color::green(path)
); );
// Stamp which page was captured (mirrors `eval @ url`) so a
// screenshot of the wrong/drifted tab is obvious (issue #8.1).
if let Some(o) = data
.get("origin")
.and_then(|v| v.as_str())
.filter(|o| !o.is_empty())
{
eprintln!("screenshot @ {o}");
}
if let Some(annotations) = data.get("annotations").and_then(|v| v.as_array()) { if let Some(annotations) = data.get("annotations").and_then(|v| v.as_array()) {
// Cap the printed legend on dense pages (it can be // Cap the printed legend on dense pages (it can be
// hundreds of lines and flood the terminal). The image // hundreds of lines and flood the terminal). The image
+25 -4
View File
@@ -199,10 +199,31 @@ async function handleForwardCdpCommand(msg) {
} }
// Everything else → chrome.debugger on the resolved tab. // Everything else → chrome.debugger on the resolved tab.
const tabId = //
(sessionId ? tabForSession(sessionId) : null) ?? // A daemon-supplied sessionId/targetId MUST resolve to a real attached tab.
(typeof params?.targetId === 'string' ? tabForTarget(params.targetId) : null) ?? // The old code fell through to anyConnectedTab() when it didn't, which
anyConnectedTab() // silently ran the command (eval/screenshot/network) on an arbitrary tab —
// exactly the "ran on the wrong page with no warning" failure in issue #8.1,
// and the blank-screenshot symptom after a service-worker restart (#8.2).
// Fail loudly instead so the agent sees an actionable error, not bad data.
let tabId
if (sessionId) {
tabId = tabForSession(sessionId)
if (!tabId) {
throw new Error(
`stale sessionId ${sessionId} for ${method}: its tab is gone (closed, ` +
`navigated across processes, or lost after an extension restart). ` +
`Re-attach by re-opening your target URL before retrying.`,
)
}
} else if (typeof params?.targetId === 'string') {
tabId = tabForTarget(params.targetId)
if (!tabId) throw new Error(`no attached tab for targetId ${params.targetId} (${method})`)
} else {
// No session/target specified — a browser-level command that legitimately
// applies to any attached tab.
tabId = anyConnectedTab()
}
if (!tabId) throw new Error(`no attached tab for ${method}`) if (!tabId) throw new Error(`no attached tab for ${method}`)
const dbg = { tabId } const dbg = { tabId }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "agent-browser-stealth", "name": "agent-browser-stealth",
"version": "0.4.1", "version": "0.4.2",
"description": "Let agent-browser drive your logged-in Chrome \u2014 install once, no token, no per-use confirmation.", "description": "Let agent-browser drive your logged-in Chrome \u2014 install once, no token, no per-use confirmation.",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB",
"icons": { "icons": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agent-browser-stealth", "name": "agent-browser-stealth",
"version": "0.27.0-fork.49", "version": "0.27.0-fork.51",
"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",
"packageManager": "pnpm@11.1.3", "packageManager": "pnpm@11.1.3",
+17 -5
View File
@@ -408,6 +408,13 @@ top-level `const x`/`let x`/`var x` in one call collides with the next
names, assign to `window.x`, or wrap the body in an IIFE names, assign to `window.x`, or wrap the body in an IIFE
(`(() => { const x = …; return x; })()`). (`(() => { const x = …; return x; })()`).
**For array/object results, use `eval --json`** — the plain renderer
pretty-prints across multiple lines, which `tail`/`head`/pipes mangle; `--json`
emits one parseable line. Also note **`type`/`fill` insert text without firing
`keydown`/`keyup`** (CDP insertText) — the value lands, but a page that gates on
key events (some search-as-you-type widgets) won't react; use `keyboard type` (or
`press` per key) when real keystrokes are required.
### Screenshot ### Screenshot
```bash ```bash
@@ -453,11 +460,16 @@ shell.
**Concurrent agents MUST each use a distinct `--session <name>`.** Within one **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 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* tab can't drift your `eval`/`screenshot`). Two agents sharing the *same* session
session (e.g. both on the bare default) share one daemon and one active tab (e.g. both on the bare default) share one daemon and one active tab and will
they will clobber each other's tab/eval context. A unique session per agent gives clobber each other.
each its own isolated tab group (own cookies, tabs, pin) on the one real Chrome,
with no cross-talk. True multi-agent isolation requires the **extension-connect path**: each
`--session` gets its own colored Chrome tab group, so sessions never touch each
other's tabs. **Raw `--cdp <port>` does NOT isolate** — every session attaches to
the same browser's existing targets, so a second session's first `open` can
navigate a sibling's tab. For concurrent agents on one real Chrome, use the
extension (each with a distinct `--session`), not raw `--cdp`.
### Mock network requests ### Mock network requests