feat: add CDP tab-group plugin handshake with silent fallback
This commit is contained in:
+127
-2
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { detectRiskSignals, toAIFriendlyError } from './actions.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { detectRiskSignals, executeCommand, toAIFriendlyError } from './actions.js';
|
||||
|
||||
describe('toAIFriendlyError', () => {
|
||||
describe('element blocked by overlay', () => {
|
||||
@@ -55,4 +55,129 @@ describe('detectRiskSignals', () => {
|
||||
const signals = detectRiskSignals('https://example.com/dashboard', 'Dashboard');
|
||||
expect(signals).toEqual([]);
|
||||
});
|
||||
|
||||
it('should detect cloudflare security verification text', () => {
|
||||
const signals = detectRiskSignals(
|
||||
'https://dash.cloudflare.com/zone/abc/ssl-tls/acm',
|
||||
'dash.cloudflare.com',
|
||||
'Performing security verification Verifying... This website uses a security service to protect against malicious bots.'
|
||||
);
|
||||
expect(signals.some((s) => s.code === 'verification_interstitial')).toBe(true);
|
||||
expect(signals.some((s) => s.code === 'bot_challenge')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab grouping fallback', () => {
|
||||
it('should keep navigate successful when tab grouping trigger throws', async () => {
|
||||
const page = {
|
||||
waitForTimeout: vi.fn().mockResolvedValue(undefined),
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
url: vi.fn().mockReturnValue('https://example.com/'),
|
||||
title: vi.fn().mockResolvedValue('Example Domain'),
|
||||
};
|
||||
|
||||
const browser = {
|
||||
getPage: vi.fn().mockReturnValue(page),
|
||||
setTargetUrl: vi.fn().mockResolvedValue(undefined),
|
||||
triggerTabGroupingForActivePage: vi.fn().mockImplementation(() => {
|
||||
throw new Error('plugin-unavailable');
|
||||
}),
|
||||
};
|
||||
|
||||
const response = await executeCommand(
|
||||
{ id: 'n1', action: 'navigate', url: 'https://example.com', riskMode: 'off' },
|
||||
browser as any
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(page.goto).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should keep tab_new successful when tab grouping trigger throws after navigation', async () => {
|
||||
const page = {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const browser = {
|
||||
newTab: vi.fn().mockResolvedValue({ index: 1, total: 2 }),
|
||||
getPage: vi.fn().mockReturnValue(page),
|
||||
triggerTabGroupingForActivePage: vi.fn().mockImplementation(() => {
|
||||
throw new Error('plugin-unavailable');
|
||||
}),
|
||||
};
|
||||
|
||||
const response = await executeCommand(
|
||||
{ id: 't1', action: 'tab_new', url: 'https://example.com' },
|
||||
browser as any
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(browser.newTab).toHaveBeenCalledTimes(1);
|
||||
expect(page.goto).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('risk interstitial recovery', () => {
|
||||
it('should wait for cloudflare-style challenge to clear before retrying navigation', async () => {
|
||||
const challengeClearMs = 10_000;
|
||||
let challengeElapsed = 0;
|
||||
let currentUrl = 'https://dash.cloudflare.com/challenge';
|
||||
let currentTitle = 'Just a moment...';
|
||||
|
||||
const syncChallengeState = () => {
|
||||
if (challengeElapsed >= challengeClearMs) {
|
||||
currentUrl = 'https://dash.cloudflare.com/zone/abc/ssl-tls/acm';
|
||||
currentTitle = 'Cloudflare Dashboard';
|
||||
} else {
|
||||
currentUrl = 'https://dash.cloudflare.com/challenge';
|
||||
currentTitle = 'Just a moment...';
|
||||
}
|
||||
};
|
||||
|
||||
const page = {
|
||||
waitForTimeout: vi.fn().mockImplementation(async (ms: number) => {
|
||||
challengeElapsed += Number(ms) || 0;
|
||||
syncChallengeState();
|
||||
}),
|
||||
goto: vi.fn().mockImplementation(async () => {
|
||||
// Refreshing during verification resets challenge progress.
|
||||
if (challengeElapsed < challengeClearMs) {
|
||||
challengeElapsed = 0;
|
||||
}
|
||||
syncChallengeState();
|
||||
}),
|
||||
url: vi.fn().mockImplementation(() => currentUrl),
|
||||
title: vi.fn().mockImplementation(async () => currentTitle),
|
||||
evaluate: vi.fn().mockImplementation(async () => {
|
||||
if (currentTitle === 'Just a moment...') {
|
||||
return 'Performing security verification Verifying... This website uses a security service to protect against malicious bots.';
|
||||
}
|
||||
return 'Dashboard content';
|
||||
}),
|
||||
};
|
||||
|
||||
const browser = {
|
||||
getPage: vi.fn().mockReturnValue(page),
|
||||
setTargetUrl: vi.fn().mockResolvedValue(undefined),
|
||||
triggerTabGroupingForActivePage: vi.fn(),
|
||||
};
|
||||
|
||||
const response = await executeCommand(
|
||||
{
|
||||
id: 'cf1',
|
||||
action: 'navigate',
|
||||
url: 'https://dash.cloudflare.com/zone/abc/ssl-tls/acm',
|
||||
riskMode: 'warn',
|
||||
},
|
||||
browser as any
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
if (response.success) {
|
||||
expect(response.data.title).toBe('Cloudflare Dashboard');
|
||||
expect(response.data.warning).toContain('cleared after wait');
|
||||
expect(response.data.riskSignals?.length ?? 0).toBeGreaterThan(0);
|
||||
}
|
||||
expect(page.goto).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
+114
-4
@@ -545,6 +545,11 @@ async function handleNavigate(
|
||||
await page.goto(command.url, {
|
||||
waitUntil: command.waitUntil ?? 'load',
|
||||
});
|
||||
try {
|
||||
browser.triggerTabGroupingForActivePage('navigate');
|
||||
} catch {
|
||||
// Tab-grouping is best-effort and must never fail navigation.
|
||||
}
|
||||
|
||||
const riskMode: RiskMode = command.riskMode ?? 'warn';
|
||||
if (riskMode === 'off') {
|
||||
@@ -557,7 +562,7 @@ async function handleNavigate(
|
||||
// Detect risk interstitials (captcha/verification) and handle by risk mode.
|
||||
const finalUrl = page.url();
|
||||
const title = await page.title();
|
||||
let encounteredSignals = detectRiskSignals(finalUrl, title);
|
||||
let encounteredSignals = await detectPageRiskSignals(page, finalUrl, title);
|
||||
if (encounteredSignals.length === 0) {
|
||||
return successResponse(command.id, {
|
||||
url: finalUrl,
|
||||
@@ -573,16 +578,43 @@ async function handleNavigate(
|
||||
);
|
||||
}
|
||||
|
||||
// Many verification interstitials (e.g. Cloudflare) auto-resolve after a short wait.
|
||||
// Poll before forcing a retry to avoid resetting the challenge loop ourselves.
|
||||
const initialRecovery = await waitForRiskRecovery(page, 12_000);
|
||||
if (initialRecovery.recovered) {
|
||||
return successResponse(command.id, {
|
||||
url: initialRecovery.url,
|
||||
title: initialRecovery.title,
|
||||
warning:
|
||||
'Risk interstitial detected and cleared after wait. Reuse the same browser session for stability.',
|
||||
riskSignals: encounteredSignals,
|
||||
});
|
||||
}
|
||||
encounteredSignals = mergeRiskSignals(encounteredSignals, initialRecovery.signals);
|
||||
|
||||
const maxRetries = 2;
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
const backoff = 3000 + Math.random() * 4000;
|
||||
await page.waitForTimeout(Math.round(backoff));
|
||||
|
||||
const passiveRecovery = await waitForRiskRecovery(page, 8_000);
|
||||
if (passiveRecovery.recovered) {
|
||||
return successResponse(command.id, {
|
||||
url: passiveRecovery.url,
|
||||
title: passiveRecovery.title,
|
||||
warning:
|
||||
'Risk interstitial detected and recovered after wait. Review riskSignals for evidence.',
|
||||
riskSignals: encounteredSignals,
|
||||
});
|
||||
}
|
||||
encounteredSignals = mergeRiskSignals(encounteredSignals, passiveRecovery.signals);
|
||||
|
||||
await page.goto(command.url, {
|
||||
waitUntil: command.waitUntil ?? 'load',
|
||||
});
|
||||
const retryUrl = page.url();
|
||||
const retryTitle = await page.title();
|
||||
const retrySignals = detectRiskSignals(retryUrl, retryTitle);
|
||||
const retrySignals = await detectPageRiskSignals(page, retryUrl, retryTitle);
|
||||
if (retrySignals.length === 0) {
|
||||
return successResponse(command.id, {
|
||||
url: retryUrl,
|
||||
@@ -600,7 +632,7 @@ async function handleNavigate(
|
||||
url: page.url(),
|
||||
title: await page.title(),
|
||||
warning:
|
||||
'Captcha/verification page detected. Try --headed mode or use --session-name for state persistence.',
|
||||
'Captcha/verification page detected. Keep one stable --session-name and retry in the same browser window.',
|
||||
riskSignals: encounteredSignals,
|
||||
});
|
||||
}
|
||||
@@ -616,12 +648,57 @@ function mergeRiskSignals(current: RiskSignal[], next: RiskSignal[]): RiskSignal
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
async function detectPageRiskSignals(
|
||||
page: Page,
|
||||
currentUrl?: string,
|
||||
currentTitle?: string
|
||||
): Promise<RiskSignal[]> {
|
||||
const url = currentUrl ?? page.url();
|
||||
const title = currentTitle ?? (await page.title());
|
||||
let pageText = '';
|
||||
try {
|
||||
pageText = await page.evaluate(() => {
|
||||
const text = (globalThis as any).document?.body?.innerText ?? '';
|
||||
return String(text).slice(0, 2000);
|
||||
});
|
||||
} catch {
|
||||
// Ignore cross-origin/script-restricted pages; URL/title signals still apply.
|
||||
}
|
||||
return detectRiskSignals(url, title, pageText);
|
||||
}
|
||||
|
||||
async function waitForRiskRecovery(
|
||||
page: Page,
|
||||
timeoutMs: number
|
||||
): Promise<{ recovered: boolean; url: string; title: string; signals: RiskSignal[] }> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let url = page.url();
|
||||
let title = await page.title();
|
||||
let signals = await detectPageRiskSignals(page, url, title);
|
||||
|
||||
while (signals.length > 0 && Date.now() < deadline) {
|
||||
const remaining = deadline - Date.now();
|
||||
await page.waitForTimeout(Math.min(1000, Math.max(250, remaining)));
|
||||
url = page.url();
|
||||
title = await page.title();
|
||||
signals = await detectPageRiskSignals(page, url, title);
|
||||
}
|
||||
|
||||
return {
|
||||
recovered: signals.length === 0,
|
||||
url,
|
||||
title,
|
||||
signals,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect verification/captcha interstitials and return structured risk evidence.
|
||||
*/
|
||||
export function detectRiskSignals(url: string, title: string): RiskSignal[] {
|
||||
export function detectRiskSignals(url: string, title: string, pageText: string = ''): RiskSignal[] {
|
||||
const lowerUrl = url.toLowerCase();
|
||||
const lowerTitle = title.toLowerCase();
|
||||
const lowerText = pageText.toLowerCase();
|
||||
const urlPatterns: Array<{ pattern: string; code: string; confidence: number }> = [
|
||||
{ pattern: '/verify/captcha', code: 'captcha_interstitial', confidence: 0.98 },
|
||||
{ pattern: '/captcha', code: 'captcha_interstitial', confidence: 0.95 },
|
||||
@@ -637,12 +714,30 @@ export function detectRiskSignals(url: string, title: string): RiskSignal[] {
|
||||
{ pattern: 'challenge', code: 'verification_interstitial', confidence: 0.8 },
|
||||
{ pattern: 'attention required', code: 'verification_interstitial', confidence: 0.96 },
|
||||
{ pattern: 'just a moment', code: 'verification_interstitial', confidence: 0.95 },
|
||||
{
|
||||
pattern: 'performing security verification',
|
||||
code: 'verification_interstitial',
|
||||
confidence: 0.98,
|
||||
},
|
||||
{ pattern: 'checking your browser', code: 'verification_interstitial', confidence: 0.97 },
|
||||
{ pattern: 'access denied', code: 'access_gate', confidence: 0.86 },
|
||||
{ pattern: '驗證', code: 'verification_interstitial', confidence: 0.88 },
|
||||
{ pattern: '验证', code: 'verification_interstitial', confidence: 0.88 },
|
||||
{ pattern: '人机验证', code: 'captcha_interstitial', confidence: 0.95 },
|
||||
];
|
||||
const textPatterns: Array<{ pattern: string; code: string; confidence: number }> = [
|
||||
{
|
||||
pattern: 'performing security verification',
|
||||
code: 'verification_interstitial',
|
||||
confidence: 0.99,
|
||||
},
|
||||
{
|
||||
pattern: 'this website uses a security service to protect against malicious bots',
|
||||
code: 'bot_challenge',
|
||||
confidence: 0.99,
|
||||
},
|
||||
{ pattern: 'verifying...', code: 'verification_interstitial', confidence: 0.84 },
|
||||
];
|
||||
const signals: RiskSignal[] = [];
|
||||
for (const item of urlPatterns) {
|
||||
if (lowerUrl.includes(item.pattern)) {
|
||||
@@ -664,6 +759,16 @@ export function detectRiskSignals(url: string, title: string): RiskSignal[] {
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const item of textPatterns) {
|
||||
if (lowerText.includes(item.pattern)) {
|
||||
signals.push({
|
||||
code: item.code,
|
||||
source: 'title',
|
||||
evidence: item.pattern,
|
||||
confidence: item.confidence,
|
||||
});
|
||||
}
|
||||
}
|
||||
return mergeRiskSignals([], signals);
|
||||
}
|
||||
|
||||
@@ -1152,6 +1257,11 @@ async function handleTabNew(
|
||||
if (command.url) {
|
||||
const page = browser.getPage();
|
||||
await page.goto(command.url, { waitUntil: 'domcontentloaded' });
|
||||
try {
|
||||
browser.triggerTabGroupingForActivePage('tab-new-navigate');
|
||||
} catch {
|
||||
// Tab-grouping is best-effort and must never fail tab creation.
|
||||
}
|
||||
}
|
||||
|
||||
return successResponse(command.id, result);
|
||||
|
||||
@@ -262,6 +262,80 @@ describe('BrowserManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab-group plugin handshake', () => {
|
||||
it('should mark plugin capability as available after successful handshake', async () => {
|
||||
const manager = new BrowserManager() as any;
|
||||
manager.tabGroupIntent = {
|
||||
session: 'default',
|
||||
groupTitle: 'Agent Browser Stealth',
|
||||
pluginId: 'plugin-123',
|
||||
};
|
||||
manager.stealthConnectionKind = 'cdp';
|
||||
|
||||
const page = {
|
||||
isClosed: () => false,
|
||||
url: () => 'https://example.com',
|
||||
};
|
||||
|
||||
const requestSpy = vi
|
||||
.spyOn(manager, 'requestTabGroupPlugin')
|
||||
.mockResolvedValue({ ok: true, extensionId: 'plugin-123' });
|
||||
|
||||
await manager.tryApplyTabGrouping(page, 'test');
|
||||
|
||||
expect(manager.getTabGroupCapability('default')).toBe('available');
|
||||
expect(requestSpy).toHaveBeenCalledTimes(1);
|
||||
requestSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should silently mark capability unavailable on timeout response', async () => {
|
||||
const manager = new BrowserManager() as any;
|
||||
manager.tabGroupIntent = {
|
||||
session: 'default',
|
||||
groupTitle: 'Agent Browser Stealth',
|
||||
pluginId: 'plugin-123',
|
||||
};
|
||||
manager.stealthConnectionKind = 'cdp';
|
||||
|
||||
const page = {
|
||||
isClosed: () => false,
|
||||
url: () => 'https://example.com',
|
||||
};
|
||||
|
||||
const requestSpy = vi.spyOn(manager, 'requestTabGroupPlugin').mockResolvedValue(null);
|
||||
|
||||
await manager.tryApplyTabGrouping(page, 'test-timeout');
|
||||
|
||||
expect(manager.getTabGroupCapability('default')).toBe('unavailable');
|
||||
expect(requestSpy).toHaveBeenCalledTimes(1);
|
||||
requestSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should stop retrying handshake once capability is unavailable', async () => {
|
||||
const manager = new BrowserManager() as any;
|
||||
manager.tabGroupIntent = {
|
||||
session: 'default',
|
||||
groupTitle: 'Agent Browser Stealth',
|
||||
pluginId: 'plugin-123',
|
||||
};
|
||||
manager.stealthConnectionKind = 'cdp';
|
||||
|
||||
const page = {
|
||||
isClosed: () => false,
|
||||
url: () => 'https://example.com',
|
||||
};
|
||||
|
||||
const requestSpy = vi.spyOn(manager, 'requestTabGroupPlugin').mockResolvedValue(null);
|
||||
|
||||
await manager.tryApplyTabGrouping(page, 'first-attempt');
|
||||
await manager.tryApplyTabGrouping(page, 'second-attempt');
|
||||
|
||||
expect(manager.getTabGroupCapability('default')).toBe('unavailable');
|
||||
expect(requestSpy).toHaveBeenCalledTimes(1);
|
||||
requestSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stale session recovery (all pages closed)', () => {
|
||||
it('should recover when all pages are closed externally', async () => {
|
||||
const testBrowser = new BrowserManager();
|
||||
|
||||
+215
-155
@@ -16,15 +16,7 @@ import {
|
||||
} from 'playwright-core';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { existsSync, mkdirSync, rmSync, readFileSync, statSync } 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';
|
||||
@@ -136,6 +128,18 @@ interface StealthContextDefaults {
|
||||
|
||||
const IGNORED_CDP_PAGE_URL_PREFIXES = ['chrome://omnibox-popup.top-chrome/'];
|
||||
const DEFAULT_TAB_GROUP_NAME = 'Agent Browser Stealth';
|
||||
const DEFAULT_TAB_GROUP_PLUGIN_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
const TAB_GROUP_REQUEST_MESSAGE_TYPE = 'AB_TAB_GROUP_REQUEST';
|
||||
const TAB_GROUP_RESPONSE_MESSAGE_TYPE = 'AB_TAB_GROUP_RESPONSE';
|
||||
const TAB_GROUP_REQUEST_TIMEOUT_MS = 400;
|
||||
|
||||
type TabGroupPluginAvailability = 'unknown' | 'available' | 'unavailable';
|
||||
|
||||
interface TabGroupIntent {
|
||||
session: string;
|
||||
groupTitle: string;
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the Playwright browser lifecycle with multiple tabs/windows
|
||||
@@ -172,7 +176,9 @@ export class BrowserManager {
|
||||
private contextUserAgent: string | undefined = undefined;
|
||||
private downloadPath: string | null = null;
|
||||
private allowedDomains: string[] = [];
|
||||
private tabGroupExtensionDir: string | null = null;
|
||||
private tabGroupIntent: TabGroupIntent | null = null;
|
||||
private tabGroupCapabilityBySession: Map<string, TabGroupPluginAvailability> = new Map();
|
||||
private tabGroupInFlight: WeakSet<Page> = new WeakSet();
|
||||
|
||||
/**
|
||||
* Set the persistent color scheme preference.
|
||||
@@ -496,115 +502,202 @@ export class BrowserManager {
|
||||
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();
|
||||
private normalizeTabGroupPluginId(pluginId?: string): string | undefined {
|
||||
if (!pluginId) return undefined;
|
||||
const trimmed = pluginId.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.slice(0, 128);
|
||||
}
|
||||
|
||||
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',
|
||||
private getAgentSessionName(): string {
|
||||
const session = process.env.AGENT_BROWSER_SESSION?.trim();
|
||||
return session && session.length > 0 ? session : 'default';
|
||||
}
|
||||
|
||||
private buildSessionTabGroupTitle(baseTitle: string, session: string): string {
|
||||
const normalizedBase = this.normalizeTabGroupName(baseTitle) ?? DEFAULT_TAB_GROUP_NAME;
|
||||
if (session === 'default') {
|
||||
return normalizedBase;
|
||||
}
|
||||
const withSuffix = `${normalizedBase} • ${session}`;
|
||||
return this.normalizeTabGroupName(withSuffix) ?? normalizedBase;
|
||||
}
|
||||
|
||||
private configureTabGroupIntent(options: LaunchCommand): void {
|
||||
const baseTitle = this.normalizeTabGroupName(options.tabGroup) ?? DEFAULT_TAB_GROUP_NAME;
|
||||
const session = this.getAgentSessionName();
|
||||
const groupTitle = this.buildSessionTabGroupTitle(baseTitle, session);
|
||||
const pluginId =
|
||||
this.normalizeTabGroupPluginId(options.tabGroupPluginId) ??
|
||||
this.normalizeTabGroupPluginId(process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID) ??
|
||||
DEFAULT_TAB_GROUP_PLUGIN_ID;
|
||||
|
||||
this.tabGroupIntent = { session, groupTitle, pluginId };
|
||||
if (!this.tabGroupCapabilityBySession.has(session)) {
|
||||
this.tabGroupCapabilityBySession.set(session, 'unknown');
|
||||
}
|
||||
}
|
||||
|
||||
private getTabGroupCapability(session: string): TabGroupPluginAvailability {
|
||||
return this.tabGroupCapabilityBySession.get(session) ?? 'unknown';
|
||||
}
|
||||
|
||||
private setTabGroupCapability(session: string, capability: TabGroupPluginAvailability): void {
|
||||
this.tabGroupCapabilityBySession.set(session, capability);
|
||||
}
|
||||
|
||||
private canInjectTabGroupScript(page: Page): boolean {
|
||||
const url = this.getSafePageUrl(page).toLowerCase();
|
||||
if (!url) return false;
|
||||
return (
|
||||
!url.startsWith('chrome://') &&
|
||||
!url.startsWith('chrome-extension://') &&
|
||||
!url.startsWith('devtools://') &&
|
||||
!url.startsWith('edge://')
|
||||
);
|
||||
}
|
||||
|
||||
private logTabGroupDebug(message: string): void {
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error(`[DEBUG] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async requestTabGroupPlugin(
|
||||
page: Page,
|
||||
intent: TabGroupIntent
|
||||
): Promise<{ ok: boolean; extensionId?: string; error?: 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 }) => {
|
||||
return new Promise<{
|
||||
ok: boolean;
|
||||
extensionId?: string;
|
||||
error?: string;
|
||||
} | null>((resolve) => {
|
||||
let settled = false;
|
||||
let timer: number | undefined;
|
||||
|
||||
const finish = (value: { ok: boolean; extensionId?: string; error?: string } | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
window.removeEventListener('message', onMessage);
|
||||
if (typeof timer === 'number') {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (event.source !== window) return;
|
||||
const data = event.data as Record<string, unknown> | null;
|
||||
if (!data || data.type !== responseType) return;
|
||||
if (data.nonce !== nonce) return;
|
||||
finish({
|
||||
ok: data.ok === true,
|
||||
extensionId:
|
||||
typeof data.extensionId === 'string' && data.extensionId.length > 0
|
||||
? data.extensionId
|
||||
: undefined,
|
||||
error: typeof data.error === 'string' ? data.error : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('message', onMessage);
|
||||
timer = window.setTimeout(() => finish(null), timeoutMs);
|
||||
|
||||
try {
|
||||
window.postMessage(
|
||||
{
|
||||
type: requestType,
|
||||
nonce,
|
||||
session,
|
||||
groupTitle,
|
||||
pluginId,
|
||||
},
|
||||
'*'
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
finish({ ok: false, error: message });
|
||||
}
|
||||
});
|
||||
},
|
||||
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;
|
||||
{
|
||||
requestType: TAB_GROUP_REQUEST_MESSAGE_TYPE,
|
||||
responseType: TAB_GROUP_RESPONSE_MESSAGE_TYPE,
|
||||
nonce,
|
||||
session: intent.session,
|
||||
groupTitle: intent.groupTitle,
|
||||
pluginId: intent.pluginId,
|
||||
timeoutMs: TAB_GROUP_REQUEST_TIMEOUT_MS,
|
||||
}
|
||||
} catch {
|
||||
// Ignore stale group IDs and continue searching.
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private scheduleTabGrouping(page: Page, source: string): void {
|
||||
void this.tryApplyTabGrouping(page, source);
|
||||
}
|
||||
|
||||
private async tryApplyTabGrouping(page: Page, source: string): Promise<void> {
|
||||
const intent = this.tabGroupIntent;
|
||||
if (!intent) return;
|
||||
if (this.stealthConnectionKind !== 'cdp') return;
|
||||
if (this.tabGroupInFlight.has(page)) return;
|
||||
|
||||
const capability = this.getTabGroupCapability(intent.session);
|
||||
if (capability === 'unavailable') return;
|
||||
|
||||
if (page.isClosed() || !this.canInjectTabGroupScript(page)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tabGroupInFlight.add(page);
|
||||
|
||||
try {
|
||||
const response = await this.requestTabGroupPlugin(page, intent);
|
||||
if (!response) {
|
||||
this.setTabGroupCapability(intent.session, 'unavailable');
|
||||
this.logTabGroupDebug(
|
||||
`Tab-group plugin unavailable (timeout, source=${source}, session=${intent.session})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
this.setTabGroupCapability(intent.session, 'unavailable');
|
||||
this.logTabGroupDebug(
|
||||
`Tab-group plugin returned error (source=${source}, session=${intent.session}): ${response.error ?? 'unknown'}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.extensionId !== intent.pluginId) {
|
||||
this.setTabGroupCapability(intent.session, 'unavailable');
|
||||
this.logTabGroupDebug(
|
||||
`Tab-group plugin id mismatch (source=${source}, expected=${intent.pluginId}, actual=${response.extensionId ?? 'missing'})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setTabGroupCapability(intent.session, 'available');
|
||||
} catch (error) {
|
||||
this.setTabGroupCapability(intent.session, 'unavailable');
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logTabGroupDebug(
|
||||
`Tab-group plugin unavailable (source=${source}, session=${intent.session}): ${message}`
|
||||
);
|
||||
} finally {
|
||||
this.tabGroupInFlight.delete(page);
|
||||
}
|
||||
}
|
||||
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;
|
||||
triggerTabGroupingForActivePage(source: string = 'active-page'): void {
|
||||
if (!this.tabGroupIntent || this.pages.length === 0) return;
|
||||
const page = this.getPage();
|
||||
this.scheduleTabGrouping(page, source);
|
||||
}
|
||||
|
||||
// CDP profiling state
|
||||
@@ -1719,9 +1812,6 @@ chrome.runtime.onMessage.addListener((message, sender) => {
|
||||
const cdpEndpoint = options.cdpUrl ?? (options.cdpPort ? String(options.cdpPort) : undefined);
|
||||
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 (configuredExtensions.length > 0 && cdpEndpoint) {
|
||||
throw new Error('Extensions cannot be used with CDP connection');
|
||||
@@ -1762,6 +1852,7 @@ chrome.runtime.onMessage.addListener((message, sender) => {
|
||||
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;
|
||||
|
||||
@@ -1777,42 +1868,7 @@ chrome.runtime.onMessage.addListener((message, sender) => {
|
||||
this.stealthConnectionKind = 'local';
|
||||
}
|
||||
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;
|
||||
const hasExtensions = configuredExtensions.length > 0;
|
||||
|
||||
if (options.downloadPath) {
|
||||
this.downloadPath = options.downloadPath;
|
||||
@@ -1953,7 +2009,7 @@ chrome.runtime.onMessage.addListener((message, sender) => {
|
||||
let context: BrowserContext;
|
||||
if (hasExtensions) {
|
||||
// Extensions require persistent context in a temp directory
|
||||
const extPaths = effectiveExtensions.join(',');
|
||||
const extPaths = configuredExtensions.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}`];
|
||||
@@ -2511,6 +2567,8 @@ chrome.runtime.onMessage.addListener((message, sender) => {
|
||||
// Invalidate CDP session since the active page changed
|
||||
this.invalidateCDPSession().catch(() => {});
|
||||
}
|
||||
|
||||
this.scheduleTabGrouping(page, 'context-page');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2533,6 +2591,7 @@ chrome.runtime.onMessage.addListener((message, sender) => {
|
||||
this.setupPageTracking(page);
|
||||
}
|
||||
this.activePageIndex = this.pages.length - 1;
|
||||
this.scheduleTabGrouping(page, 'new-tab');
|
||||
|
||||
return { index: this.activePageIndex, total: this.pages.length };
|
||||
}
|
||||
@@ -3285,8 +3344,6 @@ chrome.runtime.onMessage.addListener((message, sender) => {
|
||||
}
|
||||
}
|
||||
|
||||
this.cleanupTabGroupExtension();
|
||||
|
||||
this.pages = [];
|
||||
this.contexts = [];
|
||||
this.cdpEndpoint = null;
|
||||
@@ -3305,6 +3362,9 @@ chrome.runtime.onMessage.addListener((message, sender) => {
|
||||
this.contextTimezoneId = undefined;
|
||||
this.contextHeaders = undefined;
|
||||
this.contextUserAgent = undefined;
|
||||
this.tabGroupIntent = null;
|
||||
this.tabGroupCapabilityBySession.clear();
|
||||
this.tabGroupInFlight = new WeakSet();
|
||||
this.refMap = {};
|
||||
this.lastSnapshot = '';
|
||||
this.frameCallback = null;
|
||||
|
||||
+31
-44
@@ -465,6 +465,7 @@ export async function startDaemon(options?: {
|
||||
? colorSchemeEnv
|
||||
: undefined;
|
||||
const tabGroup = process.env.AGENT_BROWSER_TAB_GROUP?.trim();
|
||||
const tabGroupPluginId = process.env.AGENT_BROWSER_TAB_GROUP_PLUGIN_ID?.trim();
|
||||
const launchOptions = {
|
||||
id: 'auto',
|
||||
action: 'launch' as const,
|
||||
@@ -480,53 +481,42 @@ export async function startDaemon(options?: {
|
||||
|
||||
colorScheme,
|
||||
tabGroup: tabGroup && tabGroup.length > 0 ? tabGroup : undefined,
|
||||
tabGroupPluginId:
|
||||
tabGroupPluginId && tabGroupPluginId.length > 0 ? tabGroupPluginId : undefined,
|
||||
autoStateFilePath: getSessionAutoStatePath(),
|
||||
};
|
||||
|
||||
let attachedToExistingBrowser = false;
|
||||
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}`);
|
||||
}
|
||||
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,
|
||||
tabGroup: launchOptions.tabGroup,
|
||||
tabGroupPluginId: launchOptions.tabGroupPluginId,
|
||||
};
|
||||
await manager.launch({
|
||||
...cdpLaunchOptions,
|
||||
});
|
||||
attachedToExistingBrowser = true;
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error('[DEBUG] Auto-launch connected via default CDP port 9333');
|
||||
}
|
||||
} 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}`
|
||||
);
|
||||
}
|
||||
} 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 && !launchOptions.tabGroup) {
|
||||
if (!attachedToExistingBrowser) {
|
||||
try {
|
||||
await manager.launch({
|
||||
id: launchOptions.id,
|
||||
@@ -535,6 +525,8 @@ export async function startDaemon(options?: {
|
||||
ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors,
|
||||
colorScheme: launchOptions.colorScheme,
|
||||
userAgent: launchOptions.userAgent,
|
||||
tabGroup: launchOptions.tabGroup,
|
||||
tabGroupPluginId: launchOptions.tabGroupPluginId,
|
||||
});
|
||||
attachedToExistingBrowser = true;
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
@@ -549,11 +541,6 @@ 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.'
|
||||
);
|
||||
|
||||
@@ -52,6 +52,7 @@ const launchSchema = baseCommandSchema.extend({
|
||||
colorScheme: z.enum(['light', 'dark', 'no-preference']).optional(),
|
||||
downloadPath: z.string().optional(),
|
||||
tabGroup: z.string().min(1).optional(),
|
||||
tabGroupPluginId: z.string().min(1).optional(),
|
||||
storageState: z.string().optional(),
|
||||
allowedDomains: z.array(z.string()).optional(),
|
||||
actionPolicy: z.string().optional(),
|
||||
|
||||
+2
-1
@@ -41,7 +41,8 @@ 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
|
||||
tabGroup?: string; // Base tab-group title (session suffix is appended automatically)
|
||||
tabGroupPluginId?: string; // Expected Chrome extension ID for CDP tab-group handshake
|
||||
allowedDomains?: string[];
|
||||
actionPolicy?: string;
|
||||
confirmActions?: string[];
|
||||
|
||||
Reference in New Issue
Block a user