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.
This commit is contained in:
@@ -233,6 +233,19 @@ fn relay_url_path() -> PathBuf {
|
|||||||
.unwrap_or_else(|| PathBuf::from("/tmp/ab-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.
|
/// Hidden `__nm-host` mode: launched by Chrome for the ab-connect extension.
|
||||||
///
|
///
|
||||||
/// Bridges the extension (native-messaging stdio, envelope protocol) to a local
|
/// Bridges the extension (native-messaging stdio, envelope protocol) to a local
|
||||||
@@ -390,6 +403,9 @@ async fn handle_cdp_client(
|
|||||||
Err(_) => return,
|
Err(_) => return,
|
||||||
};
|
};
|
||||||
nm_log("[nm-host] cdp client connected");
|
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();
|
let (mut tx, mut rx) = ws.split();
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
|
|||||||
+23
-5
@@ -539,7 +539,7 @@ fn main() {
|
|||||||
|
|
||||||
let args: Vec<String> = env::args().skip(1).collect();
|
let args: Vec<String> = env::args().skip(1).collect();
|
||||||
let mut flags = parse_flags(&args);
|
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
|
// 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
|
// temporary EMPTY profile (no cookies / no login). For logged-in sites the
|
||||||
@@ -650,11 +650,29 @@ fn main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle extension (doesn't need daemon): native-messaging host install/status
|
// Handle extension: native-messaging host install/status, and
|
||||||
// for the ab-connect extension. (`connect <port>` stays the CDP-attach command.)
|
// `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.first().map(|s| s.as_str()) == Some("extension") {
|
||||||
connect::run_connect(&clean, flags.json);
|
if clean.get(1).map(|s| s.as_str()) == Some("connect") {
|
||||||
return;
|
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)
|
// Handle session separately (doesn't need daemon)
|
||||||
|
|||||||
@@ -76,6 +76,13 @@ async function onHostMessage(msg) {
|
|||||||
postToHost({ method: 'pong' })
|
postToHost({ method: 'pong' })
|
||||||
return
|
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') {
|
if (typeof msg.id !== 'undefined' && msg.method === 'forwardCDPCommand') {
|
||||||
try {
|
try {
|
||||||
const result = await handleForwardCdpCommand(msg)
|
const result = await handleForwardCdpCommand(msg)
|
||||||
@@ -163,7 +170,18 @@ async function attachTab(tabId) {
|
|||||||
const existing = tabs.get(tabId)
|
const existing = tabs.get(tabId)
|
||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
const dbg = { tabId }
|
const dbg = { tabId }
|
||||||
await chrome.debugger.attach(dbg, '1.3')
|
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(() => {})
|
await chrome.debugger.sendCommand(dbg, 'Page.enable').catch(() => {})
|
||||||
const info = /** @type {any} */ (await chrome.debugger.sendCommand(dbg, 'Target.getTargetInfo'))
|
const info = /** @type {any} */ (await chrome.debugger.sendCommand(dbg, 'Target.getTargetInfo'))
|
||||||
const targetInfo = info?.targetInfo
|
const targetInfo = info?.targetInfo
|
||||||
|
|||||||
@@ -318,6 +318,35 @@ agent-browser --version # Show version (-V)
|
|||||||
agent-browser <command> --help # Show detailed help for a command
|
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 in Chrome: chrome://extensions → Developer mode → Load unpacked →
|
||||||
|
# <repo>/extensions/ab-connect (load once)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, any time:
|
||||||
|
```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
|
## Debugging
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Reference in New Issue
Block a user