feat: expand agent-browser-stealth extension capabilities
This commit is contained in:
@@ -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/<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.
|
||||
|
||||
## Stealth Architecture
|
||||
|
||||
|
||||
@@ -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 • <session>`
|
||||
- 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/<session>/...`.
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
<td>
|
||||
<code>AGENT_BROWSER_TAB_GROUP</code>
|
||||
</td>
|
||||
<td>
|
||||
Base title for tab grouping. Session suffix is appended automatically in CDP mode.
|
||||
</td>
|
||||
<td>Base title for tab grouping. Session suffix is appended automatically in CDP mode.</td>
|
||||
<td>
|
||||
<code>Agent Browser Stealth</code>
|
||||
</td>
|
||||
@@ -472,8 +473,13 @@ These environment variables configure additional daemon and runtime behavior:
|
||||
<td>
|
||||
<code>AGENT_BROWSER_SESSION_NAME</code>
|
||||
</td>
|
||||
<td>Auto-save/load state persistence name (defaults to <code>AGENT_BROWSER_SESSION</code> when unset).</td>
|
||||
<td>(same as <code>AGENT_BROWSER_SESSION</code>)</td>
|
||||
<td>
|
||||
Auto-save/load state persistence name (defaults to <code>AGENT_BROWSER_SESSION</code> when
|
||||
unset).
|
||||
</td>
|
||||
<td>
|
||||
(same as <code>AGENT_BROWSER_SESSION</code>)
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
'*'
|
||||
|
||||
@@ -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": ["<all_urls>"],
|
||||
"background": {
|
||||
"service_worker": "service-worker.js"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "agent-browser-stealth"
|
||||
},
|
||||
"side_panel": {
|
||||
"default_path": "sidepanel.html"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>agent-browser-stealth panel</title>
|
||||
<link rel="stylesheet" href="sidepanel.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>agent-browser-stealth</h1>
|
||||
<div class="actions">
|
||||
<button id="refresh-btn" type="button">Refresh</button>
|
||||
<button id="cleanup-btn" type="button">Clean Empty Groups</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="summary" class="card"></section>
|
||||
<section id="sessions" class="stack"></section>
|
||||
<section id="downloads" class="card"></section>
|
||||
|
||||
<script src="sidepanel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 = `<strong>Overview</strong> · extensionId: <code>${state.extensionId}</code>`;
|
||||
|
||||
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 = '<strong>Recent Downloads</strong>';
|
||||
|
||||
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 = `<div class="item-title">#${entry.id} · ${entry.state || 'updated'}</div><div class="item-url">${entry.filename || ''}</div>`;
|
||||
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);
|
||||
@@ -268,11 +268,17 @@ AGENT_BROWSER_TAB_GROUP_PLUGIN_ID="<extension-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 • <session>`
|
||||
- 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/<session>/...`.
|
||||
|
||||
### Visual Browser (Debugging)
|
||||
|
||||
|
||||
+74
-5
@@ -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<string, unknown>).enforced === true,
|
||||
blocked: (data.policy as Record<string, unknown>).blocked === true,
|
||||
reason:
|
||||
typeof (data.policy as Record<string, unknown>).reason === 'string'
|
||||
? ((data.policy as Record<string, unknown>).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 =
|
||||
|
||||
Reference in New Issue
Block a user