Compare commits

...
Author SHA1 Message Date
leeguooooo 9efcb56651 chore(release): bump to 0.27.0-fork.18 — extension connect (zero-token native-messaging control of real Chrome) + click reliability + eval-first/find-url/site-notes
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-09 17:32:44 +09:00
leeguooooo 091a4ec02e docs(skills): teach agents the extension-connect flow + computer-use for setup
So an agent can operate the zero-confirmation real-Chrome feature itself:
- SKILL.md: tool matrix gains "the user's own already-open, logged-in window →
  extension connect", plus a short section pointing at the flow.
- commands.md: the one-time "Load unpacked" is a privileged GUI step the CLI
  can't do — call it out that the agent can perform it with a computer-use /
  GUI-automation tool (cua-driver), with the live gotchas (synthetic-keystroke
  tools like peekaboo don't reach Chrome; cua-driver does; the native file
  dialog may need the user to pick the folder).
2026-06-09 17:14:50 +09:00
leeguooooo 6c0f5cbaa1 feat(connect): attach existing tabs + extension connect one-command UX
Completes the zero-confirmation real-Chrome feature.

- Drive the user's EXISTING logged-in tabs (not just newly-created ones):
  extension attachTab now treats "already attached" (a lingering chrome.debugger
  binding after a service-worker restart) as success and announces the tab
  anyway, instead of skipping it. The nm-host also sends {method:"attachAll"}
  when an agent-browser CDP client connects, so the daemon doesn't race an empty
  target list.
- `agent-browser extension connect` auto-discovers the relay's CDP url
  (~/.agent-browser/relay-cdp-url) and attaches — no copying a ws URL. Rewrites
  into the normal `connect <url>` flow; `extension install/status/uninstall`
  unchanged.
- Skill docs: a "drive your real, logged-in Chrome (extension)" section.

Verified end-to-end: `extension connect` listed the user's real tabs (Lark,
LINUX DO, Rakuten, Discord) and read a logged-in Lark doc's title — zero token,
zero confirmation. Full suite 768 passed.
2026-06-09 17:11:59 +09:00
leeguooooo 0d72e0d889 feat(connect): bridge native-messaging host to a CDP endpoint — end-to-end works
The __nm-host now exposes a Chrome-compatible CDP WebSocket endpoint and bridges
it to the extension over native messaging via the relay translation core
(relay.rs): incoming raw CDP commands are answered locally for browser-level
Target discovery or forwarded to the extension as forwardCDPCommand; the
extension's forwardCDPEvent/results are relayed back as raw CDP.

Security without a token or user interaction: the ws URL carries an unguessable
guid and is written to ~/.agent-browser/relay-cdp-url (perms 600), so only this
user's agent-browser can drive the browser — mirroring how Chrome guards its own
remote-debugging URL.

Verified end-to-end on real Chrome: `agent-browser connect <relay-url>` then an
eval navigated a tab and read back "Example Domain | https://example.com/" —
abs → CDP → relay → native messaging → extension → chrome.debugger → real tab,
zero token, zero confirmation. Adds the tokio io-std feature for the host's
stdio.

Remaining polish: re-attach the user's EXISTING tabs after a service-worker
restart (currently attaches new tabs cleanly; existing ones need detach+reattach
since chrome.debugger may still be bound), and an `open --extension` UX that
reads relay-cdp-url so the URL isn't passed by hand.
2026-06-09 16:54:29 +09:00
leeguooooo 528de4230f feat(connect): native-messaging transport — zero-token connect to real Chrome
Optimal architecture (chosen over the WS+token copy): the ab-connect extension
talks to a local agent-browser native-messaging host. No localhost port, no
token — Chrome authenticates the extension to the host by id. This is the
codex/claude-style "install once, no per-use confirmation" model.

- extensions/ab-connect: rewritten transport WebSocket+token → native messaging
  (chrome.runtime.connectNative). Pinned the extension id via a manifest `key`
  (→ bdoiejojpjogcjojeladhioioijhgade) so the host manifest can authorize it.
  Kept the proven chrome.debugger attach + Target.attachedToTarget emulation;
  dropped WS/token/options. Rebranded to "agent-browser connect".
- cli connect.rs: `agent-browser extension install` writes the native-messaging
  host manifest (Chrome/Chromium/Edge/Brave) + a launcher; hidden `__nm-host`
  speaks the 4-byte-length native-messaging framing.

Validated end-to-end on real Chrome: Chrome spawned the host (origin matched the
pinned id) and the extension attached the user's real logged-in tabs, streaming
Target.attachedToTarget over native messaging — zero token, zero port.

Next: bridge the host to the daemon relay (relay.rs) + CdpClient so
`agent-browser click/eval/...` drives those tabs.
2026-06-09 16:39:38 +09:00
leeguooooo 7f672494c1 feat(connect): relay translation core (envelope <-> raw CDP + Target emulation)
Pure, unit-tested core of the daemon-side relay that bridges the ab-connect
extension to the existing CdpClient. The extension exposes per-tab
chrome.debugger + synthesized Target events; CdpClient expects a browser-level
endpoint. So RelayState:

- answers Target.getTargets / attachToTarget / setDiscoverTargets LOCALLY from
  targets learned via the extension's forwardCDPEvent(Target.attachedToTarget),
  returning the extension's cb-tab-N sessionId (consumes those synth events
  rather than double-forwarding them);
- forwards every other command as a forwardCDPCommand envelope (carrying
  method/params/sessionId);
- maps forwardCDPCommand responses and forwardCDPEvent events back to raw CDP;
- validates the connect-handshake token; emits challenge/ping.

Keeps CdpClient and browser.rs unchanged. 8 unit tests; clippy clean. Still
inert — the tokio WS server + `connect` command wire it next.
2026-06-09 14:59:54 +09:00
leeguooooo 8a8106ad75 feat(connect): vendor MV3 connect extension (adapted from openclaw-browser-relay)
First step toward zero-confirmation direct connect to the user's real Chrome:
Chrome 136 killed --remote-debugging-port on the default profile, so the only
sanctioned way to drive the user's live logged-in window is an extension using
chrome.debugger (same approach as Codex/Claude, whose extensions are closed).

Vendors the MIT-licensed openclaw-browser-relay extension into
extensions/ab-connect/, rebranded to "agent-browser connect" (NOTICE.md keeps
attribution). It already handles the hard parts: chrome.debugger auto-attach all
tabs, new-tab auto-attach, MV3 service-worker keepalive (alarms) + reconnect,
sessionId↔tab mapping, token auth, and a CDP-over-WebSocket envelope
(connect handshake / forwardCDPCommand / forwardCDPEvent / ping-pong).

