Shared browser, separate tab groups: when an agent drives the user's real Chrome via ab-connect, every tab it opens lands in a Chrome tab group named after its --session (stable color per name). Each agent's tabs stay visually separated from other agents' and from the user's own (ungrouped) tabs. Visibility is NOT restricted — all agents still see all tabs (per design). - CreateTargetParams gains an optional non-CDP `agentGroup` hint (skip-if-none), so a strict real-Chrome endpoint never receives it - BrowserManager.agent_group(): Some(session) only when ws_url == the live ab-connect relay URL (never on launched/direct CDP); DAEMON_SESSION set at daemon start supplies the name; emitted at all createTarget sites (transient storage target stays None) - ab-connect: +tabGroups permission; Target.createTarget reads agentGroup and groups the new tab (create/reuse by title, deterministic color), best-effort - extension 0.3.0 -> 0.4.0; re-signed crx + zip (id unchanged) Needs the v0.4.0 extension reloaded + a build with this change to take effect.
366 lines
13 KiB
JavaScript
366 lines
13 KiB
JavaScript
// agent-browser connect — MV3 service worker.
|
|
//
|
|
// Bridges the user's real Chrome tabs to the local agent-browser daemon over a
|
|
// Chrome **native messaging** channel (no localhost port, no token: Chrome
|
|
// authenticates this extension to the host by id). It attaches chrome.debugger
|
|
// to eligible tabs and relays CDP both ways via a tiny envelope:
|
|
// host → ext : {id, method:"forwardCDPCommand", params:{method,params,sessionId}}
|
|
// ext → host : {id, result|error} (command reply)
|
|
// ext → host : {method:"forwardCDPEvent", params:{sessionId,method,params}}
|
|
//
|
|
// Target/discovery semantics (getTargets/attachToTarget) are emulated on the
|
|
// daemon side; here we just attach tabs and announce them as
|
|
// Target.attachedToTarget so the daemon's CDP client sees them appear.
|
|
//
|
|
// Adapted from openclaw-browser-relay (MIT, chengyixu) — the chrome.debugger
|
|
// attach + Target handling; the transport is rewritten from WebSocket+token to
|
|
// native messaging.
|
|
|
|
const HOST_NAME = 'com.agent_browser.connect'
|
|
const SKIP_URL = /^(chrome|chrome-extension|devtools|chrome-untrusted|edge|about):/i
|
|
|
|
/** @type {chrome.runtime.Port|null} */
|
|
let port = null
|
|
let nextSession = 1
|
|
/** tabId -> { sessionId, targetId } */
|
|
const tabs = new Map()
|
|
/** sessionId -> tabId (main session per tab) */
|
|
const sessionToTab = new Map()
|
|
/** child (OOPIF/worker) sessionId -> tabId */
|
|
const childSessionToTab = new Map()
|
|
/** tab-group name -> chrome tabGroups id (best-effort cache) */
|
|
const groupIdByName = new Map()
|
|
|
|
// Deterministic color per group name so a given session keeps the same color.
|
|
const GROUP_COLORS = ['blue', 'cyan', 'green', 'yellow', 'orange', 'red', 'pink', 'purple', 'grey']
|
|
function colorForName(name) {
|
|
let h = 0
|
|
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
|
|
return GROUP_COLORS[h % GROUP_COLORS.length]
|
|
}
|
|
|
|
// Put a freshly-created tab into the agent/session's own Chrome tab group, so
|
|
// each agent's tabs are visually separated (from each other and from the user's
|
|
// own tabs) on the shared real browser. Best-effort: grouping failures never
|
|
// break tab creation.
|
|
async function groupTabInto(tabId, name) {
|
|
if (!name || !chrome.tabGroups || !chrome.tabs.group) return
|
|
const tab = await chrome.tabs.get(tabId).catch(() => null)
|
|
if (!tab) return
|
|
let gid = groupIdByName.get(name)
|
|
if (gid != null) {
|
|
const ok = await chrome.tabGroups.get(gid).then(() => true).catch(() => false)
|
|
if (!ok) {
|
|
gid = null
|
|
groupIdByName.delete(name)
|
|
}
|
|
}
|
|
if (gid == null) {
|
|
// Reuse a same-titled group already in this window (survives SW restarts).
|
|
const found = await chrome.tabGroups.query({ windowId: tab.windowId, title: name }).catch(() => [])
|
|
if (found && found[0]) gid = found[0].id
|
|
}
|
|
if (gid == null) {
|
|
gid = await chrome.tabs.group({ tabIds: tabId })
|
|
await chrome.tabGroups.update(gid, { title: name, color: colorForName(name) }).catch(() => {})
|
|
} else {
|
|
await chrome.tabs.group({ groupId: gid, tabIds: tabId }).catch(() => {})
|
|
}
|
|
groupIdByName.set(name, gid)
|
|
}
|
|
|
|
function postToHost(msg) {
|
|
try {
|
|
if (port) port.postMessage(msg)
|
|
} catch (e) {
|
|
// port died; onDisconnect will reconnect.
|
|
}
|
|
}
|
|
|
|
function setBadge(tabId, kind) {
|
|
const map = { on: '', connecting: '…', error: '!' }
|
|
const colors = { on: '#16a34a', connecting: '#d97706', error: '#b91c1c' }
|
|
try {
|
|
chrome.action.setBadgeText({ tabId, text: map[kind] ?? '' })
|
|
if (colors[kind]) chrome.action.setBadgeBackgroundColor({ tabId, color: colors[kind] })
|
|
} catch {}
|
|
}
|
|
|
|
// ---- native messaging transport ------------------------------------------
|
|
|
|
function connectHost() {
|
|
if (port) return
|
|
try {
|
|
port = chrome.runtime.connectNative(HOST_NAME)
|
|
} catch (e) {
|
|
port = null
|
|
return
|
|
}
|
|
port.onMessage.addListener((msg) => void whenReady(() => onHostMessage(msg)))
|
|
port.onDisconnect.addListener(() => {
|
|
port = null
|
|
// Sessions are stale once the host is gone; the daemon re-discovers on
|
|
// reconnect. Keep chrome.debugger attached so reconnect is cheap.
|
|
for (const tabId of tabs.keys()) setBadge(tabId, 'connecting')
|
|
})
|
|
// Tell the daemon about everything we already have attached, then attach
|
|
// anything new.
|
|
reannounceAttachedTabs()
|
|
void attachAllTabs()
|
|
}
|
|
|
|
async function onHostMessage(msg) {
|
|
if (!msg || typeof msg !== 'object') return
|
|
// Optional keepalive.
|
|
if (msg.method === 'ping') {
|
|
postToHost({ method: 'pong' })
|
|
return
|
|
}
|
|
// Daemon (re)connected — (re)attach and announce every tab so it discovers
|
|
// the user's existing tabs rather than racing an empty target list.
|
|
if (msg.method === 'attachAll') {
|
|
reannounceAttachedTabs()
|
|
await attachAllTabs()
|
|
return
|
|
}
|
|
if (typeof msg.id !== 'undefined' && msg.method === 'forwardCDPCommand') {
|
|
try {
|
|
const result = await handleForwardCdpCommand(msg)
|
|
postToHost({ id: msg.id, result })
|
|
} catch (err) {
|
|
postToHost({ id: msg.id, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- CDP command dispatch -------------------------------------------------
|
|
|
|
function tabForSession(sessionId) {
|
|
return sessionToTab.get(sessionId) ?? childSessionToTab.get(sessionId) ?? null
|
|
}
|
|
|
|
function tabForTarget(targetId) {
|
|
for (const [tabId, t] of tabs.entries()) if (t.targetId === targetId) return tabId
|
|
return null
|
|
}
|
|
|
|
function anyConnectedTab() {
|
|
const it = tabs.keys().next()
|
|
return it.done ? null : it.value
|
|
}
|
|
|
|
async function handleForwardCdpCommand(msg) {
|
|
const method = String(msg?.params?.method || '')
|
|
const params = msg?.params?.params || undefined
|
|
const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined
|
|
|
|
// Browser-level Target methods that map onto chrome.tabs.
|
|
if (method === 'Target.createTarget') {
|
|
const url = typeof params?.url === 'string' && params.url ? params.url : 'about:blank'
|
|
const tab = await chrome.tabs.create({ url, active: false })
|
|
if (!tab.id) throw new Error('createTarget: no tab id')
|
|
await new Promise((r) => setTimeout(r, 100))
|
|
const t = await attachTab(tab.id)
|
|
// Per-session tab grouping (non-CDP hint from the daemon). Best-effort.
|
|
const group = typeof params?.agentGroup === 'string' ? params.agentGroup.trim() : ''
|
|
if (group) {
|
|
try {
|
|
await groupTabInto(tab.id, group)
|
|
} catch {}
|
|
}
|
|
return { targetId: t.targetId }
|
|
}
|
|
if (method === 'Target.closeTarget') {
|
|
const tid = typeof params?.targetId === 'string' ? params.targetId : ''
|
|
const tabId = tid ? tabForTarget(tid) : null
|
|
if (!tabId) return { success: false }
|
|
try {
|
|
await chrome.tabs.remove(tabId)
|
|
} catch {
|
|
return { success: false }
|
|
}
|
|
return { success: true }
|
|
}
|
|
if (method === 'Target.activateTarget') {
|
|
const tid = typeof params?.targetId === 'string' ? params.targetId : ''
|
|
const tabId = tid ? tabForTarget(tid) : null
|
|
if (tabId) {
|
|
const tab = await chrome.tabs.get(tabId).catch(() => null)
|
|
if (tab?.windowId) await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {})
|
|
await chrome.tabs.update(tabId, { active: true }).catch(() => {})
|
|
}
|
|
return {}
|
|
}
|
|
|
|
// Everything else → chrome.debugger on the resolved tab.
|
|
const tabId =
|
|
(sessionId ? tabForSession(sessionId) : null) ??
|
|
(typeof params?.targetId === 'string' ? tabForTarget(params.targetId) : null) ??
|
|
anyConnectedTab()
|
|
if (!tabId) throw new Error(`no attached tab for ${method}`)
|
|
const dbg = { tabId }
|
|
|
|
// Re-enabling Runtime can leave a stale state; bounce it (matches upstream).
|
|
if (method === 'Runtime.enable') {
|
|
try {
|
|
await chrome.debugger.sendCommand(dbg, 'Runtime.disable')
|
|
await new Promise((r) => setTimeout(r, 30))
|
|
} catch {}
|
|
return await chrome.debugger.sendCommand(dbg, 'Runtime.enable', params)
|
|
}
|
|
return await chrome.debugger.sendCommand(dbg, method, params)
|
|
}
|
|
|
|
// ---- attach / detach ------------------------------------------------------
|
|
|
|
async function attachTab(tabId) {
|
|
const existing = tabs.get(tabId)
|
|
if (existing) return existing
|
|
const dbg = { tabId }
|
|
try {
|
|
await chrome.debugger.attach(dbg, '1.3')
|
|
} catch (e) {
|
|
// After a service-worker restart, chrome.debugger may still be bound to
|
|
// this tab from the previous instance — "Another debugger is already
|
|
// attached". The tab is still controllable via {tabId}, so don't skip it
|
|
// (skipping is why existing tabs went un-announced and the daemon opened a
|
|
// blank tab instead). Re-announce it. Any other error (restricted page) is
|
|
// surfaced and the caller skips this tab.
|
|
const msg = String((e && e.message) || e)
|
|
if (!/already attached|already being debugged/i.test(msg)) throw e
|
|
}
|
|
await chrome.debugger.sendCommand(dbg, 'Page.enable').catch(() => {})
|
|
const info = /** @type {any} */ (await chrome.debugger.sendCommand(dbg, 'Target.getTargetInfo'))
|
|
const targetInfo = info?.targetInfo
|
|
const targetId = String(targetInfo?.targetId || '')
|
|
if (!targetId) throw new Error('attachTab: no targetId')
|
|
const sessionId = `cb-tab-${nextSession++}`
|
|
const entry = { sessionId, targetId }
|
|
tabs.set(tabId, entry)
|
|
sessionToTab.set(sessionId, tabId)
|
|
setBadge(tabId, port ? 'on' : 'connecting')
|
|
postToHost({
|
|
method: 'forwardCDPEvent',
|
|
params: {
|
|
sessionId,
|
|
method: 'Target.attachedToTarget',
|
|
params: { sessionId, targetInfo: { ...targetInfo, attached: true } },
|
|
},
|
|
})
|
|
return entry
|
|
}
|
|
|
|
function detachTab(tabId, notify) {
|
|
const entry = tabs.get(tabId)
|
|
if (!entry) return
|
|
tabs.delete(tabId)
|
|
sessionToTab.delete(entry.sessionId)
|
|
for (const [sid, tid] of childSessionToTab.entries()) if (tid === tabId) childSessionToTab.delete(sid)
|
|
if (notify) {
|
|
postToHost({
|
|
method: 'forwardCDPEvent',
|
|
params: { sessionId: entry.sessionId, method: 'Target.detachedFromTarget', params: { sessionId: entry.sessionId } },
|
|
})
|
|
}
|
|
}
|
|
|
|
function eligible(tab) {
|
|
return !!tab && !!tab.id && typeof tab.url === 'string' && !SKIP_URL.test(tab.url)
|
|
}
|
|
|
|
async function attachAllTabs() {
|
|
let all = []
|
|
try {
|
|
all = await chrome.tabs.query({})
|
|
} catch {
|
|
return
|
|
}
|
|
for (const tab of all) {
|
|
if (eligible(tab) && !tabs.has(tab.id)) {
|
|
try {
|
|
await attachTab(tab.id)
|
|
} catch {
|
|
// Tab may be a restricted page or already attached elsewhere.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function reannounceAttachedTabs() {
|
|
for (const [, entry] of tabs.entries()) {
|
|
postToHost({
|
|
method: 'forwardCDPEvent',
|
|
params: {
|
|
sessionId: entry.sessionId,
|
|
method: 'Target.attachedToTarget',
|
|
params: { sessionId: entry.sessionId, targetInfo: { targetId: entry.targetId, type: 'page', attached: true } },
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---- chrome.debugger events ----------------------------------------------
|
|
|
|
chrome.debugger.onEvent.addListener((source, method, params) =>
|
|
void whenReady(() => {
|
|
const tabId = source.tabId
|
|
if (!tabId) return
|
|
const entry = tabs.get(tabId)
|
|
if (!entry) return
|
|
if (method === 'Target.attachedToTarget' && params?.sessionId) {
|
|
childSessionToTab.set(String(params.sessionId), tabId)
|
|
}
|
|
if (method === 'Target.detachedFromTarget' && params?.sessionId) {
|
|
childSessionToTab.delete(String(params.sessionId))
|
|
}
|
|
postToHost({
|
|
method: 'forwardCDPEvent',
|
|
params: { sessionId: source.sessionId || entry.sessionId, method, params },
|
|
})
|
|
}),
|
|
)
|
|
|
|
chrome.debugger.onDetach.addListener((source) =>
|
|
void whenReady(() => {
|
|
if (source.tabId) detachTab(source.tabId, true)
|
|
}),
|
|
)
|
|
|
|
// ---- tab lifecycle --------------------------------------------------------
|
|
|
|
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) =>
|
|
void whenReady(async () => {
|
|
if (changeInfo.status === 'complete' && eligible(tab) && !tabs.has(tabId) && port) {
|
|
try {
|
|
await attachTab(tabId)
|
|
} catch {}
|
|
}
|
|
}),
|
|
)
|
|
chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => detachTab(tabId, true)))
|
|
|
|
// ---- bootstrap + keepalive ------------------------------------------------
|
|
|
|
chrome.runtime.onInstalled.addListener(() => void whenReady(connectHost))
|
|
chrome.runtime.onStartup.addListener(() => void whenReady(connectHost))
|
|
chrome.action.onClicked.addListener(() => void whenReady(connectHost))
|
|
|
|
// MV3 service workers get suspended; an alarm wakes us to keep the host link
|
|
// and badges fresh.
|
|
chrome.alarms.create('keepalive', { periodInMinutes: 0.4 })
|
|
chrome.alarms.onAlarm.addListener((a) => {
|
|
if (a.name !== 'keepalive') return
|
|
void whenReady(() => {
|
|
if (!port) connectHost()
|
|
else void attachAllTabs()
|
|
})
|
|
})
|
|
|
|
// Gate placeholder so future async state-rehydration can hook in.
|
|
async function whenReady(fn) {
|
|
return fn()
|
|
}
|
|
|
|
// Kick a connection attempt as soon as the worker starts.
|
|
connectHost()
|