Compare commits

...
Author SHA1 Message Date
leeguooooo 649fa4ce94 chore(release): 0.27.0-fork.47 — tab-drift pin, snapshot -c keeps interactive, stale-ref guidance
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:15:38 +09:00
leeguooooo 3ac69e822a fix: keep interactive nodes in snapshot -c; better stale-ref guidance (issue #2/#3)
- snapshot -c (compact) now always keeps lines with an interactive ARIA role
  (button/link/textbox/combobox/option/…), not only `ref=`/`": "` lines — so a
  clickable control can't vanish from compact output and leave the agent clicking
  an empty ref (issue #2 P1). Additive: only ever keeps more. compact tests green.
- stale-ref error now leads with "take a fresh snapshot" and points to the `eval`
  fallback for ref-churning SPAs, and demotes AGENT_BROWSER_VERIFY_REF=0 to a
  flagged last resort instead of presenting it as the fix (issue #3 P1).
2026-06-11 23:15:37 +09:00
leeguooooo d7a0ed85f9 fix(tabs): pin the active tab by target_id — stop command drift (issue #2/#3 P0)
The session's active tab was a bare index into `pages`, which drifts when a
foreign/user/other-session tab is passively discovered, a tab closes, or the list
reorders — so `eval`/`screenshot`/`snapshot`/`click` could land on the wrong page.
With login state that's a safety bug (a fetch firing on the wrong origin), and it
made screenshot disagree with snapshot/eval.

Pin the intended tab by stable target_id (`active_target_id`), set on every
explicit open / tab new / tab switch / connect. `active_session_id` and
`active_target_id` resolve through it (falling back to the index only if the
pinned tab is gone), so all commands stick to the agent's tab regardless of
passive churn — and they all agree.

Verified (--cdp, multi-tab): a window.open foreign tab no longer drifts eval;
tab new / switch re-pin correctly.
2026-06-11 23:10:29 +09:00
6 changed files with 83 additions and 8 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ dependencies = [
[[package]]
name = "agent-browser-stealth"
version = "0.27.0-fork.46"
version = "0.27.0-fork.47"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-browser-stealth"
version = "0.27.0-fork.46"
version = "0.27.0-fork.47"
edition = "2021"
description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0"
+40 -2
View File
@@ -315,6 +315,13 @@ pub struct BrowserManager {
/// 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>,
/// The session's *intended* active tab, pinned by stable target_id rather
/// than the fragile `active_page_index`. Set on every explicit open / tab new
/// / tab switch. `active_session_id` resolves through this so a foreign tab
/// opening (passive discovery), a tab closing, or list reordering can't drift
/// the session's commands onto the wrong page — the wrong-origin-fetch hazard
/// in the dogfood reports. Falls back to the index if the pinned tab is gone.
active_target_id: Option<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
@@ -440,6 +447,7 @@ impl BrowserManager {
ignore_https_errors,
visited_origins: HashSet::new(),
created_targets: HashSet::new(),
active_target_id: None,
next_tab_id: 1,
capture_console: console_capture_enabled(),
};
@@ -531,6 +539,7 @@ impl BrowserManager {
ignore_https_errors: false,
visited_origins: HashSet::new(),
created_targets: HashSet::new(),
active_target_id: None,
next_tab_id: 1,
capture_console: console_capture_enabled(),
};
@@ -547,6 +556,7 @@ impl BrowserManager {
target_type: "page".to_string(),
});
manager.active_page_index = 0;
manager.pin_active_target();
manager.enable_domains_direct().await?;
} else {
manager.discover_and_attach_targets().await?;
@@ -621,6 +631,7 @@ impl BrowserManager {
target_type: "page".to_string(),
});
self.active_page_index = 0;
self.pin_active_target();
self.enable_domains(&attach_result.session_id).await?;
} else {
for target in &page_targets {
@@ -650,6 +661,7 @@ impl BrowserManager {
}
self.active_page_index = 0;
self.pin_active_target();
let session_id = self.pages[0].session_id.clone();
self.enable_domains(&session_id).await?;
}
@@ -736,9 +748,31 @@ impl BrowserManager {
Ok(())
}
/// Index of the session's active page, resolved through the pinned
/// `active_target_id` (stable across reorder/removal/passive discovery) and
/// falling back to `active_page_index` when nothing is pinned or the pin is
/// gone. This is what keeps commands on the tab the agent actually opened.
fn resolved_active_index(&self) -> usize {
if let Some(tid) = &self.active_target_id {
if let Some(i) = self.pages.iter().position(|p| &p.target_id == tid) {
return i;
}
}
self.active_page_index
}
/// Pin the current active page by target_id so later commands stick to it.
/// Call after any explicit open / tab new / tab switch.
fn pin_active_target(&mut self) {
self.active_target_id = self
.pages
.get(self.active_page_index)
.map(|p| p.target_id.clone());
}
pub fn active_session_id(&self) -> Result<&str, String> {
self.pages
.get(self.active_page_index)
.get(self.resolved_active_index())
.map(|p| p.session_id.as_str())
.ok_or_else(|| "No active page".to_string())
}
@@ -991,7 +1025,7 @@ impl BrowserManager {
pub fn active_target_id(&self) -> Result<&str, String> {
self.pages
.get(self.active_page_index)
.get(self.resolved_active_index())
.map(|p| p.target_id.as_str())
.ok_or_else(|| "No active page".to_string())
}
@@ -1219,6 +1253,7 @@ impl BrowserManager {
target_type: "page".to_string(),
});
self.active_page_index = index;
self.pin_active_target();
Ok(json!({
"tabId": format_tab_id(tab_id),
@@ -1238,6 +1273,7 @@ impl BrowserManager {
}
self.active_page_index = index;
self.pin_active_target();
let session_id = self.pages[index].session_id.clone();
self.enable_domains(&session_id).await?;
@@ -1581,6 +1617,7 @@ impl BrowserManager {
let index = self.pages.len();
self.pages.push(page);
self.active_page_index = index;
self.pin_active_target();
}
/// Add a passively-discovered page WITHOUT changing the active tab.
@@ -1783,6 +1820,7 @@ async fn initialize_lightpanda_manager(
ignore_https_errors: false,
visited_origins: HashSet::new(),
created_targets: HashSet::new(),
active_target_id: None,
next_tab_id: 1,
capture_console: console_capture_enabled(),
};
+6 -2
View File
@@ -556,8 +556,12 @@ async fn verify_ref_identity(
Err(format!(
"Ref {} no longer matches its snapshot. Was [{} \"{}\"], now [{} \"{}\"].\n\
The DOM mutated between snapshot and interaction (typical with React/Vue \
reusing nodes during re-render). Take a fresh snapshot, then re-target.\n\
To bypass this guard set AGENT_BROWSER_VERIFY_REF=0.",
reusing nodes during re-render). Fix: take a fresh `snapshot` and re-target \
with the new ref. For SPAs where refs churn every interaction, drive the \
element directly with `eval` (e.g. `eval \"document.querySelector(...).click()\"`), \
which doesn't depend on refs.\n\
(Last resort: AGENT_BROWSER_VERIFY_REF=0 disables this safety check — only \
if you accept clicks may land on a re-rendered/wrong node.)",
ref_id, expected_role, expected_name, actual_role, actual_name,
))
}
+34 -1
View File
@@ -1305,6 +1305,39 @@ fn render_tree(
}
}
/// True if a snapshot line names an interactive ARIA role. Compaction keeps
/// these even without a `ref=`/`": "` marker, so a clickable control never gets
/// dropped from `-c` output (the dogfood reports saw a button present in the full
/// snapshot vanish from compact, leaving the agent clicking an empty ref).
fn is_interactive_line(line: &str) -> bool {
const ROLES: &[&str] = &[
"button",
"link",
"textbox",
"checkbox",
"radio",
"combobox",
"listbox",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"option",
"switch",
"slider",
"spinbutton",
"searchbox",
"tab ",
"clickable",
"focusable",
"editable",
];
let t = line.trim_start();
// Lines look like `- button "Label" [ref=e1]`; match the role token after the
// leading "- " marker.
let t = t.strip_prefix("- ").unwrap_or(t);
ROLES.iter().any(|r| t.starts_with(r))
}
fn compact_tree(tree: &str, interactive: bool) -> String {
let lines: Vec<&str> = tree.lines().collect();
if lines.is_empty() {
@@ -1314,7 +1347,7 @@ fn compact_tree(tree: &str, interactive: bool) -> String {
let mut keep = vec![false; lines.len()];
for (i, line) in lines.iter().enumerate() {
if line.contains("ref=") || line.contains(": ") {
if line.contains("ref=") || line.contains(": ") || is_interactive_line(line) {
keep[i] = true;
// Mark ancestors
let my_indent = count_indent(line);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agent-browser-stealth",
"version": "0.27.0-fork.46",
"version": "0.27.0-fork.47",
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
"type": "module",
"packageManager": "pnpm@11.1.3",