Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9cc31d003 | ||
|
|
a7a3f924b0 | ||
|
|
7572c34229 | ||
|
|
06f5f9e8f1 |
Generated
+1
-1
@@ -45,7 +45,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agent-browser-stealth"
|
name = "agent-browser-stealth"
|
||||||
version = "0.27.0-fork.16"
|
version = "0.27.0-fork.17"
|
||||||
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.16"
|
version = "0.27.0-fork.17"
|
||||||
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"
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
//! `find-url` — search the user's local Chrome/Edge **bookmarks** for pages they
|
||||||
|
//! saved, by keyword. Borrowed from web-access's `find-url.mjs`; lets an agent
|
||||||
|
//! locate an internal system or a previously-saved page that public search
|
||||||
|
//! can't reach, without opening a browser.
|
||||||
|
//!
|
||||||
|
//! v1 covers bookmarks only (a zero-dependency JSON read). Visited-history lives
|
||||||
|
//! in a locked SQLite DB and would need a SQLite dependency — not included yet.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::color;
|
||||||
|
|
||||||
|
struct Hit {
|
||||||
|
name: String,
|
||||||
|
url: String,
|
||||||
|
folder: String,
|
||||||
|
date_added: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entry point for the `find-url` subcommand. `args` is the full cleaned argv
|
||||||
|
/// (including the leading "find-url").
|
||||||
|
pub fn run_find_url(args: &[String], json: bool) {
|
||||||
|
// Parse flags out of args[1..]; everything else is a keyword.
|
||||||
|
let mut browser = "chrome".to_string();
|
||||||
|
let mut profile = "Default".to_string();
|
||||||
|
let mut limit: usize = 20;
|
||||||
|
let mut keywords: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
let mut i = 1;
|
||||||
|
while i < args.len() {
|
||||||
|
match args[i].as_str() {
|
||||||
|
"--browser" => {
|
||||||
|
if let Some(v) = args.get(i + 1) {
|
||||||
|
browser = v.to_lowercase();
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--profile" => {
|
||||||
|
if let Some(v) = args.get(i + 1) {
|
||||||
|
profile = v.clone();
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--limit" => {
|
||||||
|
if let Some(v) = args.get(i + 1).and_then(|s| s.parse::<usize>().ok()) {
|
||||||
|
limit = v;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--json" => {}
|
||||||
|
other if other.starts_with("--") => {}
|
||||||
|
other => keywords.push(other.to_lowercase()),
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = match bookmarks_path(&browser, &profile) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
emit_error(
|
||||||
|
json,
|
||||||
|
&format!("Could not locate {browser} bookmarks for profile '{profile}'"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let raw = match std::fs::read_to_string(&path) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
emit_error(json, &format!("Failed to read {}: {e}", path.display()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let root: Value = match serde_json::from_str(&raw) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
emit_error(json, &format!("Failed to parse bookmarks JSON: {e}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut hits: Vec<Hit> = Vec::new();
|
||||||
|
if let Some(roots) = root.get("roots").and_then(|r| r.as_object()) {
|
||||||
|
for node in roots.values() {
|
||||||
|
walk(node, "", &keywords, &mut hits);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Most-recently-added first (date_added is microseconds since 1601).
|
||||||
|
hits.sort_by(|a, b| b.date_added.cmp(&a.date_added));
|
||||||
|
hits.truncate(limit);
|
||||||
|
|
||||||
|
if json {
|
||||||
|
let arr: Vec<Value> = hits
|
||||||
|
.iter()
|
||||||
|
.map(|h| {
|
||||||
|
serde_json::json!({
|
||||||
|
"name": h.name,
|
||||||
|
"url": h.url,
|
||||||
|
"folder": h.folder,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string(&serde_json::json!({
|
||||||
|
"success": true,
|
||||||
|
"data": { "results": arr, "count": hits.len() },
|
||||||
|
}))
|
||||||
|
.unwrap_or_default()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if hits.is_empty() {
|
||||||
|
let kw = if keywords.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" matching {:?}", keywords.join(" "))
|
||||||
|
};
|
||||||
|
println!("No {browser} bookmarks found{kw}.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for h in &hits {
|
||||||
|
if h.folder.is_empty() {
|
||||||
|
println!("{}\n {}", h.name, h.url);
|
||||||
|
} else {
|
||||||
|
println!("{} ({})\n {}", h.name, h.folder, h.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively walk a bookmark node, collecting URL entries that match every
|
||||||
|
/// keyword (in name or url). Empty keyword list matches everything.
|
||||||
|
fn walk(node: &Value, folder: &str, keywords: &[String], out: &mut Vec<Hit>) {
|
||||||
|
match node.get("type").and_then(|t| t.as_str()) {
|
||||||
|
Some("url") => {
|
||||||
|
let name = node.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let url = node.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
// Skip non-navigable bookmarks: javascript: bookmarklets and data:
|
||||||
|
// URIs aren't pages you can visit, and their bodies can be huge.
|
||||||
|
if url.is_empty()
|
||||||
|
|| url.starts_with("javascript:")
|
||||||
|
|| url.starts_with("data:")
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let hay = format!("{} {}", name.to_lowercase(), url.to_lowercase());
|
||||||
|
if keywords.iter().all(|k| hay.contains(k.as_str())) {
|
||||||
|
let date_added = node
|
||||||
|
.get("date_added")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| s.parse::<i64>().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
out.push(Hit {
|
||||||
|
name: name.to_string(),
|
||||||
|
url: url.to_string(),
|
||||||
|
folder: folder.to_string(),
|
||||||
|
date_added,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some("folder") => {
|
||||||
|
let fname = node.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let child_folder = if folder.is_empty() {
|
||||||
|
fname.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{folder}/{fname}")
|
||||||
|
};
|
||||||
|
if let Some(children) = node.get("children").and_then(|c| c.as_array()) {
|
||||||
|
for child in children {
|
||||||
|
walk(child, &child_folder, keywords, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the Bookmarks file path for a browser + profile across platforms.
|
||||||
|
fn bookmarks_path(browser: &str, profile: &str) -> Option<PathBuf> {
|
||||||
|
let base = browser_user_data_dir(browser)?;
|
||||||
|
let path = base.join(profile).join("Bookmarks");
|
||||||
|
if path.exists() {
|
||||||
|
Some(path)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The "User Data" directory that holds per-profile folders, per OS/browser.
|
||||||
|
fn browser_user_data_dir(browser: &str) -> Option<PathBuf> {
|
||||||
|
let is_edge = browser == "edge" || browser == "msedge";
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let app_support = dirs::config_dir()?; // ~/Library/Application Support
|
||||||
|
let sub = if is_edge {
|
||||||
|
"Microsoft Edge"
|
||||||
|
} else {
|
||||||
|
"Google/Chrome"
|
||||||
|
};
|
||||||
|
Some(app_support.join(sub))
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let local = dirs::data_local_dir()?; // %LOCALAPPDATA%
|
||||||
|
let sub = if is_edge {
|
||||||
|
"Microsoft/Edge/User Data"
|
||||||
|
} else {
|
||||||
|
"Google/Chrome/User Data"
|
||||||
|
};
|
||||||
|
Some(local.join(sub))
|
||||||
|
}
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
{
|
||||||
|
let config = dirs::config_dir()?; // ~/.config
|
||||||
|
let sub = if is_edge {
|
||||||
|
"microsoft-edge"
|
||||||
|
} else {
|
||||||
|
"google-chrome"
|
||||||
|
};
|
||||||
|
Some(config.join(sub))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_error(json: bool, msg: &str) {
|
||||||
|
if json {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string(&serde_json::json!({
|
||||||
|
"success": false,
|
||||||
|
"error": msg,
|
||||||
|
}))
|
||||||
|
.unwrap_or_default()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
eprintln!("{} {msg}", color::error_indicator());
|
||||||
|
}
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ mod color;
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod connection;
|
mod connection;
|
||||||
mod doctor;
|
mod doctor;
|
||||||
|
mod findurl;
|
||||||
mod flags;
|
mod flags;
|
||||||
mod install;
|
mod install;
|
||||||
mod native;
|
mod native;
|
||||||
@@ -631,6 +632,15 @@ fn main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle find-url (doesn't need daemon): search local bookmarks
|
||||||
|
if matches!(
|
||||||
|
clean.first().map(|s| s.as_str()),
|
||||||
|
Some("find-url") | Some("findurl")
|
||||||
|
) {
|
||||||
|
findurl::run_find_url(&clean, flags.json);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle session separately (doesn't need daemon)
|
// Handle session separately (doesn't need daemon)
|
||||||
if clean.first().map(|s| s.as_str()) == Some("session") {
|
if clean.first().map(|s| s.as_str()) == Some("session") {
|
||||||
run_session(&clean, &flags.session, flags.json);
|
run_session(&clean, &flags.session, flags.json);
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agent-browser-stealth",
|
"name": "agent-browser-stealth",
|
||||||
"version": "0.27.0-fork.16",
|
"version": "0.27.0-fork.17",
|
||||||
"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",
|
||||||
|
|||||||
@@ -29,6 +29,45 @@ Refs (`@e1`, `@e2`, ...) are assigned fresh on every snapshot. They become
|
|||||||
submits, dynamic re-renders, dialog opens. Always re-snapshot before your
|
submits, dynamic re-renders, dialog opens. Always re-snapshot before your
|
||||||
next ref interaction.
|
next ref interaction.
|
||||||
|
|
||||||
|
## Before you automate: pick the cheapest tool
|
||||||
|
|
||||||
|
Driving a browser is the heavy option. agent-browser earns its keep when you
|
||||||
|
need a **real, logged-in browser** — not for reading text off a public page.
|
||||||
|
|
||||||
|
| You need | Use |
|
||||||
|
|---|---|
|
||||||
|
| Discover what exists / find sources | `WebSearch` |
|
||||||
|
| Specific facts from a static or public page | `WebFetch` or `curl` (no browser) |
|
||||||
|
| Login state, interaction, JS-rendered or anti-bot pages | **agent-browser** (this skill) |
|
||||||
|
| A page the user saved before / an internal system | `agent-browser find-url <keywords>` (their bookmarks), then open it |
|
||||||
|
|
||||||
|
Don't hand-build deep URLs with query params — links discovered by *interacting*
|
||||||
|
with the site carry the right hidden context and dodge anti-bot checks; a
|
||||||
|
hand-constructed URL often doesn't.
|
||||||
|
|
||||||
|
## Two ways to drive a page — and when to drop to `eval`
|
||||||
|
|
||||||
|
You have a **real Chrome with the user's DOM**. Two layers, mix them freely:
|
||||||
|
|
||||||
|
1. **Structured** (`snapshot` + `@ref`, `find`, typed actions) — convenient and
|
||||||
|
readable; best for straightforward forms and navigation. But the a11y view is
|
||||||
|
*lossy and fragile*: refs go stale on any change, hidden inputs never show up,
|
||||||
|
overlays can block coordinate clicks.
|
||||||
|
2. **eval-first** (`agent-browser eval "<js>"`) — your eyes and hands on the real
|
||||||
|
DOM: read hidden inputs, reach into Shadow DOM / iframes, inspect
|
||||||
|
`form.elements` and `.validity`, extract the exact shape you want, or call
|
||||||
|
`el.click()` directly. **The moment the structured path fights you, drop to
|
||||||
|
`eval` instead of retrying it** — it's the fast way to find *why* something
|
||||||
|
failed (e.g. a hidden `point_choice=none` the UI never exposes).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# "what's actually in this form / why won't it submit?"
|
||||||
|
agent-browser eval "[...document.forms[0].elements].map(e=>[e.name,e.type,e.value,e.checked])"
|
||||||
|
agent-browser eval "document.querySelector('[name=point_choice]')?.value"
|
||||||
|
agent-browser eval "[...document.forms[0].elements].filter(e=>!e.validity.valid).map(e=>e.name+': '+e.validationMessage)"
|
||||||
|
agent-browser eval "document.querySelector('#stubborn').click()" # direct DOM click, bypasses overlays
|
||||||
|
```
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -137,9 +176,17 @@ agent-browser fill "input[name=email]" "user@test.com"
|
|||||||
agent-browser click "button.primary"
|
agent-browser click "button.primary"
|
||||||
```
|
```
|
||||||
|
|
||||||
Rule of thumb: snapshot + `@eN` refs are fastest and most reliable for
|
Escalation ladder: snapshot + `@eN` refs are quickest for straightforward
|
||||||
AI agents. `find role/text/label` is next best and doesn't require a prior
|
pages → `find role/text/label` when you'd rather skip the snapshot → raw CSS
|
||||||
snapshot. Raw CSS is a fallback when the others fail.
|
→ **`eval` the moment any of those fight you** (stale refs, hidden state,
|
||||||
|
occluded clicks). Don't retry a flaky structured locator three times; drop to
|
||||||
|
`eval` and act on the DOM directly.
|
||||||
|
|
||||||
|
`click` auto-scrolls into view and, if the coordinate click is occluded, falls
|
||||||
|
back to a DOM `.click()`. If a click *reports success but nothing happened* —
|
||||||
|
classic for an autocomplete/menu `<li>` that closes on the input's blur — retry
|
||||||
|
that one with `AGENT_BROWSER_CLICK_MODE=dom agent-browser click ...`, or just
|
||||||
|
`agent-browser eval "<select the item via JS>"`.
|
||||||
|
|
||||||
## Waiting (read this)
|
## Waiting (read this)
|
||||||
|
|
||||||
@@ -209,6 +256,44 @@ AGENT_BROWSER_SESSION_NAME=my-app agent-browser open https://app.example.com
|
|||||||
# State is auto-saved and restored on subsequent runs with the same name.
|
# State is auto-saved and restored on subsequent runs with the same name.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Remember a site's quirks (site notes)
|
||||||
|
|
||||||
|
A site behaves the same every time you visit it. When you work out something
|
||||||
|
durable — a working selector, a URL pattern, a hidden field a form needs, an
|
||||||
|
anti-bot trap, what requires login — **write it down so the next run doesn't
|
||||||
|
re-discover it.** Keep one markdown file per domain (these are your own notes,
|
||||||
|
not shipped with the skill):
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.agent-browser/site-patterns/<domain>.md
|
||||||
|
```
|
||||||
|
|
||||||
|
**Before** working on a domain, read its file if it exists (use your normal file
|
||||||
|
tools — this is plain markdown you own). Treat it as *hints, not guarantees* —
|
||||||
|
sites change; verify before relying. **After** a successful session that taught
|
||||||
|
you something durable, create or update it. Suggested shape:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
domain: app.example.com
|
||||||
|
updated: 2026-06-05
|
||||||
|
---
|
||||||
|
## Platform traits
|
||||||
|
SPA; form renders ~1s after load (wait --text). Cloudflare on /login.
|
||||||
|
|
||||||
|
## Working patterns
|
||||||
|
- Address pick: the `<li>` closes on blur — select with CLICK_MODE=dom.
|
||||||
|
- Submit needs hidden `point_choice` set (eval), the UI never exposes it.
|
||||||
|
- Stable selector for "Continue": button[data-testid=submit]
|
||||||
|
|
||||||
|
## Known traps (date them)
|
||||||
|
- 2026-06-05: @ref to the basket button goes stale after the mini-cart opens;
|
||||||
|
re-snapshot or use `find role button --name "Checkout"`.
|
||||||
|
```
|
||||||
|
|
||||||
|
This is how repeat visits get fast and reliable instead of re-solving the same
|
||||||
|
page every time.
|
||||||
|
|
||||||
### Extract data
|
### Extract data
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -336,6 +336,22 @@ agent-browser profiler start # Start Chrome DevTools profiling
|
|||||||
agent-browser profiler stop trace.json # Stop and save profile
|
agent-browser profiler stop trace.json # Stop and save profile
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Finding a page the user saved (`find-url`)
|
||||||
|
|
||||||
|
Search the user's local Chrome/Edge **bookmarks** by keyword — for internal
|
||||||
|
systems or previously-saved pages that public search can't reach. Local read, no
|
||||||
|
browser/daemon needed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agent-browser find-url jira board # all keywords must match (name or url)
|
||||||
|
agent-browser find-url --limit 10 invoices
|
||||||
|
agent-browser find-url --browser edge --profile "Profile 1" wiki
|
||||||
|
agent-browser find-url grafana --json # {results:[{name,url,folder}], count}
|
||||||
|
```
|
||||||
|
|
||||||
|
Results are most-recently-added first. `javascript:`/`data:` bookmarklets are
|
||||||
|
skipped. (Visited-history search isn't included yet — bookmarks only.)
|
||||||
|
|
||||||
### Debugging forms / hidden state with `eval`
|
### Debugging forms / hidden state with `eval`
|
||||||
|
|
||||||
The a11y `snapshot` shows visible, interactive elements — it does **not** show
|
The a11y `snapshot` shows visible, interactive elements — it does **not** show
|
||||||
|
|||||||
Reference in New Issue
Block a user