feat: auto-group agent tabs by default in local Chromium
This commit is contained in:
+175
-5
@@ -16,7 +16,15 @@ import {
|
||||
} from 'playwright-core';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { existsSync, mkdirSync, rmSync, readFileSync, statSync } from 'node:fs';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { writeFile, mkdir } from 'node:fs/promises';
|
||||
import type { LaunchCommand, TraceEvent } from './types.js';
|
||||
import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js';
|
||||
@@ -127,6 +135,7 @@ interface StealthContextDefaults {
|
||||
}
|
||||
|
||||
const IGNORED_CDP_PAGE_URL_PREFIXES = ['chrome://omnibox-popup.top-chrome/'];
|
||||
const DEFAULT_TAB_GROUP_NAME = 'Agent Browser Stealth';
|
||||
|
||||
/**
|
||||
* Manages the Playwright browser lifecycle with multiple tabs/windows
|
||||
@@ -163,6 +172,7 @@ export class BrowserManager {
|
||||
private contextUserAgent: string | undefined = undefined;
|
||||
private downloadPath: string | null = null;
|
||||
private allowedDomains: string[] = [];
|
||||
private tabGroupExtensionDir: string | null = null;
|
||||
|
||||
/**
|
||||
* Set the persistent color scheme preference.
|
||||
@@ -478,6 +488,125 @@ export class BrowserManager {
|
||||
return warnings;
|
||||
}
|
||||
|
||||
private normalizeTabGroupName(name?: string): string | undefined {
|
||||
if (!name) return undefined;
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return undefined;
|
||||
// Keep the title short for stable UI rendering in Chrome's tab strip.
|
||||
return trimmed.slice(0, 80);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a temporary MV3 extension that auto-groups managed tabs under a fixed title.
|
||||
* This is only used for local Chromium launches.
|
||||
*/
|
||||
private createTabGroupExtension(groupTitle: string): string {
|
||||
this.cleanupTabGroupExtension();
|
||||
|
||||
const extensionDir = mkdtempSync(path.join(os.tmpdir(), 'agent-browser-tab-group-'));
|
||||
const manifest = {
|
||||
manifest_version: 3,
|
||||
name: 'Agent Browser Tab Grouper',
|
||||
version: '1.0.0',
|
||||
permissions: ['tabs', 'tabGroups'],
|
||||
host_permissions: ['<all_urls>'],
|
||||
background: {
|
||||
service_worker: 'service-worker.js',
|
||||
},
|
||||
content_scripts: [
|
||||
{
|
||||
matches: ['<all_urls>'],
|
||||
js: ['content-script.js'],
|
||||
run_at: 'document_start',
|
||||
match_about_blank: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const serviceWorker = `const GROUP_TITLE = ${JSON.stringify(groupTitle)};
|
||||
const MESSAGE_TYPE = 'agent-browser-manage-tab';
|
||||
|
||||
async function findGroupId(windowId) {
|
||||
const tabs = await chrome.tabs.query({ windowId });
|
||||
const checkedGroupIds = new Set();
|
||||
for (const tab of tabs) {
|
||||
if (typeof tab.groupId !== 'number' || tab.groupId < 0 || checkedGroupIds.has(tab.groupId)) {
|
||||
continue;
|
||||
}
|
||||
checkedGroupIds.add(tab.groupId);
|
||||
try {
|
||||
const group = await chrome.tabGroups.get(tab.groupId);
|
||||
if (group.title === GROUP_TITLE) {
|
||||
return tab.groupId;
|
||||
}
|
||||
} catch {
|
||||
// Ignore stale group IDs and continue searching.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function styleGroup(groupId) {
|
||||
await chrome.tabGroups.update(groupId, {
|
||||
title: GROUP_TITLE,
|
||||
color: 'blue',
|
||||
collapsed: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureTabGrouped(tabId, windowId) {
|
||||
let groupId = await findGroupId(windowId);
|
||||
if (groupId === null) {
|
||||
groupId = await chrome.tabs.group({
|
||||
tabIds: [tabId],
|
||||
createProperties: { windowId },
|
||||
});
|
||||
await styleGroup(groupId);
|
||||
return;
|
||||
}
|
||||
await chrome.tabs.group({
|
||||
groupId,
|
||||
tabIds: [tabId],
|
||||
});
|
||||
await styleGroup(groupId);
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender) => {
|
||||
if (!message || message.type !== MESSAGE_TYPE) {
|
||||
return;
|
||||
}
|
||||
const tabId = sender.tab?.id;
|
||||
const windowId = sender.tab?.windowId;
|
||||
if (typeof tabId !== 'number' || typeof windowId !== 'number') {
|
||||
return;
|
||||
}
|
||||
ensureTabGrouped(tabId, windowId).catch(() => {});
|
||||
});
|
||||
`;
|
||||
|
||||
const contentScript = `(() => {
|
||||
try {
|
||||
chrome.runtime.sendMessage({ type: 'agent-browser-manage-tab' });
|
||||
} catch {
|
||||
// Ignore pages where extension messaging is unavailable.
|
||||
}
|
||||
})();
|
||||
`;
|
||||
|
||||
writeFileSync(path.join(extensionDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
||||
writeFileSync(path.join(extensionDir, 'service-worker.js'), serviceWorker);
|
||||
writeFileSync(path.join(extensionDir, 'content-script.js'), contentScript);
|
||||
|
||||
this.tabGroupExtensionDir = extensionDir;
|
||||
return extensionDir;
|
||||
}
|
||||
|
||||
private cleanupTabGroupExtension(): void {
|
||||
if (!this.tabGroupExtensionDir) return;
|
||||
rmSync(this.tabGroupExtensionDir, { recursive: true, force: true });
|
||||
this.tabGroupExtensionDir = null;
|
||||
}
|
||||
|
||||
// CDP profiling state
|
||||
private static readonly MAX_PROFILE_EVENTS = 5_000_000;
|
||||
private profilingActive: boolean = false;
|
||||
@@ -1588,14 +1717,17 @@ export class BrowserManager {
|
||||
async launch(options: LaunchCommand): Promise<void> {
|
||||
// Determine CDP endpoint: prefer cdpUrl over cdpPort for flexibility
|
||||
const cdpEndpoint = options.cdpUrl ?? (options.cdpPort ? String(options.cdpPort) : undefined);
|
||||
const hasExtensions = !!options.extensions?.length;
|
||||
const configuredExtensions = options.extensions ? [...options.extensions] : [];
|
||||
const hasStorageState = !!options.storageState;
|
||||
const explicitTabGroup = this.normalizeTabGroupName(options.tabGroup);
|
||||
const requestedTabGroup = explicitTabGroup ?? DEFAULT_TAB_GROUP_NAME;
|
||||
const tabGroupWasExplicit = explicitTabGroup !== undefined;
|
||||
|
||||
if (hasExtensions && cdpEndpoint) {
|
||||
if (configuredExtensions.length > 0 && cdpEndpoint) {
|
||||
throw new Error('Extensions cannot be used with CDP connection');
|
||||
}
|
||||
|
||||
if (hasStorageState && hasExtensions) {
|
||||
if (hasStorageState && configuredExtensions.length > 0) {
|
||||
throw new Error(
|
||||
'Storage state cannot be used with extensions (extensions require persistent context)'
|
||||
);
|
||||
@@ -1646,6 +1778,42 @@ export class BrowserManager {
|
||||
}
|
||||
this.logStealthPolicy('launch policy', options.browser ?? 'chromium');
|
||||
|
||||
let effectiveExtensions = configuredExtensions;
|
||||
if (requestedTabGroup) {
|
||||
const requestedBrowserType = options.browser ?? 'chromium';
|
||||
if (this.stealthConnectionKind !== 'local') {
|
||||
if (tabGroupWasExplicit) {
|
||||
const warning = `--tab-group "${requestedTabGroup}" is ignored in CDP/provider mode (requires local Chromium launch)`;
|
||||
this.launchWarnings.push(warning);
|
||||
console.error(`[WARN] ${warning}`);
|
||||
}
|
||||
} else if (requestedBrowserType !== 'chromium') {
|
||||
if (tabGroupWasExplicit) {
|
||||
const warning = `--tab-group is only supported in Chromium (requested: ${requestedBrowserType})`;
|
||||
this.launchWarnings.push(warning);
|
||||
console.error(`[WARN] ${warning}`);
|
||||
}
|
||||
} else if (options.headless === true) {
|
||||
if (tabGroupWasExplicit) {
|
||||
const warning = '--tab-group is ignored in headless mode';
|
||||
this.launchWarnings.push(warning);
|
||||
console.error(`[WARN] ${warning}`);
|
||||
}
|
||||
} else if (hasStorageState) {
|
||||
if (tabGroupWasExplicit) {
|
||||
const warning =
|
||||
'--tab-group is ignored when storage state is loaded via --state (extensions require persistent context)';
|
||||
this.launchWarnings.push(warning);
|
||||
console.error(`[WARN] ${warning}`);
|
||||
}
|
||||
} else {
|
||||
const tabGroupExtensionPath = this.createTabGroupExtension(requestedTabGroup);
|
||||
effectiveExtensions = [...effectiveExtensions, tabGroupExtensionPath];
|
||||
}
|
||||
}
|
||||
|
||||
const hasExtensions = effectiveExtensions.length > 0;
|
||||
|
||||
if (options.downloadPath) {
|
||||
this.downloadPath = options.downloadPath;
|
||||
}
|
||||
@@ -1785,7 +1953,7 @@ export class BrowserManager {
|
||||
let context: BrowserContext;
|
||||
if (hasExtensions) {
|
||||
// Extensions require persistent context in a temp directory
|
||||
const extPaths = options.extensions!.join(',');
|
||||
const extPaths = effectiveExtensions.join(',');
|
||||
const session = process.env.AGENT_BROWSER_SESSION || 'default';
|
||||
// Combine extension args with custom args and file access args
|
||||
const extArgs = [`--disable-extensions-except=${extPaths}`, `--load-extension=${extPaths}`];
|
||||
@@ -3117,6 +3285,8 @@ export class BrowserManager {
|
||||
}
|
||||
}
|
||||
|
||||
this.cleanupTabGroupExtension();
|
||||
|
||||
this.pages = [];
|
||||
this.contexts = [];
|
||||
this.cdpEndpoint = null;
|
||||
|
||||
+46
-24
@@ -464,6 +464,7 @@ export async function startDaemon(options?: {
|
||||
colorSchemeEnv === 'no-preference'
|
||||
? colorSchemeEnv
|
||||
: undefined;
|
||||
const tabGroup = process.env.AGENT_BROWSER_TAB_GROUP?.trim();
|
||||
const launchOptions = {
|
||||
id: 'auto',
|
||||
action: 'launch' as const,
|
||||
@@ -478,38 +479,54 @@ export async function startDaemon(options?: {
|
||||
allowFileAccess: allowFileAccess,
|
||||
|
||||
colorScheme,
|
||||
tabGroup: tabGroup && tabGroup.length > 0 ? tabGroup : undefined,
|
||||
autoStateFilePath: getSessionAutoStatePath(),
|
||||
};
|
||||
|
||||
let attachedToExistingBrowser = false;
|
||||
try {
|
||||
// Keep default CDP attempt minimal. Launch-only options like extensions
|
||||
// are incompatible with CDP and can cause false-negative attach failures.
|
||||
const cdpLaunchOptions = {
|
||||
id: launchOptions.id,
|
||||
action: launchOptions.action,
|
||||
cdpPort: 9333,
|
||||
ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors,
|
||||
colorScheme: launchOptions.colorScheme,
|
||||
userAgent: launchOptions.userAgent,
|
||||
};
|
||||
await manager.launch({
|
||||
...cdpLaunchOptions,
|
||||
});
|
||||
attachedToExistingBrowser = true;
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error('[DEBUG] Auto-launch connected via default CDP port 9333');
|
||||
if (launchOptions.tabGroup) {
|
||||
try {
|
||||
await manager.launch(launchOptions);
|
||||
attachedToExistingBrowser = true;
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error('[DEBUG] Auto-launch started local Chromium with --tab-group');
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[DEBUG] Local launch with --tab-group failed: ${message}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(
|
||||
`[DEBUG] Default CDP port 9333 unavailable, trying auto-connect discovery: ${message}`
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
// Keep default CDP attempt minimal. Launch-only options like extensions
|
||||
// are incompatible with CDP and can cause false-negative attach failures.
|
||||
const cdpLaunchOptions = {
|
||||
id: launchOptions.id,
|
||||
action: launchOptions.action,
|
||||
cdpPort: 9333,
|
||||
ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors,
|
||||
colorScheme: launchOptions.colorScheme,
|
||||
userAgent: launchOptions.userAgent,
|
||||
};
|
||||
await manager.launch({
|
||||
...cdpLaunchOptions,
|
||||
});
|
||||
attachedToExistingBrowser = true;
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error('[DEBUG] Auto-launch connected via default CDP port 9333');
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(
|
||||
`[DEBUG] Default CDP port 9333 unavailable, trying auto-connect discovery: ${message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!attachedToExistingBrowser) {
|
||||
if (!attachedToExistingBrowser && !launchOptions.tabGroup) {
|
||||
try {
|
||||
await manager.launch({
|
||||
id: launchOptions.id,
|
||||
@@ -532,6 +549,11 @@ export async function startDaemon(options?: {
|
||||
}
|
||||
|
||||
if (!attachedToExistingBrowser) {
|
||||
if (launchOptions.tabGroup) {
|
||||
throw new Error(
|
||||
'Failed to launch local Chromium with tab grouping. Check Chromium availability and extension policy settings.'
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
'Project policy requires using your existing browser. Could not connect to CDP at localhost:9333 and auto-discovery also failed.'
|
||||
);
|
||||
|
||||
@@ -16,6 +16,17 @@ describe('parseCommand', () => {
|
||||
expect((result.command as any).stealth).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse launch command with tabGroup', () => {
|
||||
const result = parseCommand(
|
||||
cmd({ id: '1', action: 'launch', headless: false, tabGroup: 'Agent Browser Stealth' })
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.command.action).toBe('launch');
|
||||
expect(result.command.tabGroup).toBe('Agent Browser Stealth');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('navigation', () => {
|
||||
|
||||
@@ -51,6 +51,7 @@ const launchSchema = baseCommandSchema.extend({
|
||||
allowFileAccess: z.boolean().optional(),
|
||||
colorScheme: z.enum(['light', 'dark', 'no-preference']).optional(),
|
||||
downloadPath: z.string().optional(),
|
||||
tabGroup: z.string().min(1).optional(),
|
||||
storageState: z.string().optional(),
|
||||
allowedDomains: z.array(z.string()).optional(),
|
||||
actionPolicy: z.string().optional(),
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface LaunchCommand extends BaseCommand {
|
||||
allowFileAccess?: boolean; // Enable file:// URL access and cross-origin file requests
|
||||
colorScheme?: 'light' | 'dark' | 'no-preference'; // Persistent color scheme override
|
||||
downloadPath?: string; // Directory for browser downloads (Playwright's downloadsPath)
|
||||
tabGroup?: string; // Chromium local-launch only: auto-group agent tabs under this title
|
||||
allowedDomains?: string[];
|
||||
actionPolicy?: string;
|
||||
confirmActions?: string[];
|
||||
|
||||
Reference in New Issue
Block a user