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
12 changed files with 352 additions and 40 deletions
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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]
+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.
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
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)
.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> {
+76
View File
@@ -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()];
+14
View File
@@ -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,
+28 -1
View File
@@ -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);
+18
View File
@@ -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
+25 -4
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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",