From 870895e922a15c558bb57d00cc7df1faf272165f Mon Sep 17 00:00:00 2001 From: leeguooooo Date: Tue, 3 Mar 2026 12:28:34 +0900 Subject: [PATCH] feat: expand agent-browser-stealth extension capabilities --- README.md | 11 +- docs/src/app/commands/page.mdx | 5 + docs/src/app/configuration/page.mdx | 16 +- extensions/tab-group-cdp/content-script.js | 33 +- extensions/tab-group-cdp/manifest.json | 14 +- extensions/tab-group-cdp/service-worker.js | 652 +++++++++++++++++++-- extensions/tab-group-cdp/sidepanel.css | 130 ++++ extensions/tab-group-cdp/sidepanel.html | 24 + extensions/tab-group-cdp/sidepanel.js | 186 ++++++ skills/agent-browser/SKILL.md | 6 + src/browser.ts | 79 ++- 11 files changed, 1094 insertions(+), 62 deletions(-) create mode 100644 extensions/tab-group-cdp/sidepanel.css create mode 100644 extensions/tab-group-cdp/sidepanel.html create mode 100644 extensions/tab-group-cdp/sidepanel.js diff --git a/README.md b/README.md index 700e4b2..9a0b0d1 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,16 @@ agent-browser --tab-group "My Agent Group" open https://example.com - `AGENT_BROWSER_TAB_GROUP` for base title - `AGENT_BROWSER_TAB_GROUP_PLUGIN_ID` for expected extension ID -Install once in Chrome: load unpacked extension from `extensions/tab-group-cdp/`. +Install once in Chrome: load unpacked extension from `extensions/tab-group-cdp/` (extension name: `agent-browser-stealth`). + +### Extension Capabilities (`agent-browser-stealth`) + +- Session window isolation: tabs are kept in their session window when possible. +- Session-aware grouping: deterministic group color, default session expanded, non-default sessions collapsed. +- Download archive routing: downloads from managed tabs are routed to `agent-browser-stealth//...`. +- Domain allowlist fallback: when allowlist is configured for a session, extension can force-block out-of-policy tabs to `about:blank`. +- Risk hints (debug only): suspicious host/TLD hints are returned via handshake and printed only when `AGENT_BROWSER_DEBUG=1`. +- Side panel console: view session/tab/group mapping, focus a session, keep only one session, clean empty groups, and edit session allowlist. ## Stealth Architecture diff --git a/docs/src/app/commands/page.mdx b/docs/src/app/commands/page.mdx index 09b2796..b1c5cf2 100644 --- a/docs/src/app/commands/page.mdx +++ b/docs/src/app/commands/page.mdx @@ -147,6 +147,11 @@ CDP mode uses a browser extension handshake to group tabs. - Default titles: - `default` session: `Agent Browser Stealth` - non-default: `Agent Browser Stealth • ` +- Extension side panel (`agent-browser-stealth`) also provides: + - Session window isolation and deterministic group colors. + - `Keep Only This`, `Focus`, `Clean Empty Groups` quick actions. + - Session allowlist editing (domain fallback to `about:blank` when violated). + - Download routing to `agent-browser-stealth//...`. - Use `--tab-group` / `AGENT_BROWSER_TAB_GROUP` for base title. - Use `AGENT_BROWSER_TAB_GROUP_PLUGIN_ID` (or `--tab-group-plugin-id`) to override expected extension ID. diff --git a/docs/src/app/configuration/page.mdx b/docs/src/app/configuration/page.mdx index 87d07a2..215168c 100644 --- a/docs/src/app/configuration/page.mdx +++ b/docs/src/app/configuration/page.mdx @@ -320,6 +320,9 @@ Every CLI flag can be set in the config file using its camelCase equivalent: For tab grouping in CDP mode, grouping is best-effort through the extension handshake: extension available => grouped by session; extension missing/unavailable => silent no-op. +With the `agent-browser-stealth` extension installed, the side panel also exposes +session window isolation controls, empty-group cleanup, and per-session allowlist policy editing. + ## Common Configurations ### Local Development @@ -431,9 +434,7 @@ These environment variables configure additional daemon and runtime behavior: AGENT_BROWSER_TAB_GROUP - - Base title for tab grouping. Session suffix is appended automatically in CDP mode. - + Base title for tab grouping. Session suffix is appended automatically in CDP mode. Agent Browser Stealth @@ -472,8 +473,13 @@ These environment variables configure additional daemon and runtime behavior: AGENT_BROWSER_SESSION_NAME - Auto-save/load state persistence name (defaults to AGENT_BROWSER_SESSION when unset). - (same as AGENT_BROWSER_SESSION) + + Auto-save/load state persistence name (defaults to AGENT_BROWSER_SESSION when + unset). + + + (same as AGENT_BROWSER_SESSION) + diff --git a/extensions/tab-group-cdp/content-script.js b/extensions/tab-group-cdp/content-script.js index 451989e..e9bf0f2 100644 --- a/extensions/tab-group-cdp/content-script.js +++ b/extensions/tab-group-cdp/content-script.js @@ -12,18 +12,14 @@ return; } - let request; - try { - request = { - type: REQUEST_TYPE, - nonce: data.nonce, - session: data.session, - groupTitle: data.groupTitle, - pluginId: data.pluginId, - }; - } catch { - return; - } + const request = { + type: REQUEST_TYPE, + nonce: data.nonce, + session: data.session, + groupTitle: data.groupTitle, + pluginId: data.pluginId, + allowedDomains: Array.isArray(data.allowedDomains) ? data.allowedDomains : undefined, + }; try { chrome.runtime.sendMessage(request, (response) => { @@ -53,6 +49,19 @@ ? payload.extensionId : chrome.runtime.id, groupId: typeof payload.groupId === 'number' ? payload.groupId : undefined, + windowId: typeof payload.windowId === 'number' ? payload.windowId : undefined, + color: typeof payload.color === 'string' ? payload.color : undefined, + collapsed: payload.collapsed === true, + policy: + payload.policy && typeof payload.policy === 'object' + ? { + enforced: payload.policy.enforced === true, + blocked: payload.policy.blocked === true, + reason: + typeof payload.policy.reason === 'string' ? payload.policy.reason : undefined, + } + : undefined, + riskHints: Array.isArray(payload.riskHints) ? payload.riskHints : undefined, error: typeof payload.error === 'string' ? payload.error : undefined, }, '*' diff --git a/extensions/tab-group-cdp/manifest.json b/extensions/tab-group-cdp/manifest.json index 13b260d..a9d616a 100644 --- a/extensions/tab-group-cdp/manifest.json +++ b/extensions/tab-group-cdp/manifest.json @@ -1,13 +1,19 @@ { "manifest_version": 3, - "name": "Agent Browser CDP Tab Grouper", - "version": "0.1.0", - "description": "Groups tabs by Agent Browser session when requested from CDP-driven pages.", - "permissions": ["tabs", "tabGroups"], + "name": "agent-browser-stealth", + "version": "0.2.0", + "description": "Session-aware tab grouping and coordination for CDP-driven agent-browser workflows.", + "permissions": ["tabs", "tabGroups", "downloads", "storage", "sidePanel"], "host_permissions": [""], "background": { "service_worker": "service-worker.js" }, + "action": { + "default_title": "agent-browser-stealth" + }, + "side_panel": { + "default_path": "sidepanel.html" + }, "content_scripts": [ { "matches": [""], diff --git a/extensions/tab-group-cdp/service-worker.js b/extensions/tab-group-cdp/service-worker.js index ef69b7f..53f0205 100644 --- a/extensions/tab-group-cdp/service-worker.js +++ b/extensions/tab-group-cdp/service-worker.js @@ -1,6 +1,25 @@ const REQUEST_TYPE = 'AB_TAB_GROUP_REQUEST'; +const PANEL_GET_STATE = 'AB_PANEL_GET_STATE'; +const PANEL_CLOSE_OTHER_TABS = 'AB_PANEL_CLOSE_OTHER_SESSION_TABS'; +const PANEL_FOCUS_SESSION = 'AB_PANEL_FOCUS_SESSION'; +const PANEL_CLEAN_EMPTY_GROUPS = 'AB_PANEL_CLEAN_EMPTY_GROUPS'; +const PANEL_SET_POLICY = 'AB_PANEL_SET_POLICY'; + const DEFAULT_GROUP_TITLE = 'Agent Browser Stealth'; +const DOWNLOAD_ARCHIVE_ROOT = 'agent-browser-stealth'; +const STORAGE_POLICY_KEY = 'abSessionPoliciesV1'; +const GROUP_COLORS = ['blue', 'green', 'pink', 'orange', 'purple', 'cyan', 'red', 'yellow']; +const RISKY_TLDS = new Set(['zip', 'mov', 'click', 'top', 'gq', 'tk', 'country']); +const RISKY_HOST_KEYWORDS = ['secure-login', 'account-verify', 'wallet-verify', 'airdrop-claim']; + const sessionGroupCache = new Map(); +const sessionWindowMap = new Map(); +const tabSessionMap = new Map(); +const tabMetaById = new Map(); +const downloadEvents = []; +const sessionPolicies = new Map(); + +let policyLoadPromise = loadPolicies(); function normalizeSession(session) { if (typeof session !== 'string') return 'default'; @@ -14,10 +33,198 @@ function normalizeGroupTitle(title) { return trimmed.length > 0 ? trimmed.slice(0, 80) : DEFAULT_GROUP_TITLE; } +function normalizeAllowedDomains(domains) { + if (!Array.isArray(domains)) return []; + return domains + .map((item) => (typeof item === 'string' ? item.trim().toLowerCase() : '')) + .filter((item) => item.length > 0) + .slice(0, 256); +} + +function parseHostname(rawUrl) { + if (typeof rawUrl !== 'string' || rawUrl.length === 0) return null; + try { + const parsed = new URL(rawUrl); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; + return parsed.hostname.toLowerCase(); + } catch { + return null; + } +} + +function domainMatches(hostname, pattern) { + if (!hostname || !pattern) return false; + if (pattern.startsWith('*.')) { + const suffix = pattern.slice(2); + return hostname === suffix || hostname.endsWith(`.${suffix}`); + } + if (pattern.startsWith('.')) { + const suffix = pattern.slice(1); + return hostname === suffix || hostname.endsWith(pattern); + } + return hostname === pattern || hostname.endsWith(`.${pattern}`); +} + +function isDomainAllowed(hostname, patterns) { + if (!hostname) return true; + if (!patterns || patterns.length === 0) return true; + return patterns.some((pattern) => domainMatches(hostname, pattern)); +} + +function collectRiskHints(rawUrl, allowedDomains) { + const hints = []; + const hostname = parseHostname(rawUrl); + if (!hostname) return hints; + + if (allowedDomains.length > 0 && !isDomainAllowed(hostname, allowedDomains)) { + hints.push(`domain-not-allowed:${hostname}`); + } + + const tld = hostname.split('.').pop(); + if (tld && RISKY_TLDS.has(tld)) { + hints.push(`high-risk-tld:.${tld}`); + } + + for (const keyword of RISKY_HOST_KEYWORDS) { + if (hostname.includes(keyword)) { + hints.push(`suspicious-host-keyword:${keyword}`); + } + } + + return [...new Set(hints)].slice(0, 10); +} + function cacheKey(windowId, session) { return `${windowId}:${session}`; } +function sanitizeSegment(input, fallback = 'default') { + const raw = typeof input === 'string' ? input : ''; + const cleaned = raw + .replace(/[\\/:*?"<>|\u0000-\u001f]/g, '-') + .replace(/\s+/g, '_') + .replace(/\.+/g, '.') + .trim(); + if (!cleaned) return fallback; + return cleaned.slice(0, 80); +} + +function sanitizeFilename(filename, fallback = 'download.bin') { + const name = typeof filename === 'string' ? filename.split('/').pop() : ''; + return sanitizeSegment(name, fallback); +} + +function pickColorForSession(session) { + let hash = 0; + for (let i = 0; i < session.length; i += 1) { + hash = (hash * 31 + session.charCodeAt(i)) >>> 0; + } + return GROUP_COLORS[hash % GROUP_COLORS.length]; +} + +function shouldCollapseGroup(session) { + return session !== 'default'; +} + +async function loadPolicies() { + try { + const result = await chrome.storage.local.get([STORAGE_POLICY_KEY]); + const entries = result?.[STORAGE_POLICY_KEY]; + if (!entries || typeof entries !== 'object') return; + + for (const [session, domains] of Object.entries(entries)) { + const normalizedSession = normalizeSession(session); + sessionPolicies.set(normalizedSession, normalizeAllowedDomains(domains)); + } + } catch { + // Ignore storage load failures. + } +} + +async function persistPolicies() { + const serialized = {}; + for (const [session, domains] of sessionPolicies.entries()) { + serialized[session] = [...domains]; + } + await chrome.storage.local.set({ [STORAGE_POLICY_KEY]: serialized }); +} + +async function setSessionPolicy(session, allowedDomains) { + const normalizedSession = normalizeSession(session); + const normalizedDomains = normalizeAllowedDomains(allowedDomains); + sessionPolicies.set(normalizedSession, normalizedDomains); + await persistPolicies(); +} + +function getSessionPolicy(session) { + const normalizedSession = normalizeSession(session); + return sessionPolicies.get(normalizedSession) ?? []; +} + +function updateTabMeta(tab) { + if (!tab || typeof tab.id !== 'number') return; + tabMetaById.set(tab.id, { + id: tab.id, + windowId: typeof tab.windowId === 'number' ? tab.windowId : -1, + url: typeof tab.url === 'string' ? tab.url : '', + title: typeof tab.title === 'string' ? tab.title : '', + groupId: typeof tab.groupId === 'number' ? tab.groupId : -1, + active: tab.active === true, + lastSeenAt: Date.now(), + }); +} + +function pruneDownloadEvents() { + const maxSize = 100; + if (downloadEvents.length > maxSize) { + downloadEvents.splice(0, downloadEvents.length - maxSize); + } +} + +function recordDownloadEvent(event) { + downloadEvents.push({ ...event, timestamp: Date.now() }); + pruneDownloadEvents(); +} + +function removeWindowCaches(windowId) { + for (const key of [...sessionGroupCache.keys()]) { + if (key.startsWith(`${windowId}:`)) { + sessionGroupCache.delete(key); + } + } + for (const [session, mappedWindowId] of [...sessionWindowMap.entries()]) { + if (mappedWindowId === windowId) { + sessionWindowMap.delete(session); + } + } +} + +async function ensureSessionWindow(tabId, currentWindowId, session) { + let targetWindowId = sessionWindowMap.get(session); + + if (typeof targetWindowId === 'number') { + try { + await chrome.windows.get(targetWindowId); + } catch { + sessionWindowMap.delete(session); + targetWindowId = undefined; + } + } + + if (typeof targetWindowId !== 'number') { + sessionWindowMap.set(session, currentWindowId); + return currentWindowId; + } + + if (targetWindowId === currentWindowId) { + return targetWindowId; + } + + await chrome.tabs.move(tabId, { windowId: targetWindowId, index: -1 }); + await chrome.tabs.update(tabId, { active: false }).catch(() => {}); + return targetWindowId; +} + async function findExistingGroup(windowId, groupTitle) { const tabs = await chrome.tabs.query({ windowId }); const checked = new Set(); @@ -34,7 +241,7 @@ async function findExistingGroup(windowId, groupTitle) { return tab.groupId; } } catch { - // Ignore stale group references and continue. + // Ignore stale group references. } } @@ -42,7 +249,8 @@ async function findExistingGroup(windowId, groupTitle) { } async function ensureSessionGroup(tabId, windowId, session, groupTitle) { - const key = cacheKey(windowId, session); + const targetWindowId = await ensureSessionWindow(tabId, windowId, session); + const key = cacheKey(targetWindowId, session); let groupId = sessionGroupCache.get(key); if (typeof groupId === 'number') { @@ -54,7 +262,7 @@ async function ensureSessionGroup(tabId, windowId, session, groupTitle) { } if (typeof groupId !== 'number') { - const existing = await findExistingGroup(windowId, groupTitle); + const existing = await findExistingGroup(targetWindowId, groupTitle); if (typeof existing === 'number') { groupId = existing; } @@ -65,70 +273,444 @@ async function ensureSessionGroup(tabId, windowId, session, groupTitle) { } else { groupId = await chrome.tabs.group({ tabIds: [tabId], - createProperties: { windowId }, + createProperties: { windowId: targetWindowId }, }); } + const color = pickColorForSession(session); + const collapsed = shouldCollapseGroup(session); await chrome.tabGroups.update(groupId, { title: groupTitle, - color: 'blue', - collapsed: false, + color, + collapsed, }); sessionGroupCache.set(key, groupId); - return groupId; + sessionWindowMap.set(session, targetWindowId); + + return { + groupId, + windowId: targetWindowId, + color, + collapsed, + }; } -chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { - if (!message || message.type !== REQUEST_TYPE) { - return; +async function applySessionDomainFallback(tabId, session) { + const allowedDomains = getSessionPolicy(session); + if (allowedDomains.length === 0) { + return { enforced: false, blocked: false }; } + let tab; + try { + tab = await chrome.tabs.get(tabId); + } catch { + return { enforced: true, blocked: false }; + } + + const hostname = parseHostname(tab.url); + if (!hostname) { + return { enforced: true, blocked: false }; + } + + if (isDomainAllowed(hostname, allowedDomains)) { + return { enforced: true, blocked: false }; + } + + await chrome.tabs.update(tabId, { url: 'about:blank' }).catch(() => {}); + return { + enforced: true, + blocked: true, + reason: `${hostname} is not in allowed domains`, + }; +} + +function getManagedSessionForTab(tabId) { + if (typeof tabId !== 'number') return undefined; + return tabSessionMap.get(tabId); +} + +function collectSessionTabIds(session) { + const result = []; + for (const [tabId, tabSession] of tabSessionMap.entries()) { + if (tabSession === session) { + result.push(tabId); + } + } + return result; +} + +async function closeOtherSessionTabs(session) { + const normalized = normalizeSession(session); + const closeIds = []; + + for (const [tabId, tabSession] of tabSessionMap.entries()) { + if (tabSession !== normalized) { + closeIds.push(tabId); + } + } + + if (closeIds.length > 0) { + await chrome.tabs.remove(closeIds); + } + + return { closed: closeIds.length }; +} + +async function focusSession(session) { + const normalized = normalizeSession(session); + const tabIds = collectSessionTabIds(normalized); + if (tabIds.length === 0) { + return { focused: false }; + } + + let tab; + try { + tab = await chrome.tabs.get(tabIds[0]); + } catch { + return { focused: false }; + } + + if (typeof tab.windowId === 'number') { + await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {}); + } + await chrome.tabs.update(tab.id, { active: true }).catch(() => {}); + return { focused: true, tabId: tab.id }; +} + +async function cleanEmptyGroups() { + let removedGroups = 0; + let removedWindows = 0; + + for (const [key, groupId] of [...sessionGroupCache.entries()]) { + const [windowIdRaw] = key.split(':'); + const windowId = Number(windowIdRaw); + + let groupExists = true; + try { + await chrome.tabGroups.get(groupId); + } catch { + groupExists = false; + } + + if (!groupExists) { + sessionGroupCache.delete(key); + removedGroups += 1; + continue; + } + + const tabs = await chrome.tabs.query({ windowId }).catch(() => []); + const hasMembers = tabs.some((tab) => tab.groupId === groupId); + if (!hasMembers) { + sessionGroupCache.delete(key); + removedGroups += 1; + } + } + + for (const [session, windowId] of [...sessionWindowMap.entries()]) { + try { + await chrome.windows.get(windowId); + } catch { + sessionWindowMap.delete(session); + removedWindows += 1; + } + } + + return { removedGroups, removedWindows }; +} + +async function buildPanelState() { + const allTabs = await chrome.tabs.query({}); + for (const tab of allTabs) { + updateTabMeta(tab); + } + + const sessionMap = new Map(); + + for (const tab of allTabs) { + if (typeof tab.id !== 'number') continue; + const session = getManagedSessionForTab(tab.id); + if (!session) continue; + + if (!sessionMap.has(session)) { + sessionMap.set(session, { + session, + windowId: sessionWindowMap.get(session) ?? tab.windowId, + allowedDomains: getSessionPolicy(session), + tabs: [], + riskHints: [], + }); + } + + const entry = sessionMap.get(session); + entry.tabs.push({ + id: tab.id, + windowId: tab.windowId, + title: tab.title ?? '', + url: tab.url ?? '', + active: tab.active === true, + groupId: typeof tab.groupId === 'number' ? tab.groupId : -1, + }); + + const hints = collectRiskHints(tab.url, entry.allowedDomains); + for (const hint of hints) { + if (!entry.riskHints.includes(hint)) { + entry.riskHints.push(hint); + } + } + } + + const sessions = []; + for (const sessionEntry of sessionMap.values()) { + sessionEntry.tabs.sort((a, b) => Number(b.active) - Number(a.active)); + const key = cacheKey(sessionEntry.windowId, sessionEntry.session); + const cachedGroupId = sessionGroupCache.get(key); + + let group; + if (typeof cachedGroupId === 'number') { + try { + const groupInfo = await chrome.tabGroups.get(cachedGroupId); + group = { + id: cachedGroupId, + title: groupInfo.title, + color: groupInfo.color, + collapsed: groupInfo.collapsed, + }; + } catch { + // Group may no longer exist. + } + } + + sessions.push({ + ...sessionEntry, + group, + }); + } + + sessions.sort((a, b) => a.session.localeCompare(b.session)); + + return { + extensionId: chrome.runtime.id, + totals: { + sessions: sessions.length, + tabs: sessions.reduce((sum, session) => sum + session.tabs.length, 0), + }, + sessions, + downloads: downloadEvents.slice(-25).reverse(), + }; +} + +async function handleTabGroupRequest(message, sender) { + await policyLoadPromise; + const tabId = sender.tab?.id; const windowId = sender.tab?.windowId; const nonce = typeof message.nonce === 'string' ? message.nonce : undefined; if (typeof tabId !== 'number' || typeof windowId !== 'number') { - sendResponse({ + return { ok: false, error: 'missing-tab-context', extensionId: chrome.runtime.id, nonce, - }); - return; + }; } if (typeof message.pluginId === 'string' && message.pluginId !== chrome.runtime.id) { - sendResponse({ + return { ok: false, error: 'plugin-id-mismatch', extensionId: chrome.runtime.id, nonce, - }); - return; + }; } const session = normalizeSession(message.session); const groupTitle = normalizeGroupTitle(message.groupTitle); + const allowedDomains = normalizeAllowedDomains(message.allowedDomains); + if (allowedDomains.length > 0) { + await setSessionPolicy(session, allowedDomains); + } - ensureSessionGroup(tabId, windowId, session, groupTitle) - .then((groupId) => { - sendResponse({ - ok: true, - groupId, - extensionId: chrome.runtime.id, - nonce, - }); - }) - .catch((error) => { - const errorMessage = error instanceof Error ? error.message : String(error); - sendResponse({ - ok: false, - error: errorMessage, - extensionId: chrome.runtime.id, - nonce, - }); - }); + tabSessionMap.set(tabId, session); + updateTabMeta(sender.tab); - return true; + const grouping = await ensureSessionGroup(tabId, windowId, session, groupTitle); + const policy = await applySessionDomainFallback(tabId, session); + const riskHints = collectRiskHints(sender.tab?.url, getSessionPolicy(session)); + if (policy.blocked && policy.reason) { + riskHints.push(`policy-blocked:${policy.reason}`); + } + + return { + ok: true, + extensionId: chrome.runtime.id, + nonce, + ...grouping, + policy, + riskHints: [...new Set(riskHints)].slice(0, 10), + }; +} + +chrome.runtime.onInstalled.addListener(() => { + chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {}); +}); + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (!message || typeof message !== 'object') { + return; + } + + const type = message.type; + + if (type === REQUEST_TYPE) { + handleTabGroupRequest(message, sender) + .then((response) => sendResponse(response)) + .catch((error) => { + const errorMessage = error instanceof Error ? error.message : String(error); + sendResponse({ + ok: false, + error: errorMessage, + extensionId: chrome.runtime.id, + nonce: typeof message.nonce === 'string' ? message.nonce : undefined, + }); + }); + return true; + } + + if (type === PANEL_GET_STATE) { + buildPanelState() + .then((state) => sendResponse({ ok: true, state })) + .catch((error) => { + const errorMessage = error instanceof Error ? error.message : String(error); + sendResponse({ ok: false, error: errorMessage }); + }); + return true; + } + + if (type === PANEL_CLOSE_OTHER_TABS) { + closeOtherSessionTabs(message.session) + .then((result) => sendResponse({ ok: true, result })) + .catch((error) => { + const errorMessage = error instanceof Error ? error.message : String(error); + sendResponse({ ok: false, error: errorMessage }); + }); + return true; + } + + if (type === PANEL_FOCUS_SESSION) { + focusSession(message.session) + .then((result) => sendResponse({ ok: true, result })) + .catch((error) => { + const errorMessage = error instanceof Error ? error.message : String(error); + sendResponse({ ok: false, error: errorMessage }); + }); + return true; + } + + if (type === PANEL_CLEAN_EMPTY_GROUPS) { + cleanEmptyGroups() + .then((result) => sendResponse({ ok: true, result })) + .catch((error) => { + const errorMessage = error instanceof Error ? error.message : String(error); + sendResponse({ ok: false, error: errorMessage }); + }); + return true; + } + + if (type === PANEL_SET_POLICY) { + setSessionPolicy(message.session, message.allowedDomains) + .then(() => sendResponse({ ok: true })) + .catch((error) => { + const errorMessage = error instanceof Error ? error.message : String(error); + sendResponse({ ok: false, error: errorMessage }); + }); + return true; + } +}); + +chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + updateTabMeta(tab); + const session = getManagedSessionForTab(tabId); + if (!session) return; + + if (typeof tab.windowId === 'number') { + sessionWindowMap.set(session, tab.windowId); + } + + if (changeInfo.status === 'complete') { + applySessionDomainFallback(tabId, session).catch(() => {}); + } +}); + +chrome.tabs.onRemoved.addListener((tabId, removeInfo) => { + const session = getManagedSessionForTab(tabId); + tabSessionMap.delete(tabId); + tabMetaById.delete(tabId); + + if (removeInfo.isWindowClosing) { + removeWindowCaches(removeInfo.windowId); + return; + } + + if (!session) return; + const remaining = collectSessionTabIds(session); + if (remaining.length === 0) { + sessionWindowMap.delete(session); + } +}); + +chrome.tabs.onDetached.addListener((tabId) => { + const session = getManagedSessionForTab(tabId); + if (!session) return; + tabMetaById.delete(tabId); +}); + +chrome.tabs.onAttached.addListener((tabId, attachInfo) => { + const session = getManagedSessionForTab(tabId); + if (!session) return; + sessionWindowMap.set(session, attachInfo.newWindowId); +}); + +chrome.windows.onRemoved.addListener((windowId) => { + removeWindowCaches(windowId); +}); + +chrome.downloads.onDeterminingFilename.addListener((item, suggest) => { + const session = getManagedSessionForTab(item.tabId); + if (!session) { + suggest(); + return; + } + + const safeSession = sanitizeSegment(session, 'default'); + const safeFilename = sanitizeFilename(item.filename, `download-${item.id}.bin`); + const filename = `${DOWNLOAD_ARCHIVE_ROOT}/${safeSession}/${safeFilename}`; + + recordDownloadEvent({ + id: item.id, + tabId: item.tabId, + session, + state: 'routing', + filename, + }); + + suggest({ + filename, + conflictAction: 'uniquify', + }); +}); + +chrome.downloads.onChanged.addListener((delta) => { + if (!delta || typeof delta.id !== 'number') return; + + const state = delta.state?.current; + if (!state) return; + + recordDownloadEvent({ + id: delta.id, + state, + filename: delta.filename?.current, + }); }); diff --git a/extensions/tab-group-cdp/sidepanel.css b/extensions/tab-group-cdp/sidepanel.css new file mode 100644 index 0000000..bd583d5 --- /dev/null +++ b/extensions/tab-group-cdp/sidepanel.css @@ -0,0 +1,130 @@ +:root { + color-scheme: light dark; + --bg: #111827; + --card: #1f2937; + --border: #374151; + --text: #f9fafb; + --muted: #d1d5db; + --accent: #22c55e; +} + +body { + margin: 0; + padding: 12px; + background: radial-gradient(circle at top left, #1f2937, #111827); + color: var(--text); + font: + 13px/1.5 -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; +} + +header { + display: flex; + gap: 8px; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +h1 { + font-size: 15px; + margin: 0; +} + +button { + background: #0f172a; + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + padding: 6px 8px; + cursor: pointer; +} + +button:hover { + border-color: var(--accent); +} + +.actions { + display: flex; + gap: 6px; +} + +.card { + background: color-mix(in srgb, var(--card) 88%, #000 12%); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px; + margin-bottom: 10px; +} + +.stack { + display: flex; + flex-direction: column; + gap: 10px; +} + +.session-title { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.session-title strong { + font-size: 14px; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 6px 0; +} + +.tag { + border: 1px solid var(--border); + border-radius: 999px; + padding: 2px 7px; + color: var(--muted); + font-size: 12px; +} + +.list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.item { + border: 1px solid color-mix(in srgb, var(--border) 85%, #000 15%); + border-radius: 8px; + padding: 6px 7px; + background: rgba(255, 255, 255, 0.03); + overflow: hidden; +} + +.item-title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.item-url { + color: var(--muted); + font-size: 11px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.row-actions { + display: flex; + gap: 6px; +} + +.empty { + color: var(--muted); +} diff --git a/extensions/tab-group-cdp/sidepanel.html b/extensions/tab-group-cdp/sidepanel.html new file mode 100644 index 0000000..fc92800 --- /dev/null +++ b/extensions/tab-group-cdp/sidepanel.html @@ -0,0 +1,24 @@ + + + + + + agent-browser-stealth panel + + + +
+