Inert for now — not wired. Next: an abs-daemon relay that speaks this envelope
and bridges it to the existing CdpClient (raw CDP), then a `connect` command.
2026-06-09 14:54:03 +09:00
12 changed files with 1242 additions and 5 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ dependencies = [
[[package]]
name = "agent-browser-stealth"
version = "0.27.0-fork.17"
version = "0.27.0-fork.18"
dependencies = [
"aes-gcm",
"async-trait",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-browser-stealth"
version = "0.27.0-fork.17"
version = "0.27.0-fork.18"
edition = "2021"
description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0"
@@ -22,7 +22,7 @@ dirs = "5.0"
include_dir = "0.7"
base64 = "0.22"
getrandom = "0.2"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "sync", "signal", "process"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "io-std", "time", "sync", "signal", "process"] }
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
futures-util = "0.3"
url = "2"
+439
View File
@@ -0,0 +1,439 @@
//! `agent-browser connect` — zero-confirmation control of the user's real,
//! logged-in Chrome via the `ab-connect` MV3 extension over Chrome **native
//! messaging** (no localhost port, no token; Chrome authenticates the extension
//! to this host by id).
//!
//! Two pieces live here:
//! - `run_connect` — `--install` writes the native-messaging host manifest (and
//! a tiny launcher) so Chrome will spawn us; with no flag it reports status.
//! - `run_nm_host` — the hidden `__nm-host` mode Chrome launches: it speaks the
//! native-messaging stdio framing (4-byte little-endian length + JSON).
//!
//! This step wires the transport end-to-end (Chrome ⇄ host). Bridging the host
//! to the daemon's relay + CdpClient is layered on next.
use std::io::Write;
use std::path::PathBuf;
/// Native-messaging host name; must match `HOST_NAME` in the extension and the
/// manifest filename.
pub const HOST_NAME: &str = "com.agent_browser.connect";
/// Stable id of the `ab-connect` extension, pinned by the `key` in its
/// manifest.json. Chrome only lets that extension talk to this host.
pub const EXTENSION_ID: &str = "bdoiejojpjogcjojeladhioioijhgade";
/// `agent-browser extension <install|uninstall|status>` (local; no daemon).
/// `args` is the cleaned argv including the leading "extension".
pub fn run_connect(args: &[String], json: bool) {
let install = args.iter().any(|a| a == "--install" || a == "install");
let uninstall = args.iter().any(|a| a == "--uninstall" || a == "uninstall");
if uninstall {
let removed = remove_host_manifests();
report(json, true, &format!("removed {removed} native-host manifest(s)"));
return;
}
if install {
match install_native_host() {
Ok(paths) => {
if json {
println!(
"{}",
serde_json::to_string(&serde_json::json!({
"success": true,
"data": { "installed": paths, "extensionId": EXTENSION_ID }
}))
.unwrap_or_default()
);
} else {
println!("✓ native-messaging host installed:");
for p in &paths {
println!(" {p}");
}
println!(
"\nNext: load the ab-connect extension in Chrome (chrome://extensions →\n\
Developer mode → Load unpacked → extensions/ab-connect), then this host\n\
is reachable with no token and no per-use confirmation."
);
}
}
Err(e) => report(json, false, &format!("install failed: {e}")),
}
return;
}
// Status.
let manifest = host_manifest_path_for_chrome();
let installed = manifest.as_ref().map(|p| p.exists()).unwrap_or(false);
if json {
println!(
"{}",
serde_json::to_string(&serde_json::json!({
"success": true,
"data": {
"installed": installed,
"manifest": manifest.as_ref().map(|p| p.display().to_string()),
"extensionId": EXTENSION_ID,
}
}))
.unwrap_or_default()
);
} else if installed {
println!("✓ native-messaging host installed ({HOST_NAME}).");
println!(" Load the ab-connect extension and it connects automatically.");
} else {
println!("✗ not installed. Run: agent-browser connect --install");
}
}
/// Write the launcher script + native-messaging host manifest(s).
fn install_native_host() -> Result<Vec<String>, String> {
let home = dirs::home_dir().ok_or("no home dir")?;
let ab_dir = home.join(".agent-browser");
std::fs::create_dir_all(&ab_dir).map_err(|e| e.to_string())?;
// Chrome execs the manifest `path` directly with the calling extension's
// origin as argv[1]; a launcher lets us run the binary in __nm-host mode
// regardless of how/where agent-browser is installed.
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
let launcher = ab_dir.join("nm-host.sh");
let script = format!(
"#!/bin/sh\n# agent-browser native-messaging host launcher (auto-generated)\nexec \"{}\" __nm-host \"$@\"\n",
exe.display()
);
std::fs::write(&launcher, script).map_err(|e| e.to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755));
}
let manifest = serde_json::json!({
"name": HOST_NAME,
"description": "agent-browser connect — native messaging host",
"path": launcher.display().to_string(),
"type": "stdio",
"allowed_origins": [format!("chrome-extension://{EXTENSION_ID}/")],
});
let body = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
let mut written = Vec::new();
for dir in native_messaging_dirs() {
if let Some(parent) = dir.parent() {
if !parent.exists() {
continue; // that browser isn't installed
}
}
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join(format!("{HOST_NAME}.json"));
std::fs::write(&path, &body).map_err(|e| e.to_string())?;
written.push(path.display().to_string());
}
if written.is_empty() {
return Err("no Chrome/Chromium NativeMessagingHosts directory found".into());
}
Ok(written)
}
fn remove_host_manifests() -> usize {
let mut n = 0;
for dir in native_messaging_dirs() {
let path = dir.join(format!("{HOST_NAME}.json"));
if path.exists() && std::fs::remove_file(&path).is_ok() {
n += 1;
}
}
n
}
/// Per-OS NativeMessagingHosts directories for Chrome + Chromium-family browsers.
fn native_messaging_dirs() -> Vec<PathBuf> {
let mut dirs_out = Vec::new();
#[cfg(target_os = "macos")]
{
if let Some(app_support) = dirs::config_dir() {
for sub in [
"Google/Chrome",
"Google/Chrome Beta",
"Google/Chrome Canary",
"Chromium",
"Microsoft Edge",
"BraveSoftware/Brave-Browser",
] {
dirs_out.push(app_support.join(sub).join("NativeMessagingHosts"));
}
}
}
#[cfg(all(unix, not(target_os = "macos")))]
{
if let Some(config) = dirs::config_dir() {
for sub in ["google-chrome", "chromium", "microsoft-edge", "BraveSoftware/Brave-Browser"] {
dirs_out.push(config.join(sub).join("NativeMessagingHosts"));
}
}
}
dirs_out
}
fn host_manifest_path_for_chrome() -> Option<PathBuf> {
native_messaging_dirs()
.into_iter()
.map(|d| d.join(format!("{HOST_NAME}.json")))
.find(|p| p.exists())
.or_else(|| {
native_messaging_dirs()
.into_iter()
.next()
.map(|d| d.join(format!("{HOST_NAME}.json")))
})
}
fn report(json: bool, ok: bool, msg: &str) {
if json {
println!(
"{}",
serde_json::to_string(&serde_json::json!({ "success": ok, "error": if ok { serde_json::Value::Null } else { serde_json::json!(msg) }, "message": msg }))
.unwrap_or_default()
);
} else if ok {
println!("{msg}");
} else {
eprintln!("{msg}");
}
if !ok {
std::process::exit(1);
}
}
// ---- native messaging host (`__nm-host`) ----------------------------------
fn nm_log(line: &str) {
let path = dirs::home_dir()
.map(|h| h.join(".agent-browser").join("nm-host.log"))
.unwrap_or_else(|| PathBuf::from("/tmp/ab-nm-host.log"));
if let Some(p) = path.parent() {
let _ = std::fs::create_dir_all(p);
}
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) {
let _ = writeln!(f, "{line}");
}
}
fn random_guid() -> String {
let mut b = [0u8; 16];
let _ = getrandom::getrandom(&mut b);
b.iter().map(|x| format!("{x:02x}")).collect()
}
/// Where the daemon/CLI reads the relay's CDP WebSocket URL (perms 600).
fn relay_url_path() -> PathBuf {
dirs::home_dir()
.map(|h| h.join(".agent-browser").join("relay-cdp-url"))
.unwrap_or_else(|| PathBuf::from("/tmp/ab-relay-cdp-url"))
}
/// The live relay CDP WebSocket URL, if the native-messaging host is running
/// (it writes the file on connect and removes it on exit). Used by
/// `agent-browser extension connect` to attach without the user copying a URL.
pub fn relay_url() -> Option<String> {
let s = std::fs::read_to_string(relay_url_path()).ok()?;
let s = s.trim().to_string();
if s.starts_with("ws://") {
Some(s)
} else {
None
}
}
/// Hidden `__nm-host` mode: launched by Chrome for the ab-connect extension.
///
/// Bridges the extension (native-messaging stdio, envelope protocol) to a local
/// **CDP WebSocket endpoint** that agent-browser connects to like any Chrome.
/// `relay::RelayState` translates envelope ⇄ raw CDP and emulates browser-level
/// Target discovery. The ws URL carries an unguessable guid (written to a 600
/// file) so only this user's agent-browser — not arbitrary local processes —
/// can drive the browser. No token, no user interaction.
pub fn run_nm_host() {
let rt = match tokio::runtime::Builder::new_multi_thread().enable_all().build() {
Ok(rt) => rt,
Err(e) => {
nm_log(&format!("[nm-host] runtime build failed: {e}"));
return;
}
};
rt.block_on(nm_host_main());
}
async fn nm_host_main() {
use crate::native::relay::{RelayOut, RelayState};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::{broadcast, mpsc, Mutex};
nm_log(&format!(
"[nm-host] start argv={:?}",
std::env::args().skip(1).collect::<Vec<_>>()
));
let listener = match tokio::net::TcpListener::bind("127.0.0.1:0").await {
Ok(l) => l,
Err(e) => {
nm_log(&format!("[nm-host] bind failed: {e}"));
return;
}
};
let port = listener.local_addr().map(|a| a.port()).unwrap_or(0);
let guid = random_guid();
let url = format!("ws://127.0.0.1:{port}/{guid}");
let url_path = relay_url_path();
if let Some(p) = url_path.parent() {
let _ = std::fs::create_dir_all(p);
}
if std::fs::write(&url_path, &url).is_ok() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&url_path, std::fs::Permissions::from_mode(0o600));
}
}
nm_log(&format!("[nm-host] cdp endpoint {url}"));
let state = Arc::new(Mutex::new(RelayState::new()));
let (to_clients, _) = broadcast::channel::<String>(4096);
let (to_ext, mut to_ext_rx) = mpsc::channel::<Vec<u8>>(4096);
// Single writer to Chrome (extension) over stdout, native-messaging framed.
tokio::spawn(async move {
let mut out = tokio::io::stdout();
while let Some(frame) = to_ext_rx.recv().await {
let len = (frame.len() as u32).to_ne_bytes();
if out.write_all(&len).await.is_err() || out.write_all(&frame).await.is_err() {
break;
}
let _ = out.flush().await;
}
});
// Accept agent-browser CDP clients on the guid-scoped ws endpoint.
{
let state = state.clone();
let to_clients = to_clients.clone();
let to_ext = to_ext.clone();
let guid = guid.clone();
tokio::spawn(async move {
loop {
let (stream, _) = match listener.accept().await {
Ok(x) => x,
Err(_) => break,
};
let st = state.clone();
let rx = to_clients.subscribe();
let tx = to_ext.clone();
let g = guid.clone();
tokio::spawn(async move {
handle_cdp_client(stream, g, st, rx, tx).await;
});
}
});
}
// Extension → host frames.
let mut stdin = tokio::io::stdin();
loop {
let mut len_buf = [0u8; 4];
if stdin.read_exact(&mut len_buf).await.is_err() {
break;
}
let len = u32::from_ne_bytes(len_buf) as usize;
let mut buf = vec![0u8; len];
if stdin.read_exact(&mut buf).await.is_err() {
break;
}
let v: serde_json::Value = match serde_json::from_slice(&buf) {
Ok(v) => v,
Err(_) => continue,
};
let outs = {
let mut s = state.lock().await;
s.handle_ext_message(&v, "")
};
for o in outs {
match o {
RelayOut::ToClient(m) => {
let _ = to_clients.send(m.to_string());
}
RelayOut::ToExt(m) => {
let _ = to_ext.send(m.to_string().into_bytes()).await;
}
}
}
}
nm_log("[nm-host] stdin EOF — Chrome closed the port");
let _ = std::fs::remove_file(relay_url_path());
}
async fn handle_cdp_client(
stream: tokio::net::TcpStream,
guid: String,
state: std::sync::Arc<tokio::sync::Mutex<crate::native::relay::RelayState>>,
mut from_relay: tokio::sync::broadcast::Receiver<String>,
to_ext: tokio::sync::mpsc::Sender<Vec<u8>>,
) {
use crate::native::relay::ClientRoute;
use futures_util::{SinkExt, StreamExt};
use tokio::sync::broadcast::error::RecvError;
use tokio_tungstenite::tungstenite::Message;
let want_path = format!("/{guid}");
let cb = |req: &tokio_tungstenite::tungstenite::handshake::server::Request,
resp: tokio_tungstenite::tungstenite::handshake::server::Response| {
if req.uri().path() == want_path {
Ok(resp)
} else {
let mut reject = tokio_tungstenite::tungstenite::handshake::server::ErrorResponse::new(
Some("forbidden".to_string()),
);
*reject.status_mut() = tokio_tungstenite::tungstenite::http::StatusCode::FORBIDDEN;
Err(reject)
}
};
let ws = match tokio_tungstenite::accept_hdr_async(stream, cb).await {
Ok(ws) => ws,
Err(_) => return,
};
nm_log("[nm-host] cdp client connected");
// Ask the extension to (re)attach + announce every tab so this client
// discovers the user's existing tabs instead of racing an empty list.
let _ = to_ext.send(br#"{"method":"attachAll"}"#.to_vec()).await;
let (mut tx, mut rx) = ws.split();
loop {
tokio::select! {
relayed = from_relay.recv() => match relayed {
Ok(text) => { if tx.send(Message::Text(text)).await.is_err() { break } }
Err(RecvError::Lagged(_)) => continue,
Err(RecvError::Closed) => break,
},
incoming = rx.next() => match incoming {
Some(Ok(Message::Text(text))) => {
let v: serde_json::Value = match serde_json::from_str(&text) {
Ok(v) => v,
Err(_) => continue,
};
let route = { state.lock().await.route_client_command(&v) };
match route {
ClientRoute::Local(reply) => {
if tx.send(Message::Text(reply.to_string())).await.is_err() { break }
}
ClientRoute::Forward(env) => {
let _ = to_ext.send(env.to_string().into_bytes()).await;
}
}
}
Some(Ok(Message::Close(_))) | None => break,
_ => {}
},
}
}
nm_log("[nm-host] cdp client disconnected");
}
+35 -1
View File
@@ -2,6 +2,7 @@ mod chat;
mod color;
mod commands;
mod connection;
mod connect;
mod doctor;
mod findurl;
mod flags;
@@ -503,6 +504,14 @@ fn main() {
env::set_var("MSYS2_ARG_CONV_EXCL", "*");
}
// Native-messaging host mode: Chrome launches `agent-browser __nm-host
// <extension-origin> [...]` for the ab-connect extension. Must run before
// ANY stdout write — stdout is the Chrome native-messaging channel.
if env::args().nth(1).as_deref() == Some("__nm-host") {
connect::run_nm_host();
return;
}
// Native daemon mode: when AGENT_BROWSER_DAEMON is set, run as the daemon process
if env::var("AGENT_BROWSER_DAEMON").is_ok() {
// Ignore SIGPIPE so the daemon isn't killed when the parent drops
@@ -530,7 +539,7 @@ fn main() {
let args: Vec<String> = env::args().skip(1).collect();
let mut flags = parse_flags(&args);
let clean = clean_args(&args);
let mut clean = clean_args(&args);
// Loudly warn when launching a fresh browser with no profile: it gets a
// temporary EMPTY profile (no cookies / no login). For logged-in sites the
@@ -641,6 +650,31 @@ fn main() {
return;
}
// Handle extension: native-messaging host install/status, and
// `extension connect` which attaches to the live relay (auto-discovers the
// CDP url the host wrote) by rewriting into the normal `connect <url>` flow.
// (`connect <port>` stays the plain CDP-attach command.)
if clean.first().map(|s| s.as_str()) == Some("extension") {
if clean.get(1).map(|s| s.as_str()) == Some("connect") {
match connect::relay_url() {
Some(url) => {
clean = vec!["connect".to_string(), url];
// fall through to the normal connect handling below
}
None => {
eprintln!(
"{} extension not connected. Run `agent-browser extension install`, load the\n ab-connect extension in Chrome (chrome://extensions → Developer mode →\n Load unpacked → extensions/ab-connect), then retry.",
color::error_indicator()
);
exit(1);
}
}
} else {
connect::run_connect(&clean, flags.json);
return;
}
}
// Handle session separately (doesn't need daemon)
if clean.first().map(|s| s.as_str()) == Some("session") {
run_session(&clean, &flags.session, flags.json);
+2
View File
@@ -27,6 +27,8 @@ pub mod policy;
#[allow(dead_code)]
pub mod providers;
#[allow(dead_code)]
pub mod relay;
#[allow(dead_code)]
pub mod react;
#[allow(dead_code)]
pub mod recording;
+362
View File
@@ -0,0 +1,362 @@
//! Relay between the `ab-connect` browser extension and the daemon's `CdpClient`.
//!
//! The extension speaks a small CDP-over-WebSocket "envelope" protocol (adapted
//! from openclaw-browser-relay) and drives the user's real tabs via per-tab
//! `chrome.debugger`. The daemon's `CdpClient`, however, expects a **browser-
//! level** CDP endpoint (`Target.getTargets` / `Target.attachToTarget` → a
//! `sessionId`, then per-session commands). This relay bridges the two: it
//! tracks the targets the extension reports, answers the browser-level
//! `Target.*` discovery commands LOCALLY, and forwards everything else to the
//! extension as `forwardCDPCommand`. That keeps `CdpClient` and `browser.rs`
//! unchanged.
//!
//! This module is the pure translation core (no I/O) so the protocol can be
//! unit-tested; the tokio WebSocket server that drives it lives alongside.
use std::collections::HashMap;
use serde_json::{json, Value};
/// Protocol version advertised in the connect handshake (matches the extension).
pub const RELAY_PROTOCOL: i64 = 3;
/// One target (tab) the extension has attached, as the relay tracks it.
#[derive(Clone)]
struct TargetEntry {
session_id: String,
target_info: Value,
}
/// Relay translation state: the set of targets the extension currently exposes.
#[derive(Default)]
pub struct RelayState {
/// targetId -> entry
targets: HashMap<String, TargetEntry>,
}
/// What to do with a raw CDP command received from the `CdpClient`.
#[derive(Debug, PartialEq)]
pub enum ClientRoute {
/// Answer locally; the value is a raw CDP response `{id, result}`.
Local(Value),
/// Forward to the extension; the value is a `forwardCDPCommand` envelope.
Forward(Value),
}
/// An output the relay emits while handling an extension message.
#[derive(Debug, PartialEq)]
pub enum RelayOut {
/// Send this raw CDP message to the `CdpClient`.
ToClient(Value),
/// Send this envelope message back to the extension.
ToExt(Value),
}
impl RelayState {
pub fn new() -> Self {
Self::default()
}
/// The challenge the relay sends to the extension as soon as it connects,
/// kicking off the connect handshake.
pub fn connect_challenge(nonce: &str) -> Value {
json!({ "type": "event", "event": "connect.challenge", "payload": { "nonce": nonce } })
}
/// A keepalive ping for the extension.
pub fn ping() -> Value {
json!({ "method": "ping" })
}
/// Route a raw CDP command `{id, method, params?, sessionId?}` from the
/// `CdpClient`: answer browser-level `Target.*` discovery locally, forward
/// the rest to the extension.
pub fn route_client_command(&self, raw: &Value) -> ClientRoute {
let id = raw.get("id").cloned().unwrap_or(Value::Null);
let method = raw.get("method").and_then(|m| m.as_str()).unwrap_or("");
let params = raw.get("params").cloned().unwrap_or_else(|| json!({}));
let session_id = raw.get("sessionId").and_then(|s| s.as_str());
match method {
// Discovery is best-effort and event-driven in real CDP; abs only
// reads the getTargets result, so an empty ack is enough here.
"Target.setDiscoverTargets" | "Target.setAutoAttach" => {
ClientRoute::Local(json!({ "id": id, "result": {} }))
}
"Target.getTargets" => {
let infos: Vec<Value> =
self.targets.values().map(|t| t.target_info.clone()).collect();
ClientRoute::Local(json!({ "id": id, "result": { "targetInfos": infos } }))
}
"Target.attachToTarget" => {
let target_id = params.get("targetId").and_then(|t| t.as_str()).unwrap_or("");
match self.targets.get(target_id) {
Some(entry) => ClientRoute::Local(
json!({ "id": id, "result": { "sessionId": entry.session_id } }),
),
None => ClientRoute::Local(json!({
"id": id,
"error": { "code": -32602, "message": format!("No such target {target_id}") }
})),
}
}
// Everything else goes to the extension's chrome.debugger.
_ => ClientRoute::Forward(json!({
"id": id,
"method": "forwardCDPCommand",
"params": { "method": method, "params": params, "sessionId": session_id },
})),
}
}
/// Handle one decoded message from the extension. Updates target state and
/// returns the messages to emit (to the client and/or back to the
/// extension). `expected_token` is matched against the connect handshake.
pub fn handle_ext_message(&mut self, msg: &Value, expected_token: &str) -> Vec<RelayOut> {
// Connect handshake request from the extension.
if msg.get("type").and_then(|t| t.as_str()) == Some("req")
&& msg.get("method").and_then(|m| m.as_str()) == Some("connect")
{
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let token = msg
.get("params")
.and_then(|p| p.get("auth"))
.and_then(|a| a.get("token"))
.and_then(|t| t.as_str())
.unwrap_or("");
let ok = !expected_token.is_empty() && token == expected_token;
let mut res = json!({ "type": "res", "id": id, "ok": ok });
if !ok {
res["error"] = json!({ "message": "invalid relay token" });
}
return vec![RelayOut::ToExt(res)];
}
// Keepalive.
if msg.get("method").and_then(|m| m.as_str()) == Some("pong") {
return vec![];
}
// Response to a forwardCDPCommand we sent → raw CDP response to client.
if msg.get("id").is_some()
&& (msg.get("result").is_some() || msg.get("error").is_some())
&& msg.get("method").is_none()
{
let mut out = json!({ "id": msg.get("id").cloned().unwrap_or(Value::Null) });
if let Some(r) = msg.get("result") {
out["result"] = r.clone();
}
if let Some(e) = msg.get("error") {
// CdpClient expects an error object; wrap a bare string.
out["error"] = match e {
Value::String(s) => json!({ "code": -32000, "message": s }),
other => other.clone(),
};
}
return vec![RelayOut::ToClient(out)];
}
// CDP event forwarded from a tab.
if msg.get("method").and_then(|m| m.as_str()) == Some("forwardCDPEvent") {
let p = msg.get("params").cloned().unwrap_or_else(|| json!({}));
let inner_method = p.get("method").and_then(|m| m.as_str()).unwrap_or("");
let inner_params = p.get("params").cloned().unwrap_or_else(|| json!({}));
let session_id = p.get("sessionId").and_then(|s| s.as_str());
// Learn/forget targets from the extension's synthesized Target events.
// We consume these to maintain state and do NOT forward them: abs
// discovers targets by pulling getTargets, and forwarding a second
// attachedToTarget would duplicate the one attachToTarget emits.
match inner_method {
"Target.attachedToTarget" => {
if let Some(info) = inner_params.get("targetInfo") {
if let Some(tid) = info.get("targetId").and_then(|t| t.as_str()) {
let sid = inner_params
.get("sessionId")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
self.targets.insert(
tid.to_string(),
TargetEntry { session_id: sid, target_info: info.clone() },
);
}
}
return vec![];
}
"Target.detachedFromTarget" => {
let gone = inner_params.get("sessionId").and_then(|s| s.as_str());
if let Some(gone) = gone {
self.targets.retain(|_, e| e.session_id != gone);
}
return vec![];
}
_ => {}
}
// Regular CDP event → deliver to the client with its sessionId.
let mut ev = json!({ "method": inner_method, "params": inner_params });
if let Some(sid) = session_id {
ev["sessionId"] = json!(sid);
}
return vec![RelayOut::ToClient(ev)];
}
vec![]
}
#[cfg(test)]
fn seed_target(&mut self, target_id: &str, session_id: &str) {
self.targets.insert(
target_id.to_string(),
TargetEntry {
session_id: session_id.to_string(),
target_info: json!({
"targetId": target_id,
"type": "page",
"title": "",
"url": "about:blank",
"attached": true,
}),
},
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn attached_event(target_id: &str, session_id: &str) -> Value {
json!({
"method": "forwardCDPEvent",
"params": {
"sessionId": session_id,
"method": "Target.attachedToTarget",
"params": {
"sessionId": session_id,
"targetInfo": { "targetId": target_id, "type": "page", "url": "https://x", "title": "X" }
}
}
})
}
#[test]
fn learns_target_from_attached_event_and_does_not_forward_it() {
let mut s = RelayState::new();
let out = s.handle_ext_message(&attached_event("T1", "cb-tab-1"), "tok");
assert!(out.is_empty(), "attachedToTarget should be consumed, not forwarded");
// Now getTargets must report it.
let route = s.route_client_command(&json!({ "id": 1, "method": "Target.getTargets" }));
match route {
ClientRoute::Local(v) => {
let infos = v["result"]["targetInfos"].as_array().unwrap();
assert_eq!(infos.len(), 1);
assert_eq!(infos[0]["targetId"], "T1");
}
_ => panic!("getTargets must be local"),
}
}
#[test]
fn attach_to_target_returns_known_session() {
let mut s = RelayState::new();
s.seed_target("T1", "cb-tab-1");
let route = s.route_client_command(
&json!({ "id": 5, "method": "Target.attachToTarget", "params": { "targetId": "T1", "flatten": true } }),
);
assert_eq!(
route,
ClientRoute::Local(json!({ "id": 5, "result": { "sessionId": "cb-tab-1" } }))
);
}
#[test]
fn attach_to_unknown_target_errors_locally() {
let s = RelayState::new();
let route = s.route_client_command(
&json!({ "id": 6, "method": "Target.attachToTarget", "params": { "targetId": "nope" } }),
);
match route {
ClientRoute::Local(v) => assert!(v.get("error").is_some()),
_ => panic!("should answer locally"),
}
}
#[test]
fn other_commands_forward_as_envelope() {
let s = RelayState::new();
let route = s.route_client_command(&json!({
"id": 9, "method": "Page.navigate",
"params": { "url": "https://x" }, "sessionId": "cb-tab-1"
}));
match route {
ClientRoute::Forward(v) => {
assert_eq!(v["method"], "forwardCDPCommand");
assert_eq!(v["id"], 9);
assert_eq!(v["params"]["method"], "Page.navigate");
assert_eq!(v["params"]["sessionId"], "cb-tab-1");
assert_eq!(v["params"]["params"]["url"], "https://x");
}
_ => panic!("Page.navigate must forward"),
}
}
#[test]
fn forward_command_response_becomes_client_response() {
let mut s = RelayState::new();
let out = s.handle_ext_message(&json!({ "id": 9, "result": { "frameId": "F1" } }), "tok");
assert_eq!(
out,
vec![RelayOut::ToClient(json!({ "id": 9, "result": { "frameId": "F1" } }))]
);
}
#[test]
fn forward_command_error_is_wrapped() {
let mut s = RelayState::new();
let out = s.handle_ext_message(&json!({ "id": 9, "error": "boom" }), "tok");
match &out[0] {
RelayOut::ToClient(v) => {
assert_eq!(v["id"], 9);
assert_eq!(v["error"]["message"], "boom");
}
_ => panic!("expected ToClient"),
}
}
#[test]
fn regular_event_is_forwarded_with_session() {
let mut s = RelayState::new();
let ev = json!({
"method": "forwardCDPEvent",
"params": { "sessionId": "cb-tab-1", "method": "Page.loadEventFired", "params": { "timestamp": 1.0 } }
});
let out = s.handle_ext_message(&ev, "tok");
assert_eq!(
out,
vec![RelayOut::ToClient(json!({
"method": "Page.loadEventFired",
"params": { "timestamp": 1.0 },
"sessionId": "cb-tab-1"
}))]
);
}
#[test]
fn connect_handshake_validates_token() {
let mut s = RelayState::new();
let req = json!({ "type": "req", "id": "c1", "method": "connect", "params": { "auth": { "token": "good" } } });
let ok = s.handle_ext_message(&req, "good");
assert_eq!(ok, vec![RelayOut::ToExt(json!({ "type": "res", "id": "c1", "ok": true }))]);
let bad = s.handle_ext_message(&req, "different");
match &bad[0] {
RelayOut::ToExt(v) => {
assert_eq!(v["ok"], false);
assert!(v.get("error").is_some());
}
_ => panic!("expected ToExt"),
}
}
}
+11
View File
@@ -0,0 +1,11 @@
# Attribution
The chrome.debugger attach + CDP Target handling in `background.js` is adapted
from **openclaw-browser-relay** by chengyixu
(https://github.com/chengyixu/openclaw-browser-relay, MIT per its README).
Changes for agent-browser-stealth: rebranded to "agent-browser connect"; the
transport is rewritten from a localhost WebSocket + shared token to Chrome
**native messaging** (host `com.agent_browser.connect`) — no port, no token,
Chrome authenticates the extension to the host by id. WebSocket/token/options
code removed.
+318
View File
@@ -0,0 +1,318 @@
// agent-browser connect — MV3 service worker.
//
// Bridges the user's real Chrome tabs to the local agent-browser daemon over a
// Chrome **native messaging** channel (no localhost port, no token: Chrome
// authenticates this extension to the host by id). It attaches chrome.debugger
// to eligible tabs and relays CDP both ways via a tiny envelope:
// host → ext : {id, method:"forwardCDPCommand", params:{method,params,sessionId}}
// ext → host : {id, result|error} (command reply)
// ext → host : {method:"forwardCDPEvent", params:{sessionId,method,params}}
//
// Target/discovery semantics (getTargets/attachToTarget) are emulated on the
// daemon side; here we just attach tabs and announce them as
// Target.attachedToTarget so the daemon's CDP client sees them appear.
//
// Adapted from openclaw-browser-relay (MIT, chengyixu) — the chrome.debugger
// attach + Target handling; the transport is rewritten from WebSocket+token to
// native messaging.
const HOST_NAME = 'com.agent_browser.connect'
const SKIP_URL = /^(chrome|chrome-extension|devtools|chrome-untrusted|edge|about):/i
/** @type {chrome.runtime.Port|null} */
let port = null
let nextSession = 1
/** tabId -> { sessionId, targetId } */
const tabs = new Map()
/** sessionId -> tabId (main session per tab) */
const sessionToTab = new Map()
/** child (OOPIF/worker) sessionId -> tabId */
const childSessionToTab = new Map()
function postToHost(msg) {
try {
if (port) port.postMessage(msg)
} catch (e) {
// port died; onDisconnect will reconnect.
}
}
function setBadge(tabId, kind) {
const map = { on: '', connecting: '…', error: '!' }
const colors = { on: '#16a34a', connecting: '#d97706', error: '#b91c1c' }
try {
chrome.action.setBadgeText({ tabId, text: map[kind] ?? '' })
if (colors[kind]) chrome.action.setBadgeBackgroundColor({ tabId, color: colors[kind] })
} catch {}
}
// ---- native messaging transport ------------------------------------------
function connectHost() {
if (port) return
try {
port = chrome.runtime.connectNative(HOST_NAME)
} catch (e) {
port = null
return
}
port.onMessage.addListener((msg) => void whenReady(() => onHostMessage(msg)))
port.onDisconnect.addListener(() => {
port = null
// Sessions are stale once the host is gone; the daemon re-discovers on
// reconnect. Keep chrome.debugger attached so reconnect is cheap.
for (const tabId of tabs.keys()) setBadge(tabId, 'connecting')
})
// Tell the daemon about everything we already have attached, then attach
// anything new.
reannounceAttachedTabs()
void attachAllTabs()
}
async function onHostMessage(msg) {
if (!msg || typeof msg !== 'object') return
// Optional keepalive.
if (msg.method === 'ping') {
postToHost({ method: 'pong' })
return
}
// Daemon (re)connected — (re)attach and announce every tab so it discovers
// the user's existing tabs rather than racing an empty target list.
if (msg.method === 'attachAll') {
reannounceAttachedTabs()
await attachAllTabs()
return
}
if (typeof msg.id !== 'undefined' && msg.method === 'forwardCDPCommand') {
try {
const result = await handleForwardCdpCommand(msg)
postToHost({ id: msg.id, result })
} catch (err) {
postToHost({ id: msg.id, error: err instanceof Error ? err.message : String(err) })
}
}
}
// ---- CDP command dispatch -------------------------------------------------
function tabForSession(sessionId) {
return sessionToTab.get(sessionId) ?? childSessionToTab.get(sessionId) ?? null
}
function tabForTarget(targetId) {
for (const [tabId, t] of tabs.entries()) if (t.targetId === targetId) return tabId
return null
}
function anyConnectedTab() {
const it = tabs.keys().next()
return it.done ? null : it.value
}
async function handleForwardCdpCommand(msg) {
const method = String(msg?.params?.method || '')
const params = msg?.params?.params || undefined
const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined
// Browser-level Target methods that map onto chrome.tabs.
if (method === 'Target.createTarget') {
const url = typeof params?.url === 'string' && params.url ? params.url : 'about:blank'
const tab = await chrome.tabs.create({ url, active: false })
if (!tab.id) throw new Error('createTarget: no tab id')
await new Promise((r) => setTimeout(r, 100))
const t = await attachTab(tab.id)
return { targetId: t.targetId }
}
if (method === 'Target.closeTarget') {
const tid = typeof params?.targetId === 'string' ? params.targetId : ''
const tabId = tid ? tabForTarget(tid) : null
if (!tabId) return { success: false }
try {
await chrome.tabs.remove(tabId)
} catch {
return { success: false }
}
return { success: true }
}
if (method === 'Target.activateTarget') {
const tid = typeof params?.targetId === 'string' ? params.targetId : ''
const tabId = tid ? tabForTarget(tid) : null
if (tabId) {
const tab = await chrome.tabs.get(tabId).catch(() => null)
if (tab?.windowId) await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {})
await chrome.tabs.update(tabId, { active: true }).catch(() => {})
}
return {}
}
// Everything else → chrome.debugger on the resolved tab.
const tabId =
(sessionId ? tabForSession(sessionId) : null) ??
(typeof params?.targetId === 'string' ? tabForTarget(params.targetId) : null) ??
anyConnectedTab()
if (!tabId) throw new Error(`no attached tab for ${method}`)
const dbg = { tabId }
// Re-enabling Runtime can leave a stale state; bounce it (matches upstream).
if (method === 'Runtime.enable') {
try {
await chrome.debugger.sendCommand(dbg, 'Runtime.disable')
await new Promise((r) => setTimeout(r, 30))
} catch {}
return await chrome.debugger.sendCommand(dbg, 'Runtime.enable', params)
}
return await chrome.debugger.sendCommand(dbg, method, params)
}
// ---- attach / detach ------------------------------------------------------
async function attachTab(tabId) {
const existing = tabs.get(tabId)
if (existing) return existing
const dbg = { tabId }
try {
await chrome.debugger.attach(dbg, '1.3')
} catch (e) {
// After a service-worker restart, chrome.debugger may still be bound to
// this tab from the previous instance — "Another debugger is already
// attached". The tab is still controllable via {tabId}, so don't skip it
// (skipping is why existing tabs went un-announced and the daemon opened a
// blank tab instead). Re-announce it. Any other error (restricted page) is
// surfaced and the caller skips this tab.
const msg = String((e && e.message) || e)
if (!/already attached|already being debugged/i.test(msg)) throw e
}
await chrome.debugger.sendCommand(dbg, 'Page.enable').catch(() => {})
const info = /** @type {any} */ (await chrome.debugger.sendCommand(dbg, 'Target.getTargetInfo'))
const targetInfo = info?.targetInfo
const targetId = String(targetInfo?.targetId || '')
if (!targetId) throw new Error('attachTab: no targetId')
const sessionId = `cb-tab-${nextSession++}`
const entry = { sessionId, targetId }
tabs.set(tabId, entry)
sessionToTab.set(sessionId, tabId)
setBadge(tabId, port ? 'on' : 'connecting')
postToHost({
method: 'forwardCDPEvent',
params: {
sessionId,
method: 'Target.attachedToTarget',
params: { sessionId, targetInfo: { ...targetInfo, attached: true } },
},
})
return entry
}
function detachTab(tabId, notify) {
const entry = tabs.get(tabId)
if (!entry) return
tabs.delete(tabId)
sessionToTab.delete(entry.sessionId)
for (const [sid, tid] of childSessionToTab.entries()) if (tid === tabId) childSessionToTab.delete(sid)
if (notify) {
postToHost({
method: 'forwardCDPEvent',
params: { sessionId: entry.sessionId, method: 'Target.detachedFromTarget', params: { sessionId: entry.sessionId } },
})
}
}
function eligible(tab) {
return !!tab && !!tab.id && typeof tab.url === 'string' && !SKIP_URL.test(tab.url)
}
async function attachAllTabs() {
let all = []
try {
all = await chrome.tabs.query({})
} catch {
return
}
for (const tab of all) {
if (eligible(tab) && !tabs.has(tab.id)) {
try {
await attachTab(tab.id)
} catch {
// Tab may be a restricted page or already attached elsewhere.
}
}
}
}
function reannounceAttachedTabs() {
for (const [, entry] of tabs.entries()) {
postToHost({
method: 'forwardCDPEvent',
params: {
sessionId: entry.sessionId,
method: 'Target.attachedToTarget',
params: { sessionId: entry.sessionId, targetInfo: { targetId: entry.targetId, type: 'page', attached: true } },
},
})
}
}
// ---- chrome.debugger events ----------------------------------------------
chrome.debugger.onEvent.addListener((source, method, params) =>
void whenReady(() => {
const tabId = source.tabId
if (!tabId) return
const entry = tabs.get(tabId)
if (!entry) return
if (method === 'Target.attachedToTarget' && params?.sessionId) {
childSessionToTab.set(String(params.sessionId), tabId)
}
if (method === 'Target.detachedFromTarget' && params?.sessionId) {
childSessionToTab.delete(String(params.sessionId))
}
postToHost({
method: 'forwardCDPEvent',
params: { sessionId: source.sessionId || entry.sessionId, method, params },
})
}),
)
chrome.debugger.onDetach.addListener((source) =>
void whenReady(() => {
if (source.tabId) detachTab(source.tabId, true)
}),
)
// ---- tab lifecycle --------------------------------------------------------
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) =>
void whenReady(async () => {
if (changeInfo.status === 'complete' && eligible(tab) && !tabs.has(tabId) && port) {
try {
await attachTab(tabId)
} catch {}
}
}),
)
chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => detachTab(tabId, true)))
// ---- bootstrap + keepalive ------------------------------------------------
chrome.runtime.onInstalled.addListener(() => void whenReady(connectHost))
chrome.runtime.onStartup.addListener(() => void whenReady(connectHost))
chrome.action.onClicked.addListener(() => void whenReady(connectHost))
// MV3 service workers get suspended; an alarm wakes us to keep the host link
// and badges fresh.
chrome.alarms.create('keepalive', { periodInMinutes: 0.4 })
chrome.alarms.onAlarm.addListener((a) => {
if (a.name !== 'keepalive') return
void whenReady(() => {
if (!port) connectHost()
else void attachAllTabs()
})
})
// Gate placeholder so future async state-rehydration can hook in.
async function whenReady(fn) {
return fn()
}
// Kick a connection attempt as soon as the worker starts.
connectHost()
+16
View File
@@ -0,0 +1,16 @@
{
"manifest_version": 3,
"name": "agent-browser connect",
"version": "0.2.0",
"description": "Let agent-browser drive your logged-in Chrome — install once, no token, no per-use confirmation.",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvPGcSmMx7dSfq9gRBDbQuAgx/+TEavrDxP/4jLa2+Ycexf/FEmq1MN8gAHoTRjSyp66YKD+1qI1CF6bk0rH5ZtxpRO7DYRTUPcsA1IpHbgEn5mppx3YxNGZilfkEZyrxdhBqUIzq3J74+/kpZzEVsO+DQTbAZSsFfdUkoCb5mbJid2VQYeqeBnYGAhbpGvN1P99jdT9EA1nKINb3ji6tLobCpyQ1fjf2uWm4mUirWkkF/nbUFVFEAh33Q/IYZmtUHgDYea5LsM9xH4KAG2kxMvFGj6vHR39sZd5/+gnvwScTcItUWQ9lFIyWYiwrSB25Lu0FshfllevXUFrG5vrvRwIDAQAB",
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"permissions": ["debugger", "tabs", "nativeMessaging", "storage", "alarms", "webNavigation"],
"background": { "service_worker": "background.js", "type": "module" },
"action": { "default_title": "agent-browser connect" }
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agent-browser-stealth",
"version": "0.27.0-fork.17",
"version": "0.27.0-fork.18",
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
"type": "module",
"packageManager": "pnpm@11.1.3",
+12
View File
@@ -40,11 +40,23 @@ need a **real, logged-in browser** — not for reading text off a public page.
| 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 |
| The user's **own already-open, logged-in** Chrome window | the **extension connect** flow (below) |
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.
### Driving the user's real, already-open Chrome (extension)
When the task needs the user's *live* logged-in window (their real session, the
window they're looking at — not a fresh browser), use the extension connect flow:
`agent-browser extension install` once, load `extensions/ab-connect` in
`chrome://extensions` once (a GUI step you can perform with a **computer-use /
GUI-automation tool** like the `cua-driver` skill — see
`references/commands.md` → "Drive your real, logged-in Chrome"), then
`agent-browser extension connect`. After that it's zero-confirmation, zero-token
CLI. Use `--launch` instead when a fresh, isolated browser is fine.
## 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:
+43
View File
@@ -318,6 +318,49 @@ agent-browser --version # Show version (-V)
agent-browser <command> --help # Show detailed help for a command
```
## Drive your real, logged-in Chrome (extension — zero confirmation)
Chrome 136 blocked `--remote-debugging-port` on the default profile, so to drive
the user's *existing* logged-in window, agent-browser uses a Chrome **extension**
over native messaging — no port, no token, no per-use confirmation (the
codex/claude approach).
One-time setup:
```bash
agent-browser extension install # writes the native-messaging host manifest
```
Then load the extension **once** — this is a GUI step (Chrome's `chrome://extensions`
is privileged; the CLI can't load an unpacked extension):
> chrome://extensions → enable **Developer mode** (top-right) → **Load unpacked** →
> select `<repo>/extensions/ab-connect`
**You can do this load step yourself with a computer-use / GUI-automation tool**
(e.g. the `cua-driver` skill) — drive `chrome://extensions`, toggle Developer
mode, click *Load unpacked*, pick the folder in the Open dialog. If the extension
is already loaded, clicking its **Reload** (↻) button after a code change is
enough. Notes from doing this live: tools that send *synthetic keystrokes* (e.g.
peekaboo) often don't reach Chrome — **`cua-driver` works** because it reads
Chrome's accessibility tree and clicks real elements. The native "Open" file
dialog is the fiddly part; if keystroke entry there fails, ask the user to pick
the folder (one click). After it loads, Chrome assigns the extension a fixed id
(pinned in its manifest) and auto-connects the host.
Then, any time (pure CLI, zero confirmation):
```bash
agent-browser extension connect # auto-attaches to the live, logged-in tabs
agent-browser tab # list the real tabs it now controls
agent-browser tab t3 # switch the session to one of them
agent-browser snapshot -i / eval / click ... # drive it like any session
agent-browser extension status # is the host installed?
agent-browser extension uninstall # remove the host manifest
```
Security: the extension↔host link is authenticated by Chrome (extension id); the
host↔agent-browser CDP link uses an unguessable URL in a 0600 file. Use this when
you need the user's real cookies/login on their actual machine. (`--extension
<path>` is unrelated — that loads an extension into a *launched* browser.)
## Debugging
```bash