feat: complete agent-browser-stealth extension controls
This commit is contained in:
@@ -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/<session>/...`.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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/<session>/...`.
|
||||
- Use `--tab-group` / `AGENT_BROWSER_TAB_GROUP` for base title.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": ["<all_urls>"],
|
||||
"background": {
|
||||
"service_worker": "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) => {
|
||||
|
||||
@@ -17,6 +17,7 @@ function createTag(text) {
|
||||
|
||||
function renderSummary(state) {
|
||||
summaryEl.innerHTML = '';
|
||||
const options = state.options || {};
|
||||
const title = document.createElement('div');
|
||||
title.innerHTML = `<strong>Overview</strong> · extensionId: <code>${state.extensionId}</code>`;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -276,7 +276,7 @@ Notes:
|
||||
- non-default session: `Agent Browser Stealth • <session>`
|
||||
- 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/<session>/...`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user