From 528de4230f0515841f6cffe2c224fb96b8a04bab Mon Sep 17 00:00:00 2001 From: leeguooooo Date: Tue, 9 Jun 2026 16:39:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(connect):=20native-messaging=20transport?= =?UTF-8?q?=20=E2=80=94=20zero-token=20connect=20to=20real=20Chrome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cli/src/connect.rs | 289 +++++ cli/src/main.rs | 16 + extensions/ab-connect/NOTICE.md | 15 +- extensions/ab-connect/background-utils.js | 48 - extensions/ab-connect/background.js | 1108 ++++--------------- extensions/ab-connect/manifest.json | 19 +- extensions/ab-connect/options-validation.js | 57 - extensions/ab-connect/options.html | 200 ---- extensions/ab-connect/options.js | 74 -- 9 files changed, 535 insertions(+), 1291 deletions(-) create mode 100644 cli/src/connect.rs delete mode 100644 extensions/ab-connect/background-utils.js delete mode 100644 extensions/ab-connect/options-validation.js delete mode 100644 extensions/ab-connect/options.html delete mode 100644 extensions/ab-connect/options.js diff --git a/cli/src/connect.rs b/cli/src/connect.rs new file mode 100644 index 0000000..a4d9bb1 --- /dev/null +++ b/cli/src/connect.rs @@ -0,0 +1,289 @@ +//! `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::{Read, 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 ` (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, 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 { + 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 { + 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`) ---------------------------------- + +/// Read one native-messaging frame from stdin: 4-byte native-endian length, +/// then that many bytes of UTF-8 JSON. Returns `None` on clean EOF. +fn read_frame(stdin: &mut impl Read) -> std::io::Result>> { + let mut len_buf = [0u8; 4]; + match stdin.read_exact(&mut len_buf) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None), + Err(e) => return Err(e), + } + let len = u32::from_ne_bytes(len_buf) as usize; + let mut buf = vec![0u8; len]; + stdin.read_exact(&mut buf)?; + Ok(Some(buf)) +} + +/// Write one native-messaging frame to stdout. +fn write_frame(stdout: &mut impl Write, payload: &[u8]) -> std::io::Result<()> { + let len = payload.len() as u32; + stdout.write_all(&len.to_ne_bytes())?; + stdout.write_all(payload)?; + stdout.flush() +} + +/// Hidden `__nm-host` mode: launched by Chrome for the ab-connect extension. +/// +/// Step 1 (this commit): speak the framing correctly and log what the extension +/// sends to `~/.agent-browser/nm-host.log`, replying `pong` to `ping`. The next +/// step bridges these frames to the daemon's relay + CdpClient. +pub fn run_nm_host() { + let log_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) = log_path.parent() { + let _ = std::fs::create_dir_all(p); + } + let mut log = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .ok(); + let mut logln = |s: &str| { + if let Some(f) = log.as_mut() { + let _ = writeln!(f, "{s}"); + let _ = f.flush(); + } + }; + logln(&format!( + "[nm-host] started argv={:?}", + std::env::args().skip(1).collect::>() + )); + + let mut stdin = std::io::stdin().lock(); + let mut stdout = std::io::stdout().lock(); + let mut count = 0usize; + loop { + match read_frame(&mut stdin) { + Ok(Some(bytes)) => { + count += 1; + let text = String::from_utf8_lossy(&bytes); + // Log a compact summary (method + sizes) without flooding. + let summary: String = text.chars().take(300).collect(); + logln(&format!("[nm-host] recv #{count} ({} bytes): {summary}", bytes.len())); + if let Ok(v) = serde_json::from_slice::(&bytes) { + if v.get("method").and_then(|m| m.as_str()) == Some("ping") { + let _ = write_frame(&mut stdout, br#"{"method":"pong"}"#); + } + } + } + Ok(None) => { + logln("[nm-host] stdin EOF — Chrome closed the port"); + break; + } + Err(e) => { + logln(&format!("[nm-host] read error: {e}")); + break; + } + } + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index ab1909e..9de762a 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -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 + // [...]` 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 @@ -641,6 +650,13 @@ fn main() { return; } + // Handle extension (doesn't need daemon): native-messaging host install/status + // for the ab-connect extension. (`connect ` stays the CDP-attach command.) + if clean.first().map(|s| s.as_str()) == Some("extension") { + 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); diff --git a/extensions/ab-connect/NOTICE.md b/extensions/ab-connect/NOTICE.md index 0a973de..200f1a2 100644 --- a/extensions/ab-connect/NOTICE.md +++ b/extensions/ab-connect/NOTICE.md @@ -1,10 +1,11 @@ # Attribution -The `ab-connect` extension is adapted from **openclaw-browser-relay** -by chengyixu — https://github.com/chengyixu/openclaw-browser-relay -(MIT License, per its README). +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; points at the agent-browser -daemon's local relay endpoint instead of the OpenClaw gateway; protocol -otherwise preserved (connect handshake, forwardCDPCommand/forwardCDPEvent, -ping/pong). +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. diff --git a/extensions/ab-connect/background-utils.js b/extensions/ab-connect/background-utils.js deleted file mode 100644 index fe32d2c..0000000 --- a/extensions/ab-connect/background-utils.js +++ /dev/null @@ -1,48 +0,0 @@ -export function reconnectDelayMs( - attempt, - opts = { baseMs: 1000, maxMs: 30000, jitterMs: 1000, random: Math.random }, -) { - const baseMs = Number.isFinite(opts.baseMs) ? opts.baseMs : 1000; - const maxMs = Number.isFinite(opts.maxMs) ? opts.maxMs : 30000; - const jitterMs = Number.isFinite(opts.jitterMs) ? opts.jitterMs : 1000; - const random = typeof opts.random === "function" ? opts.random : Math.random; - const safeAttempt = Math.max(0, Number.isFinite(attempt) ? attempt : 0); - const backoff = Math.min(baseMs * 2 ** safeAttempt, maxMs); - return backoff + Math.max(0, jitterMs) * random(); -} - -export async function deriveRelayToken(gatewayToken, port) { - const enc = new TextEncoder(); - const key = await crypto.subtle.importKey( - "raw", - enc.encode(gatewayToken), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], - ); - const sig = await crypto.subtle.sign( - "HMAC", - key, - enc.encode(`openclaw-extension-relay-v1:${port}`), - ); - return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join(""); -} - -export async function buildRelayWsUrl(port, gatewayToken) { - const token = String(gatewayToken || "").trim(); - if (!token) { - throw new Error( - "Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)", - ); - } - const relayToken = await deriveRelayToken(token, port); - return `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(relayToken)}`; -} - -export function isRetryableReconnectError(err) { - const message = err instanceof Error ? err.message : String(err || ""); - if (message.includes("Missing gatewayToken")) { - return false; - } - return true; -} diff --git a/extensions/ab-connect/background.js b/extensions/ab-connect/background.js index 4e84831..2a47ced 100644 --- a/extensions/ab-connect/background.js +++ b/extensions/ab-connect/background.js @@ -1,974 +1,300 @@ -import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js' +// 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 DEFAULT_PORT = 18792 - -const BADGE = { - on: { text: 'ON', color: '#FF5A36' }, - off: { text: '', color: '#000000' }, - connecting: { text: '…', color: '#F59E0B' }, - error: { text: '!', color: '#B91C1C' }, -} - -/** @type {WebSocket|null} */ -let relayWs = null -/** @type {Promise|null} */ -let relayConnectPromise = null -let relayGatewayToken = '' -/** @type {string|null} */ -let relayConnectRequestId = null +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 - -/** @type {Map} */ +/** tabId -> { sessionId, targetId } */ const tabs = new Map() -/** @type {Map} */ -const tabBySession = new Map() -/** @type {Map} */ +/** sessionId -> tabId (main session per tab) */ +const sessionToTab = new Map() +/** child (OOPIF/worker) sessionId -> tabId */ const childSessionToTab = new Map() -/** @type {Mapvoid, reject:(e:Error)=>void}>} */ -const pending = new Map() - -// Per-tab operation locks prevent double-attach races. -/** @type {Set} */ -const tabOperationLocks = new Set() - -// Tabs currently in a detach/re-attach cycle after navigation. -/** @type {Set} */ -const reattachPending = new Set() - -// Reconnect state for exponential backoff. -let reconnectAttempt = 0 -let reconnectTimer = null - -function nowStack() { +function postToHost(msg) { try { - return new Error().stack || '' - } catch { - return '' + if (port) port.postMessage(msg) + } catch (e) { + // port died; onDisconnect will reconnect. } } -async function getRelayPort() { - const stored = await chrome.storage.local.get(['relayPort']) - const raw = stored.relayPort - const n = Number.parseInt(String(raw || ''), 10) - if (!Number.isFinite(n) || n <= 0 || n > 65535) return DEFAULT_PORT - return n -} - -async function getGatewayToken() { - const stored = await chrome.storage.local.get(['gatewayToken']) - const token = String(stored.gatewayToken || '').trim() - return token || '' -} - function setBadge(tabId, kind) { - const cfg = BADGE[kind] - void chrome.action.setBadgeText({ tabId, text: cfg.text }) - void chrome.action.setBadgeBackgroundColor({ tabId, color: cfg.color }) - void chrome.action.setBadgeTextColor({ tabId, color: '#FFFFFF' }).catch(() => {}) -} - -// Persist attached tab state to survive MV3 service worker restarts. -async function persistState() { + const map = { on: '', connecting: '…', error: '!' } + const colors = { on: '#16a34a', connecting: '#d97706', error: '#b91c1c' } try { - const tabEntries = [] - for (const [tabId, tab] of tabs.entries()) { - if (tab.state === 'connected' && tab.sessionId && tab.targetId) { - tabEntries.push({ tabId, sessionId: tab.sessionId, targetId: tab.targetId, attachOrder: tab.attachOrder }) - } - } - await chrome.storage.session.set({ - persistedTabs: tabEntries, - nextSession, - }) - } catch { - // chrome.storage.session may not be available in all contexts. - } + chrome.action.setBadgeText({ tabId, text: map[kind] ?? '' }) + if (colors[kind]) chrome.action.setBadgeBackgroundColor({ tabId, color: colors[kind] }) + } catch {} } -// Rehydrate tab state on service worker startup. Fast path — just restores -// maps and badges. Relay reconnect happens separately in background. -async function rehydrateState() { +// ---- native messaging transport ------------------------------------------ + +function connectHost() { + if (port) return try { - const stored = await chrome.storage.session.get(['persistedTabs', 'nextSession']) - if (stored.nextSession) { - nextSession = Math.max(nextSession, stored.nextSession) - } - const entries = stored.persistedTabs || [] - // Phase 1: optimistically restore state and badges. - for (const entry of entries) { - tabs.set(entry.tabId, { - state: 'connected', - sessionId: entry.sessionId, - targetId: entry.targetId, - attachOrder: entry.attachOrder, - }) - tabBySession.set(entry.sessionId, entry.tabId) - setBadge(entry.tabId, 'on') - } - // Phase 2: validate asynchronously, remove dead tabs. - for (const entry of entries) { - try { - await chrome.tabs.get(entry.tabId) - await chrome.debugger.sendCommand({ tabId: entry.tabId }, 'Runtime.evaluate', { - expression: '1', - returnByValue: true, - }) - } catch { - tabs.delete(entry.tabId) - tabBySession.delete(entry.sessionId) - setBadge(entry.tabId, 'off') - } - } - } catch { - // Ignore rehydration errors. + port = chrome.runtime.connectNative(HOST_NAME) + } catch (e) { + port = null + return } -} - -async function ensureRelayConnection() { - if (relayWs && relayWs.readyState === WebSocket.OPEN) return - if (relayConnectPromise) return await relayConnectPromise - - relayConnectPromise = (async () => { - const port = await getRelayPort() - const gatewayToken = await getGatewayToken() - const httpBase = `http://127.0.0.1:${port}` - const wsUrl = await buildRelayWsUrl(port, gatewayToken) - - // Fast preflight: is the relay server up? - try { - await fetch(`${httpBase}/`, { method: 'HEAD', signal: AbortSignal.timeout(2000) }) - } catch (err) { - throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`) - } - - const ws = new WebSocket(wsUrl) - relayWs = ws - relayGatewayToken = gatewayToken - // Bind message handler before open so an immediate first frame (for example - // gateway connect.challenge) cannot be missed. - ws.onmessage = (event) => { - if (ws !== relayWs) return - void whenReady(() => onRelayMessage(String(event.data || ''))) - } - - await new Promise((resolve, reject) => { - const t = setTimeout(() => reject(new Error('WebSocket connect timeout')), 5000) - ws.onopen = () => { - clearTimeout(t) - resolve() - } - ws.onerror = () => { - clearTimeout(t) - reject(new Error('WebSocket connect failed')) - } - ws.onclose = (ev) => { - clearTimeout(t) - reject(new Error(`WebSocket closed (${ev.code} ${ev.reason || 'no reason'})`)) - } - }) - - // Bind permanent handlers. Guard against stale socket: if this WS was - // replaced before its close fires, the handler is a no-op. - ws.onclose = () => { - if (ws !== relayWs) return - onRelayClosed('closed') - } - ws.onerror = () => { - if (ws !== relayWs) return - onRelayClosed('error') - } - })() - - try { - await relayConnectPromise - reconnectAttempt = 0 - } finally { - relayConnectPromise = null - } -} - -// Relay closed — update badges, reject pending requests, auto-reconnect. -// Debugger sessions are kept alive so they survive transient WS drops. -function onRelayClosed(reason) { - relayWs = null - relayGatewayToken = '' - relayConnectRequestId = null - - for (const [id, p] of pending.entries()) { - pending.delete(id) - p.reject(new Error(`Relay disconnected (${reason})`)) - } - - reattachPending.clear() - - for (const [tabId, tab] of tabs.entries()) { - if (tab.state === 'connected') { - setBadge(tabId, 'connecting') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: relay reconnecting…', - }) - } - } - - scheduleReconnect() -} - -function scheduleReconnect() { - if (reconnectTimer) { - clearTimeout(reconnectTimer) - reconnectTimer = null - } - - const delay = reconnectDelayMs(reconnectAttempt) - reconnectAttempt++ - - console.log(`Scheduling reconnect attempt ${reconnectAttempt} in ${Math.round(delay)}ms`) - - reconnectTimer = setTimeout(async () => { - reconnectTimer = null - try { - await ensureRelayConnection() - reconnectAttempt = 0 - console.log('Reconnected successfully') - await reannounceAttachedTabs() - await autoAttachAllTabs() - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - console.warn(`Reconnect attempt ${reconnectAttempt} failed: ${message}`) - if (!isRetryableReconnectError(err)) { - return - } - scheduleReconnect() - } - }, delay) -} - -function cancelReconnect() { - if (reconnectTimer) { - clearTimeout(reconnectTimer) - reconnectTimer = null - } - reconnectAttempt = 0 -} - -// Re-announce all attached tabs to the relay after reconnect. -async function reannounceAttachedTabs() { - for (const [tabId, tab] of tabs.entries()) { - if (tab.state !== 'connected' || !tab.sessionId || !tab.targetId) continue - - // Verify debugger is still attached. - try { - await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { - expression: '1', - returnByValue: true, - }) - } catch { - tabs.delete(tabId) - if (tab.sessionId) tabBySession.delete(tab.sessionId) - setBadge(tabId, 'off') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay (auto-attach enabled)', - }) - continue - } - - // Send fresh attach event to relay. - try { - const info = /** @type {any} */ ( - await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo') - ) - const targetInfo = info?.targetInfo - - sendToRelay({ - method: 'forwardCDPEvent', - params: { - method: 'Target.attachedToTarget', - params: { - sessionId: tab.sessionId, - targetInfo: { ...targetInfo, attached: true }, - waitingForDebugger: false, - }, - }, - }) - - setBadge(tabId, 'on') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: attached (click to detach)', - }) - } catch { - setBadge(tabId, 'on') - } - } - - await persistState() -} - -function sendToRelay(payload) { - const ws = relayWs - if (!ws || ws.readyState !== WebSocket.OPEN) { - throw new Error('Relay not connected') - } - ws.send(JSON.stringify(payload)) -} - -function ensureGatewayHandshakeStarted(payload) { - if (relayConnectRequestId) return - const nonce = typeof payload?.nonce === 'string' ? payload.nonce.trim() : '' - relayConnectRequestId = `ext-connect-${Date.now()}-${Math.random().toString(16).slice(2, 8)}` - sendToRelay({ - type: 'req', - id: relayConnectRequestId, - method: 'connect', - params: { - minProtocol: 3, - maxProtocol: 3, - client: { - id: 'chrome-relay-extension', - version: '1.0.0', - platform: 'chrome-extension', - mode: 'webchat', - }, - role: 'operator', - scopes: ['operator.read', 'operator.write'], - caps: [], - commands: [], - nonce: nonce || undefined, - auth: relayGatewayToken ? { token: relayGatewayToken } : undefined, - }, + 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 maybeOpenHelpOnce() { - try { - const stored = await chrome.storage.local.get(['helpOnErrorShown']) - if (stored.helpOnErrorShown === true) return - await chrome.storage.local.set({ helpOnErrorShown: true }) - await chrome.runtime.openOptionsPage() - } catch { - // ignore - } -} - -function requestFromRelay(command) { - const id = command.id - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - pending.delete(id) - reject(new Error('Relay request timeout (30s)')) - }, 30000) - pending.set(id, { - resolve: (v) => { clearTimeout(timer); resolve(v) }, - reject: (e) => { clearTimeout(timer); reject(e) }, - }) - try { - sendToRelay(command) - } catch (err) { - clearTimeout(timer) - pending.delete(id) - reject(err instanceof Error ? err : new Error(String(err))) - } - }) -} - -async function onRelayMessage(text) { - /** @type {any} */ - let msg - try { - msg = JSON.parse(text) - } catch { +async function onHostMessage(msg) { + if (!msg || typeof msg !== 'object') return + // Optional keepalive. + if (msg.method === 'ping') { + postToHost({ method: 'pong' }) return } - - if (msg && msg.type === 'event' && msg.event === 'connect.challenge') { - try { - ensureGatewayHandshakeStarted(msg.payload) - } catch (err) { - console.warn('gateway connect handshake start failed', err instanceof Error ? err.message : String(err)) - relayConnectRequestId = null - const ws = relayWs - if (ws && ws.readyState === WebSocket.OPEN) { - ws.close(1008, 'gateway connect failed') - } - } - return - } - - if (msg && msg.type === 'res' && relayConnectRequestId && msg.id === relayConnectRequestId) { - relayConnectRequestId = null - if (!msg.ok) { - const detail = msg?.error?.message || msg?.error || 'gateway connect failed' - console.warn('gateway connect handshake rejected', String(detail)) - const ws = relayWs - if (ws && ws.readyState === WebSocket.OPEN) { - ws.close(1008, 'gateway connect failed') - } - } - return - } - - if (msg && msg.method === 'ping') { - try { - sendToRelay({ method: 'pong' }) - } catch { - // ignore - } - return - } - - if (msg && typeof msg.id === 'number' && (msg.result !== undefined || msg.error !== undefined)) { - const p = pending.get(msg.id) - if (!p) return - pending.delete(msg.id) - if (msg.error) p.reject(new Error(String(msg.error))) - else p.resolve(msg.result) - return - } - - if (msg && typeof msg.id === 'number' && msg.method === 'forwardCDPCommand') { + if (typeof msg.id !== 'undefined' && msg.method === 'forwardCDPCommand') { try { const result = await handleForwardCdpCommand(msg) - sendToRelay({ id: msg.id, result }) + postToHost({ id: msg.id, result }) } catch (err) { - sendToRelay({ id: msg.id, error: err instanceof Error ? err.message : String(err) }) + postToHost({ id: msg.id, error: err instanceof Error ? err.message : String(err) }) } } } -function getTabBySessionId(sessionId) { - const direct = tabBySession.get(sessionId) - if (direct) return { tabId: direct, kind: 'main' } - const child = childSessionToTab.get(sessionId) - if (child) return { tabId: child, kind: 'child' } +// ---- 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 getTabByTargetId(targetId) { - for (const [tabId, tab] of tabs.entries()) { - if (tab.targetId === targetId) return tabId - } - return null -} - -async function attachTab(tabId, opts = {}) { - const debuggee = { tabId } - await chrome.debugger.attach(debuggee, '1.3') - await chrome.debugger.sendCommand(debuggee, 'Page.enable').catch(() => {}) - - const info = /** @type {any} */ (await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo')) - const targetInfo = info?.targetInfo - const targetId = String(targetInfo?.targetId || '').trim() - if (!targetId) { - throw new Error('Target.getTargetInfo returned no targetId') - } - - const sid = nextSession++ - const sessionId = `cb-tab-${sid}` - const attachOrder = sid - - tabs.set(tabId, { state: 'connected', sessionId, targetId, attachOrder }) - tabBySession.set(sessionId, tabId) - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: attached (click to detach)', - }) - - if (!opts.skipAttachedEvent) { - sendToRelay({ - method: 'forwardCDPEvent', - params: { - method: 'Target.attachedToTarget', - params: { - sessionId, - targetInfo: { ...targetInfo, attached: true }, - waitingForDebugger: false, - }, - }, - }) - } - - setBadge(tabId, 'on') - await persistState() - - return { sessionId, targetId } -} - -async function detachTab(tabId, reason) { - const tab = tabs.get(tabId) - - // Send detach events for child sessions first. - for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { - if (parentTabId === tabId) { - try { - sendToRelay({ - method: 'forwardCDPEvent', - params: { - method: 'Target.detachedFromTarget', - params: { sessionId: childSessionId, reason: 'parent_detached' }, - }, - }) - } catch { - // Relay may be down. - } - childSessionToTab.delete(childSessionId) - } - } - - // Send detach event for main session. - if (tab?.sessionId && tab?.targetId) { - try { - sendToRelay({ - method: 'forwardCDPEvent', - params: { - method: 'Target.detachedFromTarget', - params: { sessionId: tab.sessionId, targetId: tab.targetId, reason }, - }, - }) - } catch { - // Relay may be down. - } - } - - if (tab?.sessionId) tabBySession.delete(tab.sessionId) - tabs.delete(tabId) - - try { - await chrome.debugger.detach({ tabId }) - } catch { - // May already be detached. - } - - setBadge(tabId, 'off') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay (auto-attach enabled)', - }) - - await persistState() -} - -function isAttachableUrl(url) { - if (!url) return false - if (url.startsWith('chrome://')) return false - if (url.startsWith('chrome-extension://')) return false - if (url.startsWith('devtools://')) return false - return true -} - -async function autoAttachAllTabs() { - if (!relayWs || relayWs.readyState !== WebSocket.OPEN) return - - const allTabs = await chrome.tabs.query({}) - for (const tab of allTabs) { - const tabId = tab.id - if (!tabId) continue - if (tabs.has(tabId)) continue - if (!isAttachableUrl(tab.url)) continue - if (tabOperationLocks.has(tabId)) continue - if (reattachPending.has(tabId)) continue - - tabOperationLocks.add(tabId) - try { - await attachTab(tabId) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - console.warn(`Auto-attach tab ${tabId} failed: ${message}`) - } finally { - tabOperationLocks.delete(tabId) - } - } -} - -async function connectOrToggleForActiveTab() { - // Click now triggers connect + auto-attach all tabs. - cancelReconnect() - - try { - await ensureRelayConnection() - await autoAttachAllTabs() - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - console.warn('connect failed', message, nowStack()) - void maybeOpenHelpOnce() - } +function anyConnectedTab() { + const it = tabs.keys().next() + return it.done ? null : it.value } async function handleForwardCdpCommand(msg) { - const method = String(msg?.params?.method || '').trim() + const method = String(msg?.params?.method || '') const params = msg?.params?.params || undefined const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined - const bySession = sessionId ? getTabBySessionId(sessionId) : null - const targetId = typeof params?.targetId === 'string' ? params.targetId : undefined - const tabId = - bySession?.tabId || - (targetId ? getTabByTargetId(targetId) : null) || - (() => { - for (const [id, tab] of tabs.entries()) { - if (tab.state === 'connected') return id - } - return null - })() - - if (!tabId) throw new Error(`No attached tab for method ${method}`) - - /** @type {chrome.debugger.DebuggerSession} */ - const debuggee = { tabId } - - if (method === 'Runtime.enable') { - try { - await chrome.debugger.sendCommand(debuggee, 'Runtime.disable') - await new Promise((r) => setTimeout(r, 50)) - } catch { - // ignore - } - return await chrome.debugger.sendCommand(debuggee, 'Runtime.enable', params) - } - + // Browser-level Target methods that map onto chrome.tabs. if (method === 'Target.createTarget') { - const url = typeof params?.url === 'string' ? params.url : 'about:blank' + 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('Failed to create tab') + if (!tab.id) throw new Error('createTarget: no tab id') await new Promise((r) => setTimeout(r, 100)) - const attached = await attachTab(tab.id) - return { targetId: attached.targetId } + const t = await attachTab(tab.id) + return { targetId: t.targetId } } - if (method === 'Target.closeTarget') { - const target = typeof params?.targetId === 'string' ? params.targetId : '' - const toClose = target ? getTabByTargetId(target) : tabId - if (!toClose) return { success: false } + const tid = typeof params?.targetId === 'string' ? params.targetId : '' + const tabId = tid ? tabForTarget(tid) : null + if (!tabId) return { success: false } try { - await chrome.tabs.remove(toClose) + await chrome.tabs.remove(tabId) } catch { return { success: false } } return { success: true } } - if (method === 'Target.activateTarget') { - const target = typeof params?.targetId === 'string' ? params.targetId : '' - const toActivate = target ? getTabByTargetId(target) : tabId - if (!toActivate) return {} - const tab = await chrome.tabs.get(toActivate).catch(() => null) - if (!tab) return {} - if (tab.windowId) { - await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {}) + 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(() => {}) } - await chrome.tabs.update(toActivate, { active: true }).catch(() => {}) return {} } - const tabState = tabs.get(tabId) - const mainSessionId = tabState?.sessionId - const debuggerSession = - sessionId && mainSessionId && sessionId !== mainSessionId - ? { ...debuggee, sessionId } - : debuggee + // 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 } - return await chrome.debugger.sendCommand(debuggerSession, method, params) + // 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) } -function onDebuggerEvent(source, method, params) { - const tabId = source.tabId - if (!tabId) return - const tab = tabs.get(tabId) - if (!tab?.sessionId) return +// ---- attach / detach ------------------------------------------------------ - if (method === 'Target.attachedToTarget' && params?.sessionId) { - childSessionToTab.set(String(params.sessionId), tabId) +async function attachTab(tabId) { + const existing = tabs.get(tabId) + if (existing) return existing + const dbg = { tabId } + await chrome.debugger.attach(dbg, '1.3') + 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 } }, + }) } +} - if (method === 'Target.detachedFromTarget' && params?.sessionId) { - childSessionToTab.delete(String(params.sessionId)) - } +function eligible(tab) { + return !!tab && !!tab.id && typeof tab.url === 'string' && !SKIP_URL.test(tab.url) +} +async function attachAllTabs() { + let all = [] try { - sendToRelay({ + 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: source.sessionId || tab.sessionId, - method, - params, + sessionId: entry.sessionId, + method: 'Target.attachedToTarget', + params: { sessionId: entry.sessionId, targetInfo: { targetId: entry.targetId, type: 'page', attached: true } }, }, }) - } catch { - // Relay may be down. } } -async function onDebuggerDetach(source, reason) { - const tabId = source.tabId - if (!tabId) return - if (!tabs.has(tabId)) return +// ---- chrome.debugger events ---------------------------------------------- - // User explicitly cancelled or DevTools replaced the connection — respect their intent - if (reason === 'canceled_by_user' || reason === 'replaced_with_devtools') { - void detachTab(tabId, reason) - return - } - - // Check if tab still exists — distinguishes navigation from tab close - let tabInfo - try { - tabInfo = await chrome.tabs.get(tabId) - } catch { - // Tab is gone (closed) — normal cleanup - void detachTab(tabId, reason) - return - } - - if (tabInfo.url?.startsWith('chrome://') || tabInfo.url?.startsWith('chrome-extension://')) { - void detachTab(tabId, reason) - return - } - - if (reattachPending.has(tabId)) return - - const oldTab = tabs.get(tabId) - const oldSessionId = oldTab?.sessionId - const oldTargetId = oldTab?.targetId - - if (oldSessionId) tabBySession.delete(oldSessionId) - tabs.delete(tabId) - for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { - if (parentTabId === tabId) childSessionToTab.delete(childSessionId) - } - - if (oldSessionId && oldTargetId) { - try { - sendToRelay({ - method: 'forwardCDPEvent', - params: { - method: 'Target.detachedFromTarget', - params: { sessionId: oldSessionId, targetId: oldTargetId, reason: 'navigation-reattach' }, - }, - }) - } catch { - // Relay may be down. +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) } - } - - reattachPending.add(tabId) - setBadge(tabId, 'connecting') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: re-attaching after navigation…', - }) - - const delays = [300, 700, 1500] - for (let attempt = 0; attempt < delays.length; attempt++) { - await new Promise((r) => setTimeout(r, delays[attempt])) - - if (!reattachPending.has(tabId)) return - - try { - await chrome.tabs.get(tabId) - } catch { - reattachPending.delete(tabId) - setBadge(tabId, 'off') - return + if (method === 'Target.detachedFromTarget' && params?.sessionId) { + childSessionToTab.delete(String(params.sessionId)) } + postToHost({ + method: 'forwardCDPEvent', + params: { sessionId: source.sessionId || entry.sessionId, method, params }, + }) + }), +) - if (!relayWs || relayWs.readyState !== WebSocket.OPEN) { - reattachPending.delete(tabId) - setBadge(tabId, 'error') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: relay disconnected during re-attach', - }) - return +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))) - try { - await attachTab(tabId) - reattachPending.delete(tabId) - return - } catch { - // continue retries - } - } +// ---- bootstrap + keepalive ------------------------------------------------ - reattachPending.delete(tabId) - setBadge(tabId, 'off') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: re-attach failed (click to retry)', - }) -} +chrome.runtime.onInstalled.addListener(() => void whenReady(connectHost)) +chrome.runtime.onStartup.addListener(() => void whenReady(connectHost)) +chrome.action.onClicked.addListener(() => void whenReady(connectHost)) -// Tab lifecycle listeners — clean up stale entries. -chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => { - reattachPending.delete(tabId) - if (!tabs.has(tabId)) return - const tab = tabs.get(tabId) - if (tab?.sessionId) tabBySession.delete(tab.sessionId) - tabs.delete(tabId) - for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { - if (parentTabId === tabId) childSessionToTab.delete(childSessionId) - } - if (tab?.sessionId && tab?.targetId) { - try { - sendToRelay({ - method: 'forwardCDPEvent', - params: { - method: 'Target.detachedFromTarget', - params: { sessionId: tab.sessionId, targetId: tab.targetId, reason: 'tab_closed' }, - }, - }) - } catch { - // Relay may be down. - } - } - void persistState() -})) - -chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => void whenReady(() => { - const tab = tabs.get(removedTabId) - if (!tab) return - tabs.delete(removedTabId) - tabs.set(addedTabId, tab) - if (tab.sessionId) { - tabBySession.set(tab.sessionId, addedTabId) - } - for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { - if (parentTabId === removedTabId) { - childSessionToTab.set(childSessionId, addedTabId) - } - } - setBadge(addedTabId, 'on') - void persistState() -})) - -// Auto-attach tabs when they finish loading. -chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => void whenReady(async () => { - if (changeInfo.status !== 'complete') return - if (tabs.has(tabId)) return - if (!isAttachableUrl(tab.url)) return - if (tabOperationLocks.has(tabId)) return - if (reattachPending.has(tabId)) return - if (!relayWs || relayWs.readyState !== WebSocket.OPEN) return - - tabOperationLocks.add(tabId) - try { - await attachTab(tabId) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - console.warn(`Auto-attach tab ${tabId} on update failed: ${message}`) - } finally { - tabOperationLocks.delete(tabId) - } -})) - -// Register debugger listeners at module scope so detach/event handling works -// even when the relay WebSocket is down. -chrome.debugger.onEvent.addListener((...args) => void whenReady(() => onDebuggerEvent(...args))) -chrome.debugger.onDetach.addListener((...args) => void whenReady(() => onDebuggerDetach(...args))) - -chrome.action.onClicked.addListener(() => void whenReady(() => connectOrToggleForActiveTab())) - -// Refresh badge after navigation completes — service worker may have restarted -// during navigation, losing ephemeral badge state. -chrome.webNavigation.onCompleted.addListener(({ tabId, frameId }) => void whenReady(() => { - if (frameId !== 0) return - const tab = tabs.get(tabId) - if (tab?.state === 'connected') { - setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting') - } -})) - -// Refresh badge when user switches to an attached tab. -chrome.tabs.onActivated.addListener(({ tabId }) => void whenReady(() => { - const tab = tabs.get(tabId) - if (tab?.state === 'connected') { - setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting') - } -})) - -chrome.runtime.onInstalled.addListener(() => { - void chrome.runtime.openOptionsPage() -}) - -// MV3 keepalive via chrome.alarms — more reliable than setInterval across -// service worker restarts. Checks relay health and refreshes badges. -chrome.alarms.create('relay-keepalive', { periodInMinutes: 0.5 }) - -chrome.alarms.onAlarm.addListener(async (alarm) => { - if (alarm.name !== 'relay-keepalive') return - await initPromise - - // Refresh badges (ephemeral in MV3). - for (const [tabId, tab] of tabs.entries()) { - if (tab.state === 'connected') { - setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting') - } - } - - // Auto-attach any unattached tabs while relay is healthy. - if (relayWs && relayWs.readyState === WebSocket.OPEN) { - await autoAttachAllTabs() - } - - // If relay is down and no reconnect is in progress, trigger one. - if (!relayWs || relayWs.readyState !== WebSocket.OPEN) { - if (!relayConnectPromise && !reconnectTimer) { - console.log('Keepalive: WebSocket unhealthy, triggering reconnect') - await ensureRelayConnection().catch(() => { - // ensureRelayConnection may throw without triggering onRelayClosed - // (e.g. preflight fetch fails before WS is created), so ensure - // reconnect is always scheduled on failure. - if (!reconnectTimer) { - scheduleReconnect() - } - }) - } - } -}) - -// Rehydrate state on service worker startup. Split: rehydration is the gate -// (fast), relay reconnect runs in background (slow, non-blocking). -const initPromise = rehydrateState() - -initPromise.then(() => { - ensureRelayConnection().then(() => { - reconnectAttempt = 0 - return reannounceAttachedTabs().then(() => autoAttachAllTabs()) - }).catch(() => { - scheduleReconnect() +// 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() }) }) -// Shared gate: all state-dependent handlers await this before accessing maps. +// Gate placeholder so future async state-rehydration can hook in. async function whenReady(fn) { - await initPromise return fn() } -// Relay check handler for the options page. The service worker has -// host_permissions and bypasses CORS preflight, so the options page -// delegates token-validation requests here. -chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { - if (msg?.type !== 'relayCheck') return false - const { url, token } = msg - const headers = token ? { 'x-openclaw-relay-token': token } : {} - fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(2000) }) - .then(async (res) => { - const contentType = String(res.headers.get('content-type') || '') - let json = null - if (contentType.includes('application/json')) { - try { - json = await res.json() - } catch { - json = null - } - } - sendResponse({ status: res.status, ok: res.ok, contentType, json }) - }) - .catch((err) => sendResponse({ status: 0, ok: false, error: String(err) })) - return true -}) +// Kick a connection attempt as soon as the worker starts. +connectHost() diff --git a/extensions/ab-connect/manifest.json b/extensions/ab-connect/manifest.json index 5e18469..cf1eb00 100644 --- a/extensions/ab-connect/manifest.json +++ b/extensions/ab-connect/manifest.json @@ -1,25 +1,16 @@ { "manifest_version": 3, "name": "agent-browser connect", - "version": "0.1.0", - "description": "Let agent-browser drive your existing logged-in Chrome tab via a local relay — install once, no per-use confirmation.", + "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", "activeTab", "storage", "alarms", "webNavigation"], - "host_permissions": ["http://127.0.0.1/*", "http://localhost/*"], + "permissions": ["debugger", "tabs", "nativeMessaging", "storage", "alarms", "webNavigation"], "background": { "service_worker": "background.js", "type": "module" }, - "action": { - "default_title": "agent-browser connect (auto-attach enabled)", - "default_icon": { - "16": "icons/icon16.png", - "32": "icons/icon32.png", - "48": "icons/icon48.png", - "128": "icons/icon128.png" - } - }, - "options_ui": { "page": "options.html", "open_in_tab": true } + "action": { "default_title": "agent-browser connect" } } diff --git a/extensions/ab-connect/options-validation.js b/extensions/ab-connect/options-validation.js deleted file mode 100644 index 53e2cd5..0000000 --- a/extensions/ab-connect/options-validation.js +++ /dev/null @@ -1,57 +0,0 @@ -const PORT_GUIDANCE = 'Use gateway port + 3 (for gateway 18789, relay is 18792).' - -function hasCdpVersionShape(data) { - return !!data && typeof data === 'object' && 'Browser' in data && 'Protocol-Version' in data -} - -export function classifyRelayCheckResponse(res, port) { - if (!res) { - return { action: 'throw', error: 'No response from service worker' } - } - - if (res.status === 401) { - return { action: 'status', kind: 'error', message: 'Gateway token rejected. Check token and save again.' } - } - - if (res.error) { - return { action: 'throw', error: res.error } - } - - if (!res.ok) { - return { action: 'throw', error: `HTTP ${res.status}` } - } - - const contentType = String(res.contentType || '') - if (!contentType.includes('application/json')) { - return { - action: 'status', - kind: 'error', - message: `Wrong port: this is likely the gateway, not the relay. ${PORT_GUIDANCE}`, - } - } - - if (!hasCdpVersionShape(res.json)) { - return { - action: 'status', - kind: 'error', - message: `Wrong port: expected relay /json/version response. ${PORT_GUIDANCE}`, - } - } - - return { action: 'status', kind: 'ok', message: `Relay reachable and authenticated at http://127.0.0.1:${port}/` } -} - -export function classifyRelayCheckException(err, port) { - const message = String(err || '').toLowerCase() - if (message.includes('json') || message.includes('syntax')) { - return { - kind: 'error', - message: `Wrong port: this is not a relay endpoint. ${PORT_GUIDANCE}`, - } - } - - return { - kind: 'error', - message: `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, - } -} diff --git a/extensions/ab-connect/options.html b/extensions/ab-connect/options.html deleted file mode 100644 index 17fc6a7..0000000 --- a/extensions/ab-connect/options.html +++ /dev/null @@ -1,200 +0,0 @@ - - - - - - OpenClaw Browser Relay - - - -
-
- -
-

