From 8a8106ad75a2b9228eb05cd4889deb724606ce0a Mon Sep 17 00:00:00 2001 From: leeguooooo Date: Tue, 9 Jun 2026 14:54:03 +0900 Subject: [PATCH] feat(connect): vendor MV3 connect extension (adapted from openclaw-browser-relay) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- extensions/ab-connect/NOTICE.md | 10 + extensions/ab-connect/background-utils.js | 48 + extensions/ab-connect/background.js | 974 ++++++++++++++++++++ extensions/ab-connect/manifest.json | 25 + extensions/ab-connect/options-validation.js | 57 ++ extensions/ab-connect/options.html | 200 ++++ extensions/ab-connect/options.js | 74 ++ 7 files changed, 1388 insertions(+) create mode 100644 extensions/ab-connect/NOTICE.md create mode 100644 extensions/ab-connect/background-utils.js create mode 100644 extensions/ab-connect/background.js create mode 100644 extensions/ab-connect/manifest.json create mode 100644 extensions/ab-connect/options-validation.js create mode 100644 extensions/ab-connect/options.html create mode 100644 extensions/ab-connect/options.js diff --git a/extensions/ab-connect/NOTICE.md b/extensions/ab-connect/NOTICE.md new file mode 100644 index 0000000..0a973de --- /dev/null +++ b/extensions/ab-connect/NOTICE.md @@ -0,0 +1,10 @@ +# 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). + +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). diff --git a/extensions/ab-connect/background-utils.js b/extensions/ab-connect/background-utils.js new file mode 100644 index 0000000..fe32d2c --- /dev/null +++ b/extensions/ab-connect/background-utils.js @@ -0,0 +1,48 @@ +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 new file mode 100644 index 0000000..4e84831 --- /dev/null +++ b/extensions/ab-connect/background.js @@ -0,0 +1,974 @@ +import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js' + +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 + +let nextSession = 1 + +/** @type {Map} */ +const tabs = new Map() +/** @type {Map} */ +const tabBySession = new Map() +/** @type {Map} */ +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() { + try { + return new Error().stack || '' + } catch { + return '' + } +} + +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() { + 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. + } +} + +// Rehydrate tab state on service worker startup. Fast path — just restores +// maps and badges. Relay reconnect happens separately in background. +async function rehydrateState() { + 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. + } +} + +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, + }, + }) +} + +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 { + 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') { + try { + const result = await handleForwardCdpCommand(msg) + sendToRelay({ id: msg.id, result }) + } catch (err) { + sendToRelay({ 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' } + 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() + } +} + +async function handleForwardCdpCommand(msg) { + const method = String(msg?.params?.method || '').trim() + 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) + } + + if (method === 'Target.createTarget') { + const url = typeof params?.url === 'string' ? params.url : 'about:blank' + const tab = await chrome.tabs.create({ url, active: false }) + if (!tab.id) throw new Error('Failed to create tab') + await new Promise((r) => setTimeout(r, 100)) + const attached = await attachTab(tab.id) + return { targetId: attached.targetId } + } + + if (method === 'Target.closeTarget') { + const target = typeof params?.targetId === 'string' ? params.targetId : '' + const toClose = target ? getTabByTargetId(target) : tabId + if (!toClose) return { success: false } + try { + await chrome.tabs.remove(toClose) + } 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(() => {}) + } + 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 + + return await chrome.debugger.sendCommand(debuggerSession, method, params) +} + +function onDebuggerEvent(source, method, params) { + const tabId = source.tabId + if (!tabId) return + const tab = tabs.get(tabId) + if (!tab?.sessionId) return + + if (method === 'Target.attachedToTarget' && params?.sessionId) { + childSessionToTab.set(String(params.sessionId), tabId) + } + + if (method === 'Target.detachedFromTarget' && params?.sessionId) { + childSessionToTab.delete(String(params.sessionId)) + } + + try { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + sessionId: source.sessionId || tab.sessionId, + method, + params, + }, + }) + } catch { + // Relay may be down. + } +} + +async function onDebuggerDetach(source, reason) { + const tabId = source.tabId + if (!tabId) return + if (!tabs.has(tabId)) return + + // 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. + } + } + + 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 (!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 + } + + try { + await attachTab(tabId) + reattachPending.delete(tabId) + return + } catch { + // continue retries + } + } + + reattachPending.delete(tabId) + setBadge(tabId, 'off') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: re-attach failed (click to retry)', + }) +} + +// 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() + }) +}) + +// Shared gate: all state-dependent handlers await this before accessing maps. +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 +}) diff --git a/extensions/ab-connect/manifest.json b/extensions/ab-connect/manifest.json new file mode 100644 index 0000000..5e18469 --- /dev/null +++ b/extensions/ab-connect/manifest.json @@ -0,0 +1,25 @@ +{ + "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.", + "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/*"], + "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 } +} diff --git a/extensions/ab-connect/options-validation.js b/extensions/ab-connect/options-validation.js new file mode 100644 index 0000000..53e2cd5 --- /dev/null +++ b/extensions/ab-connect/options-validation.js @@ -0,0 +1,57 @@ +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 new file mode 100644 index 0000000..17fc6a7 --- /dev/null +++ b/extensions/ab-connect/options.html @@ -0,0 +1,200 @@ + + + + + + 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 new file mode 100644 index 0000000..aa6fcc4 --- /dev/null +++ b/extensions/ab-connect/options.js @@ -0,0 +1,74 @@ +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()