Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf4c27d13d | ||
|
|
372eaf2ef6 |
@@ -22,6 +22,47 @@ For basic usage, commands, and API reference, see the [upstream documentation](h
|
|||||||
| User collaboration | Separate window | Same window, take over anytime |
|
| User collaboration | Separate window | Same window, take over anytime |
|
||||||
| CAPTCHA | Agent stuck | You solve it, agent continues |
|
| CAPTCHA | Agent stuck | You solve it, agent continues |
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Your **agent-browser CLI** talks to a tiny **browser extension** over Chrome
|
||||||
|
**native messaging** — a local inter-process channel, *no network socket, no
|
||||||
|
token, no remote server*. The extension uses `chrome.debugger` to drive the tabs
|
||||||
|
you target in **your own, already-logged-in Chrome**, then hands results back to
|
||||||
|
the CLI. Everything stays on your machine.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Each `--session` gets its **own colored Chrome tab group**, so multiple agents
|
||||||
|
can share one real browser concurrently without stepping on each other — or your
|
||||||
|
own tabs.
|
||||||
|
|
||||||
|
## Why the extension (not a raw debug port)
|
||||||
|
|
||||||
|
Other local tools drive Chrome over a raw `--remote-debugging-port` (CDP). Since
|
||||||
|
**Chrome 136**, every such connection pops a blocking **"Allow remote debugging?"**
|
||||||
|
consent dialog — and the port has to be enabled up front. Our extension uses
|
||||||
|
native messaging instead: **install once, then zero per-use confirmation.**
|
||||||
|
|
||||||
|
| | **agent-browser-stealth** (this extension) | web-access (raw CDP port) | Claude in Chrome (chrome.debugger) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Connect method | native messaging — no port, no token | `--remote-debugging-port` | `chrome.debugger` |
|
||||||
|
| **"Allow remote debugging?" popup** | **never** ✅ | **every connection** 🔴 | no |
|
||||||
|
| Uses your real login | yes | yes | yes |
|
||||||
|
| `Runtime.enable` (CDP) leak¹ | **off by default → clean** ✅ | domain enabled | n/a |
|
||||||
|
| CreepJS stealth score² | **0% stealth · 0% headless** ✅ | real Chrome | real Chrome |
|
||||||
|
| Per-session tab groups / concurrent agents | **yes** ✅ | no | no |
|
||||||
|
| Built for the agent-browser CLI | yes | a separate proxy | a single-app assistant |
|
||||||
|
|
||||||
|
> ¹ Verified against [rebrowser-bot-detector](https://bot-detector.rebrowser.net/):
|
||||||
|
> our relay reports `runtimeEnableLeak: 🟢 No leak` and `navigatorWebdriver: 🟢`.
|
||||||
|
> ² Verified against [CreepJS](https://abrahamjuliot.github.io/creepjs/) on the
|
||||||
|
> connected real-Chrome path — see [Anti-detection](#anti-detection).
|
||||||
|
>
|
||||||
|
> The consent dialog isn't hypothetical: a raw-port tool pops it on **every**
|
||||||
|
> attach (Chrome 136+ security). The extension path never does.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
Generated
+1
-1
@@ -45,7 +45,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agent-browser-stealth"
|
name = "agent-browser-stealth"
|
||||||
version = "0.27.0-fork.31"
|
version = "0.27.0-fork.32"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "agent-browser-stealth"
|
name = "agent-browser-stealth"
|
||||||
version = "0.27.0-fork.31"
|
version = "0.27.0-fork.32"
|
||||||
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"
|
||||||
|
|||||||
+24
-2
@@ -540,7 +540,7 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
|
|
||||||
// === Wait ===
|
// === Wait ===
|
||||||
"wait" => {
|
"wait" => {
|
||||||
// Check for --url flag: wait --url "**/dashboard"
|
// Check for --url flag: wait --url "**/dashboard" [--timeout ms]
|
||||||
if let Some(idx) = rest.iter().position(|&s| s == "--url" || s == "-u") {
|
if let Some(idx) = rest.iter().position(|&s| s == "--url" || s == "-u") {
|
||||||
let url = rest
|
let url = rest
|
||||||
.get(idx + 1)
|
.get(idx + 1)
|
||||||
@@ -548,7 +548,15 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
context: "wait --url".to_string(),
|
context: "wait --url".to_string(),
|
||||||
usage: "wait --url <pattern>",
|
usage: "wait --url <pattern>",
|
||||||
})?;
|
})?;
|
||||||
return Ok(json!({ "id": id, "action": "waitforurl", "url": url }));
|
let mut cmd = json!({ "id": id, "action": "waitforurl", "url": url });
|
||||||
|
// Parse --timeout (without it the default applies — and a
|
||||||
|
// non-matching pattern would otherwise wait the full default).
|
||||||
|
if let Some(t_idx) = rest.iter().position(|&s| s == "--timeout") {
|
||||||
|
if let Some(ms) = rest.get(t_idx + 1).and_then(|s| s.parse::<u64>().ok()) {
|
||||||
|
cmd["timeout"] = json!(ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(cmd);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for --load flag: wait --load networkidle
|
// Check for --load flag: wait --load networkidle
|
||||||
@@ -3803,6 +3811,20 @@ mod tests {
|
|||||||
assert_eq!(cmd["url"], "**/dashboard");
|
assert_eq!(cmd["url"], "**/dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wait_url_with_timeout() {
|
||||||
|
// --timeout must be parsed for the --url path; without it a non-matching
|
||||||
|
// pattern waits the full default (and could wedge the daemon).
|
||||||
|
let cmd = parse_command(
|
||||||
|
&args("wait --url **/dashboard --timeout 3000"),
|
||||||
|
&default_flags(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cmd["action"], "waitforurl");
|
||||||
|
assert_eq!(cmd["url"], "**/dashboard");
|
||||||
|
assert_eq!(cmd["timeout"], 3000);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_wait_load() {
|
fn test_wait_load() {
|
||||||
let cmd = parse_command(&args("wait --load networkidle"), &default_flags()).unwrap();
|
let cmd = parse_command(&args("wait --load networkidle"), &default_flags()).unwrap();
|
||||||
|
|||||||
+169
-30
@@ -3494,16 +3494,53 @@ async fn wait_for_selector(
|
|||||||
poll_until_true(client, session_id, &check_fn, timeout_ms).await
|
poll_until_true(client, session_id, &check_fn, timeout_ms).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert a URL glob (Playwright-style: `*` matches within a path segment,
|
||||||
|
/// `**` matches across segments, `?` matches one char) to an anchored regex.
|
||||||
|
fn url_glob_to_regex(glob: &str) -> String {
|
||||||
|
let mut re = String::from("^");
|
||||||
|
let mut chars = glob.chars().peekable();
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
match c {
|
||||||
|
'*' => {
|
||||||
|
if chars.peek() == Some(&'*') {
|
||||||
|
chars.next();
|
||||||
|
re.push_str(".*"); // ** — any chars incl. '/'
|
||||||
|
} else {
|
||||||
|
re.push_str("[^/]*"); // * — any chars except '/'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'?' => re.push('.'),
|
||||||
|
'.' | '+' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$' | '\\' => {
|
||||||
|
re.push('\\');
|
||||||
|
re.push(c);
|
||||||
|
}
|
||||||
|
_ => re.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
re.push('$');
|
||||||
|
re
|
||||||
|
}
|
||||||
|
|
||||||
async fn wait_for_url(
|
async fn wait_for_url(
|
||||||
client: &super::cdp::client::CdpClient,
|
client: &super::cdp::client::CdpClient,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
pattern: &str,
|
pattern: &str,
|
||||||
timeout_ms: u64,
|
timeout_ms: u64,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let check_fn = format!(
|
// A pattern with glob metacharacters is matched as a glob (the core skill
|
||||||
"location.href.includes({})",
|
// documents `wait --url "**/dashboard"`); otherwise it's a plain substring
|
||||||
serde_json::to_string(pattern).unwrap_or_default()
|
// so exact / partial URLs keep working.
|
||||||
);
|
let check_fn = if pattern.contains('*') || pattern.contains('?') {
|
||||||
|
format!(
|
||||||
|
"(()=>{{try{{return new RegExp({}).test(location.href)}}catch(e){{return false}}}})()",
|
||||||
|
serde_json::to_string(&url_glob_to_regex(pattern)).unwrap_or_default()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"location.href.includes({})",
|
||||||
|
serde_json::to_string(pattern).unwrap_or_default()
|
||||||
|
)
|
||||||
|
};
|
||||||
poll_until_true(client, session_id, &check_fn, timeout_ms).await
|
poll_until_true(client, session_id, &check_fn, timeout_ms).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3539,8 +3576,19 @@ async fn poll_until_true(
|
|||||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
|
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let result: super::cdp::types::EvaluateResult = client
|
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||||
.send_command_typed(
|
if remaining.is_zero() {
|
||||||
|
return Err(format!("Wait timed out after {}ms", timeout_ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bound each probe. A `Runtime.evaluate` issued while the page is
|
||||||
|
// navigating can hang (the execution context is being torn down); without
|
||||||
|
// a cap the `.await` would block past the deadline forever and wedge the
|
||||||
|
// daemon's request loop. Cap at the remaining budget (max 2s per probe).
|
||||||
|
let probe_cap = remaining.min(tokio::time::Duration::from_secs(2));
|
||||||
|
let probe = tokio::time::timeout(
|
||||||
|
probe_cap,
|
||||||
|
client.send_command_typed::<_, super::cdp::types::EvaluateResult>(
|
||||||
"Runtime.evaluate",
|
"Runtime.evaluate",
|
||||||
&super::cdp::types::EvaluateParams {
|
&super::cdp::types::EvaluateParams {
|
||||||
expression: expression.to_string(),
|
expression: expression.to_string(),
|
||||||
@@ -3548,24 +3596,31 @@ async fn poll_until_true(
|
|||||||
await_promise: Some(true),
|
await_promise: Some(true),
|
||||||
},
|
},
|
||||||
Some(session_id),
|
Some(session_id),
|
||||||
)
|
),
|
||||||
.await?;
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
if result
|
// A probe that timed out or errored (e.g. the execution context was
|
||||||
.result
|
// replaced mid-navigation) is transient — keep polling until the
|
||||||
.value
|
// deadline rather than failing or hanging.
|
||||||
.as_ref()
|
if let Ok(Ok(result)) = probe {
|
||||||
.and_then(|v| v.as_bool())
|
if result
|
||||||
.unwrap_or(false)
|
.result
|
||||||
{
|
.value
|
||||||
return Ok(());
|
.as_ref()
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if tokio::time::Instant::now() >= deadline {
|
let nap = tokio::time::Duration::from_millis(100)
|
||||||
|
.min(deadline.saturating_duration_since(tokio::time::Instant::now()));
|
||||||
|
if nap.is_zero() {
|
||||||
return Err(format!("Wait timed out after {}ms", timeout_ms));
|
return Err(format!("Wait timed out after {}ms", timeout_ms));
|
||||||
}
|
}
|
||||||
|
tokio::time::sleep(nap).await;
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6004,6 +6059,54 @@ async fn execute_subaction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// CSS selector that matches an ARIA `role` — both an explicit `role="X"`
|
||||||
|
/// attribute AND the HTML elements that carry that role *implicitly*. The naive
|
||||||
|
/// `[role="X"], X` form fails for every role whose implicit element has a
|
||||||
|
/// different tag than the role name (e.g. role `link` ⇒ `<a href>`, not `<link>`;
|
||||||
|
/// role `heading` ⇒ `<h1>`..`<h6>`), which made `find role link/heading` never
|
||||||
|
/// match real elements.
|
||||||
|
fn role_to_query(role: &str) -> String {
|
||||||
|
let implicit = match role {
|
||||||
|
"link" => "a[href], area[href]",
|
||||||
|
"button" => "button, input[type=button], input[type=submit], input[type=reset], summary",
|
||||||
|
"heading" => "h1, h2, h3, h4, h5, h6",
|
||||||
|
"textbox" => {
|
||||||
|
"input[type=text], input[type=search], input[type=email], input[type=url], \
|
||||||
|
input[type=tel], input[type=password], input:not([type]), textarea"
|
||||||
|
}
|
||||||
|
"searchbox" => "input[type=search]",
|
||||||
|
"checkbox" => "input[type=checkbox]",
|
||||||
|
"radio" => "input[type=radio]",
|
||||||
|
"combobox" => "select",
|
||||||
|
"listbox" => "select[multiple]",
|
||||||
|
"slider" => "input[type=range]",
|
||||||
|
"spinbutton" => "input[type=number]",
|
||||||
|
"img" => "img",
|
||||||
|
"list" => "ul, ol",
|
||||||
|
"listitem" => "li",
|
||||||
|
"table" => "table",
|
||||||
|
"row" => "tr",
|
||||||
|
"cell" | "gridcell" => "td",
|
||||||
|
"columnheader" | "rowheader" => "th",
|
||||||
|
"article" => "article",
|
||||||
|
"navigation" => "nav",
|
||||||
|
"main" => "main",
|
||||||
|
"banner" => "header",
|
||||||
|
"contentinfo" => "footer",
|
||||||
|
"complementary" => "aside",
|
||||||
|
"figure" => "figure",
|
||||||
|
"separator" => "hr",
|
||||||
|
"progressbar" => "progress",
|
||||||
|
"group" => "fieldset",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
if implicit.is_empty() {
|
||||||
|
format!("[role=\"{role}\"], {role}")
|
||||||
|
} else {
|
||||||
|
format!("[role=\"{role}\"], {implicit}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn build_role_selector(role: &str, name: Option<&str>, exact: bool) -> String {
|
fn build_role_selector(role: &str, name: Option<&str>, exact: bool) -> String {
|
||||||
match name {
|
match name {
|
||||||
Some(n) => {
|
Some(n) => {
|
||||||
@@ -6024,27 +6127,25 @@ async fn handle_getbyrole(cmd: &Value, state: &mut DaemonState) -> Result<Value,
|
|||||||
let name = cmd.get("name").and_then(|v| v.as_str());
|
let name = cmd.get("name").and_then(|v| v.as_str());
|
||||||
let exact = cmd.get("exact").and_then(|v| v.as_bool()).unwrap_or(false);
|
let exact = cmd.get("exact").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
|
|
||||||
|
// Accessible-name approximation: aria-label, then title/alt/value, then the
|
||||||
|
// element's text. Covers links (text), input buttons (value), images (alt).
|
||||||
let name_match = name
|
let name_match = name
|
||||||
.map(|n| {
|
.map(|n| {
|
||||||
|
let nj = serde_json::to_string(n).unwrap_or_default();
|
||||||
if exact {
|
if exact {
|
||||||
format!(
|
format!("__an === {nj}")
|
||||||
"el.getAttribute('aria-label') === {} || el.textContent.trim() === {}",
|
|
||||||
serde_json::to_string(n).unwrap_or_default(),
|
|
||||||
serde_json::to_string(n).unwrap_or_default()
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!("__an.includes({nj})")
|
||||||
"(el.getAttribute('aria-label') || '').includes({n}) || el.textContent.includes({n})",
|
|
||||||
n = serde_json::to_string(n).unwrap_or_default()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| "true".to_string());
|
.unwrap_or_else(|| "true".to_string());
|
||||||
|
|
||||||
let js = format!(
|
let js = format!(
|
||||||
r#"(() => {{
|
r#"(() => {{
|
||||||
const els = document.querySelectorAll('[role="{role}"], {role}');
|
const els = document.querySelectorAll({selector});
|
||||||
for (const el of els) {{
|
for (const el of els) {{
|
||||||
|
const __an = (el.getAttribute('aria-label') || el.getAttribute('title')
|
||||||
|
|| el.getAttribute('alt') || el.value || el.textContent || '').trim();
|
||||||
if ({name_match}) {{
|
if ({name_match}) {{
|
||||||
el.setAttribute('data-agent-browser-located', 'true');
|
el.setAttribute('data-agent-browser-located', 'true');
|
||||||
return true;
|
return true;
|
||||||
@@ -6052,7 +6153,7 @@ async fn handle_getbyrole(cmd: &Value, state: &mut DaemonState) -> Result<Value,
|
|||||||
}}
|
}}
|
||||||
return false;
|
return false;
|
||||||
}})()"#,
|
}})()"#,
|
||||||
role = role,
|
selector = serde_json::to_string(&role_to_query(role)).unwrap_or_default(),
|
||||||
name_match = name_match,
|
name_match = name_match,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -8430,6 +8531,44 @@ mod tests {
|
|||||||
use crate::test_utils::EnvGuard;
|
use crate::test_utils::EnvGuard;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_url_glob_to_regex() {
|
||||||
|
assert_eq!(url_glob_to_regex("**/dashboard"), "^.*/dashboard$");
|
||||||
|
assert_eq!(url_glob_to_regex("**iana**"), "^.*iana.*$");
|
||||||
|
assert_eq!(
|
||||||
|
url_glob_to_regex("https://x.com/**"),
|
||||||
|
"^https://x\\.com/.*$"
|
||||||
|
);
|
||||||
|
// single * stays within a path segment
|
||||||
|
assert_eq!(url_glob_to_regex("/a/*/c"), "^/a/[^/]*/c$");
|
||||||
|
assert_eq!(url_glob_to_regex("/p?ge"), "^/p.ge$");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_url_glob_regex_matches() {
|
||||||
|
let re = regex_lite::Regex::new(&url_glob_to_regex("**/help/**")).unwrap();
|
||||||
|
assert!(re.is_match("https://www.iana.org/help/example-domains"));
|
||||||
|
assert!(!re.is_match("https://www.iana.org/about"));
|
||||||
|
let re2 = regex_lite::Regex::new(&url_glob_to_regex("https://www.iana.org/**")).unwrap();
|
||||||
|
assert!(re2.is_match("https://www.iana.org/help/example-domains"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_role_to_query_implicit_elements() {
|
||||||
|
// links are <a href>, not <link>; headings are h1..h6
|
||||||
|
assert_eq!(
|
||||||
|
role_to_query("link"),
|
||||||
|
"[role=\"link\"], a[href], area[href]"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
role_to_query("heading"),
|
||||||
|
"[role=\"heading\"], h1, h2, h3, h4, h5, h6"
|
||||||
|
);
|
||||||
|
assert!(role_to_query("button").contains("button"));
|
||||||
|
// unknown/custom roles fall back to the attribute + literal tag
|
||||||
|
assert_eq!(role_to_query("tablist"), "[role=\"tablist\"], tablist");
|
||||||
|
}
|
||||||
|
|
||||||
fn unique_socket_dir(label: &str) -> PathBuf {
|
fn unique_socket_dir(label: &str) -> PathBuf {
|
||||||
let nanos = std::time::SystemTime::now()
|
let nanos = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
|||||||
@@ -440,6 +440,18 @@ pub async fn resolve_element_object_id(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// A syntactically-invalid selector makes `document.querySelector` THROW.
|
||||||
|
// With returnByValue:false, Runtime.evaluate then returns the thrown
|
||||||
|
// DOMException as a remote object *with* an objectId — which would otherwise
|
||||||
|
// be mistaken for "the element" and silently no-op a `.click()` on it. Treat
|
||||||
|
// any thrown exception as a hard error so a typo'd selector fails loudly.
|
||||||
|
if let Some(ex) = result.exception_details {
|
||||||
|
return Err(format!(
|
||||||
|
"Invalid selector '{}': {}",
|
||||||
|
selector_or_ref, ex.text
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let object_id = result
|
let object_id = result
|
||||||
.result
|
.result
|
||||||
.object_id
|
.object_id
|
||||||
|
|||||||
+13
-5
@@ -228,12 +228,20 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
|||||||
}
|
}
|
||||||
// Navigation response
|
// Navigation response
|
||||||
if let Some(url) = data.get("url").and_then(|v| v.as_str()) {
|
if let Some(url) = data.get("url").and_then(|v| v.as_str()) {
|
||||||
if let Some(title) = data.get("title").and_then(|v| v.as_str()) {
|
let title = data
|
||||||
println!("{} {}", color::success_indicator(), color::bold(title));
|
.get("title")
|
||||||
println!(" {}", color::dim(url));
|
.and_then(|v| v.as_str())
|
||||||
return;
|
.map(str::trim)
|
||||||
|
.filter(|t| !t.is_empty());
|
||||||
|
match title {
|
||||||
|
Some(t) => {
|
||||||
|
println!("{} {}", color::success_indicator(), color::bold(t));
|
||||||
|
println!(" {}", color::dim(url));
|
||||||
|
}
|
||||||
|
// Title-less page: show the URL with the checkmark instead of an
|
||||||
|
// empty title line.
|
||||||
|
None => println!("{} {}", color::success_indicator(), color::dim(url)),
|
||||||
}
|
}
|
||||||
println!("{}", url);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(cdp_url) = data.get("cdpUrl").and_then(|v| v.as_str()) {
|
if let Some(cdp_url) = data.get("cdpUrl").and_then(|v| v.as_str()) {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agent-browser-stealth",
|
"name": "agent-browser-stealth",
|
||||||
"version": "0.27.0-fork.31",
|
"version": "0.27.0-fork.32",
|
||||||
"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",
|
||||||
|
|||||||
@@ -363,13 +363,14 @@ Pass `--hide-scrollbars false` when launching to keep native scrollbars visible.
|
|||||||
```bash
|
```bash
|
||||||
agent-browser tab # list open tabs (with stable tabId)
|
agent-browser tab # list open tabs (with stable tabId)
|
||||||
agent-browser tab new https://docs... # open a new tab (and switch to it)
|
agent-browser tab new https://docs... # open a new tab (and switch to it)
|
||||||
agent-browser tab 2 # switch to tab 2
|
agent-browser tab t2 # switch to tab t2
|
||||||
agent-browser tab close 2 # close tab 2
|
agent-browser tab close t2 # close tab t2
|
||||||
```
|
```
|
||||||
|
|
||||||
Stable `tabId`s mean `tab 2` points at the same tab across commands even
|
Tab ids are stable strings (`t1`, `t2`, …), never reused within a session, so
|
||||||
when other tabs open or close. After switching, refs from a prior snapshot
|
the same id keeps referring to the same tab across commands. Positional
|
||||||
on a different tab no longer apply — re-snapshot.
|
integers are **not** accepted — use `t2`, not `2`. After switching, refs from a
|
||||||
|
prior snapshot on a different tab no longer apply — re-snapshot.
|
||||||
|
|
||||||
### Run multiple browsers in parallel
|
### Run multiple browsers in parallel
|
||||||
|
|
||||||
|
|||||||
@@ -95,8 +95,8 @@ Electron apps often have multiple windows or webviews. Use tab commands to list
|
|||||||
# List all available targets (windows, webviews, etc.)
|
# List all available targets (windows, webviews, etc.)
|
||||||
agent-browser tab
|
agent-browser tab
|
||||||
|
|
||||||
# Switch to a specific tab by index
|
# Switch to a specific tab by id (t1, t2, …; integers not accepted)
|
||||||
agent-browser tab 2
|
agent-browser tab t2
|
||||||
|
|
||||||
# Switch by URL pattern
|
# Switch by URL pattern
|
||||||
agent-browser tab --url "*settings*"
|
agent-browser tab --url "*settings*"
|
||||||
@@ -117,7 +117,7 @@ agent-browser tab
|
|||||||
# 1: [webview] Embedded Content https://example.com/widget
|
# 1: [webview] Embedded Content https://example.com/widget
|
||||||
|
|
||||||
# Switch to a webview
|
# Switch to a webview
|
||||||
agent-browser tab 1
|
agent-browser tab t1
|
||||||
|
|
||||||
# Interact with the webview normally
|
# Interact with the webview normally
|
||||||
agent-browser snapshot -i
|
agent-browser snapshot -i
|
||||||
|
|||||||
Reference in New Issue
Block a user