Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cf7de2dd6 | ||
|
|
658bf4226f | ||
|
|
37cd9b91e1 | ||
|
|
b2c4aa0004 | ||
|
|
ec8d01ef4c | ||
|
|
0e5409a81e | ||
|
|
3ded30c210 |
Generated
+1
-1
@@ -45,7 +45,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-browser-stealth"
|
||||
version = "0.27.0-fork.50"
|
||||
version = "0.27.0-fork.51"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-browser-stealth"
|
||||
version = "0.27.0-fork.50"
|
||||
version = "0.27.0-fork.51"
|
||||
edition = "2021"
|
||||
description = "Fast browser automation CLI for AI agents"
|
||||
license = "Apache-2.0"
|
||||
|
||||
+126
-18
@@ -375,12 +375,25 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
||||
// === Core Actions ===
|
||||
"click" => {
|
||||
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
|
||||
.iter()
|
||||
.find(|arg| **arg != "--new-tab")
|
||||
.ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "click".to_string(),
|
||||
usage: "click <selector> [--new-tab]",
|
||||
usage: "click <selector> | click <x> <y> | click --coords <x>,<y> [--new-tab]",
|
||||
})?;
|
||||
if new_tab {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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" => parse_is(&rest, &id),
|
||||
|
||||
@@ -1389,7 +1411,9 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
||||
}
|
||||
|
||||
// === 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() {
|
||||
Some("new") => {
|
||||
// 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> {
|
||||
const VALID: &[&str] = &[
|
||||
"role",
|
||||
"text",
|
||||
"label",
|
||||
"placeholder",
|
||||
"alt",
|
||||
"title",
|
||||
"testid",
|
||||
"first",
|
||||
"last",
|
||||
"nth",
|
||||
];
|
||||
|
||||
let locator = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "find".to_string(),
|
||||
usage: "find <locator> <value> [action] [text]",
|
||||
@@ -2495,13 +2506,37 @@ fn parse_find(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
||||
}
|
||||
Ok(cmd)
|
||||
}
|
||||
_ => Err(ParseError::UnknownSubcommand {
|
||||
subcommand: locator.to_string(),
|
||||
valid_options: VALID,
|
||||
_ => Err(ParseError::InvalidValue {
|
||||
// The user passed a value where a locator keyword was expected — the
|
||||
// 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> {
|
||||
const VALID: &[&str] = &["move", "down", "up", "wheel"];
|
||||
|
||||
@@ -3550,6 +3585,79 @@ mod tests {
|
||||
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 ===
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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.
|
||||
if !daemon_version_matches(session) {
|
||||
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()
|
||||
);
|
||||
// Best-effort: ask the old daemon for its current URL so the
|
||||
|
||||
+57
-12
@@ -2981,11 +2981,32 @@ async fn handle_screenshot(cmd: &Value, state: &mut DaemonState) -> Result<Value
|
||||
response["annotations"] = serde_json::to_value(&result.annotations)
|
||||
.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)
|
||||
}
|
||||
|
||||
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
|
||||
.get("selector")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -7900,23 +7921,40 @@ pub fn matches_status_filter(status: Option<i64>, filter: &str) -> bool {
|
||||
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> {
|
||||
if cmd.get("clear").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
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 }));
|
||||
}
|
||||
|
||||
if !state.request_tracking {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
enable_request_tracking(state).await;
|
||||
// Current page URL, so a `requests` read on a drifted/wrong tab is obvious
|
||||
// and "0 captured" can't be confused with "wrong page" (issues #8.1/#8.3).
|
||||
let origin = match state.browser.as_ref() {
|
||||
Some(mgr) => mgr.get_url().await.ok().filter(|u| !u.is_empty()),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let filter = cmd.get("filter").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();
|
||||
|
||||
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> {
|
||||
|
||||
@@ -1641,8 +1641,18 @@ impl BrowserManager {
|
||||
|
||||
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) {
|
||||
let removed_was_pinned = self.active_target_id.as_deref() == Some(target_id);
|
||||
self.pages.remove(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);
|
||||
}
|
||||
|
||||
// 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]
|
||||
fn test_validate_launch_options_extensions_and_cdp() {
|
||||
let ext = vec!["/path/to/ext".to_string()];
|
||||
|
||||
@@ -1143,6 +1143,20 @@ async fn wait_for_paint_settled(client: &CdpClient, session_id: &str) {
|
||||
.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(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
|
||||
@@ -308,12 +308,39 @@ const __abRedefineNavProto = (name, getterImpl) => {
|
||||
try {
|
||||
if (iframe.contentWindow) return;
|
||||
} 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, {
|
||||
get(target, key) {
|
||||
if (key === 'self') return proxy;
|
||||
if (key === 'frameElement') return iframe;
|
||||
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);
|
||||
|
||||
@@ -595,6 +595,15 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
}
|
||||
// Network requests
|
||||
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() {
|
||||
println!("No requests captured");
|
||||
} else {
|
||||
@@ -798,6 +807,15 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
color::success_indicator(),
|
||||
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()) {
|
||||
// Cap the printed legend on dense pages (it can be
|
||||
// hundreds of lines and flood the terminal). The image
|
||||
|
||||
@@ -199,10 +199,31 @@ async function handleForwardCdpCommand(msg) {
|
||||
}
|
||||
|
||||
// Everything else → chrome.debugger on the resolved tab.
|
||||
const tabId =
|
||||
(sessionId ? tabForSession(sessionId) : null) ??
|
||||
(typeof params?.targetId === 'string' ? tabForTarget(params.targetId) : null) ??
|
||||
anyConnectedTab()
|
||||
//
|
||||
// A daemon-supplied sessionId/targetId MUST resolve to a real attached tab.
|
||||
// The old code fell through to anyConnectedTab() when it didn't, which
|
||||
// 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}`)
|
||||
const dbg = { tabId }
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"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.",
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB",
|
||||
"icons": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agent-browser-stealth",
|
||||
"version": "0.27.0-fork.50",
|
||||
"version": "0.27.0-fork.51",
|
||||
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.1.3",
|
||||
|
||||
Reference in New Issue
Block a user