agent-browser-stealth

+
+ + +
+
+ +
+
+
+ + + + diff --git a/extensions/tab-group-cdp/sidepanel.js b/extensions/tab-group-cdp/sidepanel.js new file mode 100644 index 0000000..5b27a59 --- /dev/null +++ b/extensions/tab-group-cdp/sidepanel.js @@ -0,0 +1,186 @@ +const summaryEl = document.getElementById('summary'); +const sessionsEl = document.getElementById('sessions'); +const downloadsEl = document.getElementById('downloads'); +const refreshBtn = document.getElementById('refresh-btn'); +const cleanupBtn = document.getElementById('cleanup-btn'); + +async function send(message) { + return chrome.runtime.sendMessage(message); +} + +function createTag(text) { + const span = document.createElement('span'); + span.className = 'tag'; + span.textContent = text; + return span; +} + +function renderSummary(state) { + summaryEl.innerHTML = ''; + const title = document.createElement('div'); + title.innerHTML = `Overview · extensionId: ${state.extensionId}`; + + const tags = document.createElement('div'); + tags.className = 'tags'; + tags.appendChild(createTag(`sessions: ${state.totals.sessions}`)); + tags.appendChild(createTag(`tabs: ${state.totals.tabs}`)); + + summaryEl.appendChild(title); + summaryEl.appendChild(tags); +} + +function renderSessions(state) { + sessionsEl.innerHTML = ''; + + if (!state.sessions || state.sessions.length === 0) { + const empty = document.createElement('div'); + empty.className = 'card empty'; + empty.textContent = 'No managed sessions yet.'; + sessionsEl.appendChild(empty); + return; + } + + for (const session of state.sessions) { + const card = document.createElement('article'); + card.className = 'card'; + + const titleRow = document.createElement('div'); + titleRow.className = 'session-title'; + + const titleLeft = document.createElement('strong'); + titleLeft.textContent = session.session; + + const actions = document.createElement('div'); + actions.className = 'row-actions'; + + const focusBtn = document.createElement('button'); + focusBtn.type = 'button'; + focusBtn.textContent = 'Focus'; + focusBtn.addEventListener('click', async () => { + await send({ type: 'AB_PANEL_FOCUS_SESSION', session: session.session }); + await refresh(); + }); + + const keepBtn = document.createElement('button'); + keepBtn.type = 'button'; + keepBtn.textContent = 'Keep Only This'; + keepBtn.addEventListener('click', async () => { + await send({ type: 'AB_PANEL_CLOSE_OTHER_SESSION_TABS', session: session.session }); + await refresh(); + }); + + const policyBtn = document.createElement('button'); + policyBtn.type = 'button'; + policyBtn.textContent = 'Set Allowlist'; + policyBtn.addEventListener('click', async () => { + const current = (session.allowedDomains || []).join(','); + const input = window.prompt('Allowed domains (comma-separated)', current); + if (input === null) return; + const allowedDomains = input + .split(',') + .map((item) => item.trim().toLowerCase()) + .filter((item) => item.length > 0); + await send({ type: 'AB_PANEL_SET_POLICY', session: session.session, allowedDomains }); + await refresh(); + }); + + actions.appendChild(focusBtn); + actions.appendChild(keepBtn); + actions.appendChild(policyBtn); + + titleRow.appendChild(titleLeft); + titleRow.appendChild(actions); + + const tags = document.createElement('div'); + tags.className = 'tags'; + tags.appendChild(createTag(`window: ${session.windowId ?? 'n/a'}`)); + tags.appendChild(createTag(`tabs: ${session.tabs.length}`)); + + if (session.group) { + tags.appendChild(createTag(`group: ${session.group.title || session.group.id}`)); + tags.appendChild(createTag(`color: ${session.group.color}`)); + tags.appendChild(createTag(`collapsed: ${session.group.collapsed}`)); + } + + if (session.allowedDomains && session.allowedDomains.length > 0) { + tags.appendChild(createTag(`allowlist: ${session.allowedDomains.join(', ')}`)); + } + + if (session.riskHints && session.riskHints.length > 0) { + for (const hint of session.riskHints) { + tags.appendChild(createTag(`risk: ${hint}`)); + } + } + + const list = document.createElement('div'); + list.className = 'list'; + for (const tab of session.tabs.slice(0, 10)) { + const item = document.createElement('div'); + item.className = 'item'; + + const t = document.createElement('div'); + t.className = 'item-title'; + t.textContent = `${tab.active ? '● ' : ''}${tab.title || '(untitled)'}`; + + const u = document.createElement('div'); + u.className = 'item-url'; + u.textContent = tab.url || 'about:blank'; + + item.appendChild(t); + item.appendChild(u); + list.appendChild(item); + } + + card.appendChild(titleRow); + card.appendChild(tags); + card.appendChild(list); + sessionsEl.appendChild(card); + } +} + +function renderDownloads(state) { + downloadsEl.innerHTML = 'Recent Downloads'; + + const list = document.createElement('div'); + list.className = 'list'; + + const entries = state.downloads || []; + if (entries.length === 0) { + const empty = document.createElement('div'); + empty.className = 'empty'; + empty.textContent = 'No download events yet.'; + list.appendChild(empty); + } else { + for (const entry of entries.slice(0, 8)) { + const item = document.createElement('div'); + item.className = 'item'; + item.innerHTML = `
#${entry.id} · ${entry.state || 'updated'}
${entry.filename || ''}
`; + list.appendChild(item); + } + } + + downloadsEl.appendChild(list); +} + +async function refresh() { + const response = await send({ type: 'AB_PANEL_GET_STATE' }); + if (!response || response.ok !== true || !response.state) { + summaryEl.textContent = response?.error || 'Failed to load extension state.'; + sessionsEl.innerHTML = ''; + downloadsEl.innerHTML = ''; + return; + } + + renderSummary(response.state); + renderSessions(response.state); + renderDownloads(response.state); +} + +refreshBtn.addEventListener('click', refresh); +cleanupBtn.addEventListener('click', async () => { + await send({ type: 'AB_PANEL_CLEAN_EMPTY_GROUPS' }); + await refresh(); +}); + +refresh(); +setInterval(refresh, 5000); diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index 1288934..f535056 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -268,11 +268,17 @@ AGENT_BROWSER_TAB_GROUP_PLUGIN_ID="" agent-browser open https://ex Notes: - Works in CDP mode via extension handshake. +- Extension package name in Chrome: `agent-browser-stealth`. - Extension installed and reachable: tabs are grouped by session. - Extension missing/unavailable: silent no-op (no warning/error unless debug mode). - Default titles: - `default` session: `Agent Browser Stealth` - non-default session: `Agent Browser Stealth • ` +- Additional extension-side capabilities: + - Session window isolation + deterministic group colors. + - Side panel controls: Focus / Keep Only This / Clean Empty Groups. + - Session allowlist policy editing and fallback blocking (`about:blank`). + - Download auto-routing to `agent-browser-stealth//...`. ### Visual Browser (Debugging) diff --git a/src/browser.ts b/src/browser.ts index 4174c2e..cb33b99 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -139,6 +139,7 @@ interface TabGroupIntent { session: string; groupTitle: string; pluginId: string; + allowedDomains: string[]; } /** @@ -532,7 +533,12 @@ export class BrowserManager { this.normalizeTabGroupPluginId(process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID) ?? DEFAULT_TAB_GROUP_PLUGIN_ID; - this.tabGroupIntent = { session, groupTitle, pluginId }; + this.tabGroupIntent = { + session, + groupTitle, + pluginId, + allowedDomains: [...this.allowedDomains], + }; if (!this.tabGroupCapabilityBySession.has(session)) { this.tabGroupCapabilityBySession.set(session, 'unknown'); } @@ -566,19 +572,56 @@ export class BrowserManager { private async requestTabGroupPlugin( page: Page, intent: TabGroupIntent - ): Promise<{ ok: boolean; extensionId?: string; error?: string } | null> { + ): Promise<{ + ok: boolean; + extensionId?: string; + error?: string; + riskHints?: string[]; + policy?: { + enforced: boolean; + blocked: boolean; + reason?: string; + }; + } | null> { const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; const result = await page.evaluate( - ({ requestType, responseType, nonce, session, groupTitle, pluginId, timeoutMs }) => { + ({ + requestType, + responseType, + nonce, + session, + groupTitle, + pluginId, + allowedDomains, + timeoutMs, + }) => { return new Promise<{ ok: boolean; extensionId?: string; error?: string; + riskHints?: string[]; + policy?: { + enforced: boolean; + blocked: boolean; + reason?: string; + }; } | null>((resolve) => { let settled = false; let timer: number | undefined; - const finish = (value: { ok: boolean; extensionId?: string; error?: string } | null) => { + const finish = ( + value: { + ok: boolean; + extensionId?: string; + error?: string; + riskHints?: string[]; + policy?: { + enforced: boolean; + blocked: boolean; + reason?: string; + }; + } | null + ) => { if (settled) return; settled = true; window.removeEventListener('message', onMessage); @@ -600,6 +643,20 @@ export class BrowserManager { ? data.extensionId : undefined, error: typeof data.error === 'string' ? data.error : undefined, + riskHints: Array.isArray(data.riskHints) + ? data.riskHints.filter((item): item is string => typeof item === 'string') + : undefined, + policy: + data.policy && typeof data.policy === 'object' + ? { + enforced: (data.policy as Record).enforced === true, + blocked: (data.policy as Record).blocked === true, + reason: + typeof (data.policy as Record).reason === 'string' + ? ((data.policy as Record).reason as string) + : undefined, + } + : undefined, }); }; @@ -614,6 +671,7 @@ export class BrowserManager { session, groupTitle, pluginId, + allowedDomains, }, '*' ); @@ -630,6 +688,7 @@ export class BrowserManager { session: intent.session, groupTitle: intent.groupTitle, pluginId: intent.pluginId, + allowedDomains: intent.allowedDomains, timeoutMs: TAB_GROUP_REQUEST_TIMEOUT_MS, } ); @@ -683,6 +742,16 @@ export class BrowserManager { } this.setTabGroupCapability(intent.session, 'available'); + if (response.policy?.blocked) { + this.logTabGroupDebug( + `Tab-group policy blocked navigation (source=${source}, session=${intent.session}): ${response.policy.reason ?? 'domain-not-allowed'}` + ); + } + if (response.riskHints && response.riskHints.length > 0) { + this.logTabGroupDebug( + `Tab-group plugin risk hints (source=${source}, session=${intent.session}): ${response.riskHints.join(' | ')}` + ); + } } catch (error) { this.setTabGroupCapability(intent.session, 'unavailable'); const message = error instanceof Error ? error.message : String(error); @@ -1852,7 +1921,6 @@ export class BrowserManager { this.contextTimezoneId = this.resolveStealthTimezoneId(); this.contextHeaders = undefined; this.contextUserAgent = options.userAgent; - this.configureTabGroupIntent(options); // -p flag takes precedence over AGENT_BROWSER_PROVIDER. const provider = options.provider ?? process.env.AGENT_BROWSER_PROVIDER; @@ -1882,6 +1950,7 @@ export class BrowserManager { this.allowedDomains = parseDomainList(envDomains); } } + this.configureTabGroupIntent(options); if (this.downloadPath && (cdpEndpoint || options.autoConnect)) { const warning =