diff --git a/README.md b/README.md index 9a0b0d1..da21e75 100644 --- a/README.md +++ b/README.md @@ -74,11 +74,12 @@ Install once in Chrome: load unpacked extension from `extensions/tab-group-cdp/` ### Extension Capabilities (`agent-browser-stealth`) - Session window isolation: tabs are kept in their session window when possible. +- Configurable isolation controls: side panel can toggle `strictWindowIsolation` and cross-window activation guard. - 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. +- Side panel console: view session/tab/group mapping, focus a session, keep only one session, clean empty groups, edit session allowlist, and toggle auto-clean. ## Stealth Architecture diff --git a/docs/src/app/commands/page.mdx b/docs/src/app/commands/page.mdx index b1c5cf2..4dd8678 100644 --- a/docs/src/app/commands/page.mdx +++ b/docs/src/app/commands/page.mdx @@ -150,6 +150,7 @@ CDP mode uses a browser extension handshake to group tabs. - Extension side panel (`agent-browser-stealth`) also provides: - Session window isolation and deterministic group colors. - `Keep Only This`, `Focus`, `Clean Empty Groups` quick actions. + - Toggle switches for strict isolation / activation guard / auto-clean. - 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. diff --git a/docs/src/app/configuration/page.mdx b/docs/src/app/configuration/page.mdx index 215168c..cc31855 100644 --- a/docs/src/app/configuration/page.mdx +++ b/docs/src/app/configuration/page.mdx @@ -321,7 +321,7 @@ For tab grouping in CDP mode, grouping is best-effort through the extension hand 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. +session window isolation controls, activation guard toggles, empty-group cleanup, and per-session allowlist policy editing. ## Common Configurations diff --git a/extensions/tab-group-cdp/manifest.json b/extensions/tab-group-cdp/manifest.json index a9d616a..a0c70bc 100644 --- a/extensions/tab-group-cdp/manifest.json +++ b/extensions/tab-group-cdp/manifest.json @@ -3,7 +3,7 @@ "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"], + "permissions": ["tabs", "tabGroups", "downloads", "storage", "sidePanel", "alarms"], "host_permissions": [""], "background": { "service_worker": "service-worker.js" diff --git a/extensions/tab-group-cdp/service-worker.js b/extensions/tab-group-cdp/service-worker.js index 53f0205..b2c9cf3 100644 --- a/extensions/tab-group-cdp/service-worker.js +++ b/extensions/tab-group-cdp/service-worker.js @@ -4,22 +4,32 @@ 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 PANEL_SET_OPTIONS = 'AB_PANEL_SET_OPTIONS'; const DEFAULT_GROUP_TITLE = 'Agent Browser Stealth'; const DOWNLOAD_ARCHIVE_ROOT = 'agent-browser-stealth'; const STORAGE_POLICY_KEY = 'abSessionPoliciesV1'; +const STORAGE_OPTIONS_KEY = 'abExtensionOptionsV1'; +const CLEANUP_ALARM_NAME = 'ab-clean-empty-groups'; 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 DEFAULT_EXTENSION_OPTIONS = { + strictWindowIsolation: true, + suppressCrossWindowActivation: true, + autoCleanEmptyGroups: true, +}; + const sessionGroupCache = new Map(); const sessionWindowMap = new Map(); const tabSessionMap = new Map(); const tabMetaById = new Map(); const downloadEvents = []; const sessionPolicies = new Map(); +let extensionOptions = { ...DEFAULT_EXTENSION_OPTIONS }; -let policyLoadPromise = loadPolicies(); +let bootstrapPromise = bootstrapState(); function normalizeSession(session) { if (typeof session !== 'string') return 'default'; @@ -141,6 +151,46 @@ async function loadPolicies() { } } +function normalizeOptions(raw) { + if (!raw || typeof raw !== 'object') { + return { ...DEFAULT_EXTENSION_OPTIONS }; + } + return { + strictWindowIsolation: raw.strictWindowIsolation !== false, + suppressCrossWindowActivation: raw.suppressCrossWindowActivation !== false, + autoCleanEmptyGroups: raw.autoCleanEmptyGroups !== false, + }; +} + +async function loadOptions() { + try { + const result = await chrome.storage.local.get([STORAGE_OPTIONS_KEY]); + extensionOptions = normalizeOptions(result?.[STORAGE_OPTIONS_KEY]); + } catch { + extensionOptions = { ...DEFAULT_EXTENSION_OPTIONS }; + } +} + +async function persistOptions() { + await chrome.storage.local.set({ [STORAGE_OPTIONS_KEY]: extensionOptions }); +} + +async function setExtensionOptions(nextOptions) { + extensionOptions = { + ...extensionOptions, + ...normalizeOptions(nextOptions), + }; + await persistOptions(); + await syncCleanupAlarm(); + return extensionOptions; +} + +async function bootstrapState() { + await loadPolicies(); + await loadOptions(); + await syncCleanupAlarm(); +} + async function persistPolicies() { const serialized = {}; for (const [session, domains] of sessionPolicies.entries()) { @@ -200,6 +250,11 @@ function removeWindowCaches(windowId) { } async function ensureSessionWindow(tabId, currentWindowId, session) { + if (!extensionOptions.strictWindowIsolation) { + sessionWindowMap.set(session, currentWindowId); + return currentWindowId; + } + let targetWindowId = sessionWindowMap.get(session); if (typeof targetWindowId === 'number') { @@ -420,6 +475,75 @@ async function cleanEmptyGroups() { return { removedGroups, removedWindows }; } +async function syncCleanupAlarm() { + try { + await chrome.alarms.clear(CLEANUP_ALARM_NAME); + if (extensionOptions.autoCleanEmptyGroups) { + await chrome.alarms.create(CLEANUP_ALARM_NAME, { periodInMinutes: 1 }); + } + } catch { + // Ignore alarms API failures. + } +} + +async function enforceSessionWindowAffinity(tabId) { + if (!extensionOptions.suppressCrossWindowActivation) return { moved: false }; + + const session = getManagedSessionForTab(tabId); + if (!session) return { moved: false }; + if (!extensionOptions.strictWindowIsolation) return { moved: false }; + + let tab; + try { + tab = await chrome.tabs.get(tabId); + } catch { + return { moved: false }; + } + + const mappedWindowId = sessionWindowMap.get(session); + if (typeof mappedWindowId !== 'number' || mappedWindowId === tab.windowId) { + if (typeof tab.windowId === 'number') { + sessionWindowMap.set(session, tab.windowId); + } + return { moved: false }; + } + + try { + await chrome.tabs.move(tabId, { windowId: mappedWindowId, index: -1 }); + await chrome.tabs.update(tabId, { active: false }).catch(() => {}); + return { moved: true, toWindowId: mappedWindowId }; + } catch { + return { moved: false }; + } +} + +async function updateRiskBadge(tabId) { + let text = ''; + let title = 'agent-browser-stealth'; + + const session = getManagedSessionForTab(tabId); + if (session) { + let tab; + try { + tab = await chrome.tabs.get(tabId); + } catch { + tab = undefined; + } + + if (tab) { + const hints = collectRiskHints(tab.url, getSessionPolicy(session)); + if (hints.length > 0) { + text = '!'; + title = `Risk hints (${hints.length}): ${hints.join(', ')}`; + } + } + } + + await chrome.action.setBadgeText({ text }).catch(() => {}); + await chrome.action.setBadgeBackgroundColor({ color: '#dc2626' }).catch(() => {}); + await chrome.action.setTitle({ title }).catch(() => {}); +} + async function buildPanelState() { const allTabs = await chrome.tabs.query({}); for (const tab of allTabs) { @@ -492,6 +616,7 @@ async function buildPanelState() { return { extensionId: chrome.runtime.id, + options: { ...extensionOptions }, totals: { sessions: sessions.length, tabs: sessions.reduce((sum, session) => sum + session.tabs.length, 0), @@ -502,7 +627,7 @@ async function buildPanelState() { } async function handleTabGroupRequest(message, sender) { - await policyLoadPromise; + await bootstrapPromise; const tabId = sender.tab?.id; const windowId = sender.tab?.windowId; @@ -555,6 +680,11 @@ async function handleTabGroupRequest(message, sender) { chrome.runtime.onInstalled.addListener(() => { chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {}); + bootstrapPromise = bootstrapState(); +}); + +chrome.runtime.onStartup.addListener(() => { + bootstrapPromise = bootstrapState(); }); chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { @@ -580,7 +710,8 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } if (type === PANEL_GET_STATE) { - buildPanelState() + bootstrapPromise + .then(() => buildPanelState()) .then((state) => sendResponse({ ok: true, state })) .catch((error) => { const errorMessage = error instanceof Error ? error.message : String(error); @@ -620,7 +751,8 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } if (type === PANEL_SET_POLICY) { - setSessionPolicy(message.session, message.allowedDomains) + bootstrapPromise + .then(() => setSessionPolicy(message.session, message.allowedDomains)) .then(() => sendResponse({ ok: true })) .catch((error) => { const errorMessage = error instanceof Error ? error.message : String(error); @@ -628,12 +760,28 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { }); return true; } + + if (type === PANEL_SET_OPTIONS) { + bootstrapPromise + .then(() => setExtensionOptions(message.options)) + .then((options) => sendResponse({ ok: true, options })) + .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 (!session) { + if (changeInfo.status === 'complete' && tab.active === true) { + updateRiskBadge(tabId).catch(() => {}); + } + return; + } if (typeof tab.windowId === 'number') { sessionWindowMap.set(session, tab.windowId); @@ -641,9 +789,17 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { if (changeInfo.status === 'complete') { applySessionDomainFallback(tabId, session).catch(() => {}); + if (tab.active === true) { + updateRiskBadge(tabId).catch(() => {}); + } } }); +chrome.tabs.onActivated.addListener((activeInfo) => { + enforceSessionWindowAffinity(activeInfo.tabId).catch(() => {}); + updateRiskBadge(activeInfo.tabId).catch(() => {}); +}); + chrome.tabs.onRemoved.addListener((tabId, removeInfo) => { const session = getManagedSessionForTab(tabId); tabSessionMap.delete(tabId); @@ -659,6 +815,7 @@ chrome.tabs.onRemoved.addListener((tabId, removeInfo) => { if (remaining.length === 0) { sessionWindowMap.delete(session); } + cleanEmptyGroups().catch(() => {}); }); chrome.tabs.onDetached.addListener((tabId) => { @@ -675,6 +832,12 @@ chrome.tabs.onAttached.addListener((tabId, attachInfo) => { chrome.windows.onRemoved.addListener((windowId) => { removeWindowCaches(windowId); + cleanEmptyGroups().catch(() => {}); +}); + +chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm?.name !== CLEANUP_ALARM_NAME) return; + cleanEmptyGroups().catch(() => {}); }); chrome.downloads.onDeterminingFilename.addListener((item, suggest) => { diff --git a/extensions/tab-group-cdp/sidepanel.js b/extensions/tab-group-cdp/sidepanel.js index 5b27a59..cdd37c0 100644 --- a/extensions/tab-group-cdp/sidepanel.js +++ b/extensions/tab-group-cdp/sidepanel.js @@ -17,6 +17,7 @@ function createTag(text) { function renderSummary(state) { summaryEl.innerHTML = ''; + const options = state.options || {}; const title = document.createElement('div'); title.innerHTML = `Overview · extensionId: ${state.extensionId}`; @@ -24,9 +25,68 @@ function renderSummary(state) { tags.className = 'tags'; tags.appendChild(createTag(`sessions: ${state.totals.sessions}`)); tags.appendChild(createTag(`tabs: ${state.totals.tabs}`)); + tags.appendChild( + createTag(`isolation: ${options.strictWindowIsolation === false ? 'off' : 'on'}`) + ); + tags.appendChild( + createTag(`activation-guard: ${options.suppressCrossWindowActivation === false ? 'off' : 'on'}`) + ); + tags.appendChild( + createTag(`auto-clean: ${options.autoCleanEmptyGroups === false ? 'off' : 'on'}`) + ); + + const optionActions = document.createElement('div'); + optionActions.className = 'row-actions'; + + const isolationBtn = document.createElement('button'); + isolationBtn.type = 'button'; + isolationBtn.textContent = + options.strictWindowIsolation === false ? 'Enable Isolation' : 'Disable Isolation'; + isolationBtn.addEventListener('click', async () => { + await send({ + type: 'AB_PANEL_SET_OPTIONS', + options: { ...options, strictWindowIsolation: options.strictWindowIsolation === false }, + }); + await refresh(); + }); + + const guardBtn = document.createElement('button'); + guardBtn.type = 'button'; + guardBtn.textContent = + options.suppressCrossWindowActivation === false ? 'Enable Guard' : 'Disable Guard'; + guardBtn.addEventListener('click', async () => { + await send({ + type: 'AB_PANEL_SET_OPTIONS', + options: { + ...options, + suppressCrossWindowActivation: options.suppressCrossWindowActivation === false, + }, + }); + await refresh(); + }); + + const autoCleanBtn = document.createElement('button'); + autoCleanBtn.type = 'button'; + autoCleanBtn.textContent = + options.autoCleanEmptyGroups === false ? 'Enable Auto-Clean' : 'Disable Auto-Clean'; + autoCleanBtn.addEventListener('click', async () => { + await send({ + type: 'AB_PANEL_SET_OPTIONS', + options: { + ...options, + autoCleanEmptyGroups: options.autoCleanEmptyGroups === false, + }, + }); + await refresh(); + }); + + optionActions.appendChild(isolationBtn); + optionActions.appendChild(guardBtn); + optionActions.appendChild(autoCleanBtn); summaryEl.appendChild(title); summaryEl.appendChild(tags); + summaryEl.appendChild(optionActions); } function renderSessions(state) { diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index f535056..b513cf1 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -276,7 +276,7 @@ Notes: - 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. + - Side panel controls: Focus / Keep Only This / Clean Empty Groups + isolation/auto-clean toggles. - Session allowlist policy editing and fallback blocking (`about:blank`). - Download auto-routing to `agent-browser-stealth//...`.