OpenClaw Browser Relay

-

Click the toolbar button on a tab to attach / detach.

-
-
- -
-
-

Getting started

-

- If you see a red ! badge on the extension icon, the relay server is not reachable. - Start OpenClaw’s browser relay on this machine (Gateway or node host), then click the toolbar button again. -

-

- Full guide (install, remote Gateway, security): docs.openclaw.ai/tools/chrome-extension -

-
- -
-

Relay connection

- -
- -
- -
- - -
-
- Default port: 18792. Extension connects to: http://127.0.0.1:<port>/. - Gateway token must match gateway.auth.token (or OPENCLAW_GATEWAY_TOKEN). -
-
-
-
- - -
- - diff --git a/extensions/ab-connect/options.js b/extensions/ab-connect/options.js deleted file mode 100644 index aa6fcc4..0000000 --- a/extensions/ab-connect/options.js +++ /dev/null @@ -1,74 +0,0 @@ -import { deriveRelayToken } from './background-utils.js' -import { classifyRelayCheckException, classifyRelayCheckResponse } from './options-validation.js' - -const DEFAULT_PORT = 18792 - -function clampPort(value) { - const n = Number.parseInt(String(value || ''), 10) - if (!Number.isFinite(n)) return DEFAULT_PORT - if (n <= 0 || n > 65535) return DEFAULT_PORT - return n -} - -function updateRelayUrl(port) { - const el = document.getElementById('relay-url') - if (!el) return - el.textContent = `http://127.0.0.1:${port}/` -} - -function setStatus(kind, message) { - const status = document.getElementById('status') - if (!status) return - status.dataset.kind = kind || '' - status.textContent = message || '' -} - -async function checkRelayReachable(port, token) { - const url = `http://127.0.0.1:${port}/json/version` - const trimmedToken = String(token || '').trim() - if (!trimmedToken) { - setStatus('error', 'Gateway token required. Save your gateway token to connect.') - return - } - try { - const relayToken = await deriveRelayToken(trimmedToken, port) - // Delegate the fetch to the background service worker to bypass - // CORS preflight on the custom x-openclaw-relay-token header. - const res = await chrome.runtime.sendMessage({ - type: 'relayCheck', - url, - token: relayToken, - }) - const result = classifyRelayCheckResponse(res, port) - if (result.action === 'throw') throw new Error(result.error) - setStatus(result.kind, result.message) - } catch (err) { - const result = classifyRelayCheckException(err, port) - setStatus(result.kind, result.message) - } -} - -async function load() { - const stored = await chrome.storage.local.get(['relayPort', 'gatewayToken']) - const port = clampPort(stored.relayPort) - const token = String(stored.gatewayToken || '').trim() - document.getElementById('port').value = String(port) - document.getElementById('token').value = token - updateRelayUrl(port) - await checkRelayReachable(port, token) -} - -async function save() { - const portInput = document.getElementById('port') - const tokenInput = document.getElementById('token') - const port = clampPort(portInput.value) - const token = String(tokenInput.value || '').trim() - await chrome.storage.local.set({ relayPort: port, gatewayToken: token }) - portInput.value = String(port) - tokenInput.value = token - updateRelayUrl(port) - await checkRelayReachable(port, token) -} - -document.getElementById('save').addEventListener('click', () => void save()) -void load()