feat(cdp): 默认优先连接 9333 常驻 Chrome

- 无显式连接参数时先尝试 CDP 9333,失败后回退本地浏览器启动

- 修复 CDP 页选择稳定性:过滤 omnibox 系统页、无可用页时自动创建 fallback 页

- 调整页面关闭后的 active 索引维护,降低 No page found 问题

- 新增 agent-browser-stealth 二进制入口并保持与 agent-browser 行为一致

- 同步更新 CLI 帮助、README、技能文档与 docs 说明
This commit is contained in:
leeguooooo
2026-02-24 16:22:49 +09:00
parent ea2e93dbba
commit 893ddfd259
11 changed files with 236 additions and 18 deletions
+66 -6
View File
@@ -69,7 +69,7 @@ describe('BrowserManager', () => {
it('should apply init-script stealth policy for CDP connections', async () => {
const addInitScript = vi.fn().mockResolvedValue(undefined);
const mockPage = { url: () => 'http://example.com', on: vi.fn() };
const mockPage = { url: () => 'http://example.com', on: vi.fn(), isClosed: () => false };
const mockContext = {
pages: () => [mockPage],
on: vi.fn(),
@@ -99,7 +99,7 @@ describe('BrowserManager', () => {
it('should disable stealth capabilities when launch stealth is false in CDP mode', async () => {
const addInitScript = vi.fn().mockResolvedValue(undefined);
const mockPage = { url: () => 'http://example.com', on: vi.fn() };
const mockPage = { url: () => 'http://example.com', on: vi.fn(), isClosed: () => false };
const mockContext = {
pages: () => [mockPage],
on: vi.fn(),
@@ -926,15 +926,16 @@ describe('BrowserManager', () => {
contexts: () => [
{
pages: () => [
{ url: () => 'http://example.com', on: vi.fn() },
{ url: () => '', on: vi.fn() }, // This page should be filtered out
{ url: () => 'http://anothersite.com', on: vi.fn() },
{ url: () => 'http://example.com', on: vi.fn(), isClosed: () => false },
{ url: () => '', on: vi.fn(), isClosed: () => false }, // This page should be filtered out
{ url: () => 'http://anothersite.com', on: vi.fn(), isClosed: () => false },
],
on: vi.fn(),
setDefaultTimeout: vi.fn(),
addInitScript: vi.fn().mockResolvedValue(undefined),
},
],
close: vi.fn(),
close: vi.fn().mockResolvedValue(undefined),
};
const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
@@ -950,6 +951,65 @@ describe('BrowserManager', () => {
expect(urls).toContain('http://example.com');
spy.mockRestore();
});
it('should ignore omnibox popup pages during CDP connection', async () => {
const mockBrowser = {
contexts: () => [
{
pages: () => [
{
url: () => 'chrome://omnibox-popup.top-chrome/',
on: vi.fn(),
isClosed: () => false,
},
{ url: () => 'http://example.com', on: vi.fn(), isClosed: () => false },
],
on: vi.fn(),
setDefaultTimeout: vi.fn(),
addInitScript: vi.fn().mockResolvedValue(undefined),
},
],
close: vi.fn().mockResolvedValue(undefined),
};
const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
const cdpBrowser = new BrowserManager();
await cdpBrowser.launch({ cdpPort: 9222 });
expect(cdpBrowser.getPages().length).toBe(1);
expect(cdpBrowser.getPages()[0]?.url()).toBe('http://example.com');
spy.mockRestore();
});
it('should create a fallback page when CDP has only internal pages', async () => {
const newPage = { url: () => 'about:blank', on: vi.fn(), isClosed: () => false };
const context = {
pages: () => [
{
url: () => 'chrome://omnibox-popup.top-chrome/omnibox_popup_aim.html',
on: vi.fn(),
isClosed: () => false,
},
],
newPage: vi.fn().mockResolvedValue(newPage),
on: vi.fn(),
setDefaultTimeout: vi.fn(),
addInitScript: vi.fn().mockResolvedValue(undefined),
};
const mockBrowser = {
contexts: () => [context],
close: vi.fn().mockResolvedValue(undefined),
};
const spy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
const cdpBrowser = new BrowserManager();
await cdpBrowser.launch({ cdpPort: 9222 });
expect(context.newPage).toHaveBeenCalledTimes(1);
expect(cdpBrowser.getPages().length).toBe(1);
expect(cdpBrowser.getPages()[0]?.url()).toBe('about:blank');
spy.mockRestore();
});
});
describe('screencast', () => {
+82 -6
View File
@@ -125,6 +125,8 @@ interface StealthContextDefaults {
extraHTTPHeaders?: Record<string, string>;
}
const IGNORED_CDP_PAGE_URL_PREFIXES = ['chrome://omnibox-popup.top-chrome/'];
/**
* Manages the Playwright browser lifecycle with multiple tabs/windows
*/
@@ -483,6 +485,33 @@ export class BrowserManager {
return this.pages.length > 0;
}
private getSafePageUrl(page: Page): string {
try {
return page.url();
} catch {
return '';
}
}
private isIgnoredCDPPageUrl(url: string): boolean {
if (!url) return false;
const normalizedUrl = url.toLowerCase();
return IGNORED_CDP_PAGE_URL_PREFIXES.some((prefix) => normalizedUrl.startsWith(prefix));
}
private isUsableCDPPage(page: Page): boolean {
if (page.isClosed()) return false;
const url = this.getSafePageUrl(page);
if (!url) return false;
return !this.isIgnoredCDPPageUrl(url);
}
private collectUsableCDPPages(contexts: BrowserContext[]): Page[] {
return contexts
.flatMap((context) => context.pages())
.filter((page) => this.isUsableCDPPage(page));
}
/**
* Ensure at least one page exists. If the browser is launched but all pages
* were closed (stale session), creates a new page on the existing context.
@@ -527,6 +556,24 @@ export class BrowserManager {
if (this.pages.length === 0) {
throw new Error('Browser not launched. Call launch first.');
}
const current = this.pages[this.activePageIndex];
if (current && this.isUsableCDPPage(current)) {
return current;
}
const usableIndex = this.pages.findIndex((page) => this.isUsableCDPPage(page));
if (usableIndex !== -1) {
this.activePageIndex = usableIndex;
return this.pages[this.activePageIndex];
}
const openIndex = this.pages.findIndex((page) => !page.isClosed());
if (openIndex !== -1) {
this.activePageIndex = openIndex;
return this.pages[this.activePageIndex];
}
return this.pages[this.activePageIndex];
}
@@ -1008,7 +1055,7 @@ export class BrowserManager {
try {
const contexts = this.browser.contexts();
if (contexts.length === 0) return false;
return contexts.some((context) => context.pages().length > 0);
return contexts.some((context) => context.pages().some((page) => this.isUsableCDPPage(page)));
} catch {
return false;
}
@@ -1722,11 +1769,32 @@ export class BrowserManager {
throw new Error('No browser context found. Make sure the app has an open window.');
}
// Filter out pages with empty URLs, which can cause Playwright to hang
const allPages = contexts.flatMap((context) => context.pages()).filter((page) => page.url());
let allPages = this.collectUsableCDPPages(contexts);
if (allPages.length === 0) {
throw new Error('No page found. Make sure the app has loaded content.');
// Some Chrome instances (especially with custom UI pages) expose only internal/transient
// pages over CDP. Create a fresh page so commands always have a stable target.
let fallbackPage: Page | null = null;
for (const context of contexts) {
try {
const page = await context.newPage();
if (!fallbackPage) {
fallbackPage = page;
}
if (this.isUsableCDPPage(page)) {
fallbackPage = page;
break;
}
} catch {
// Try next context
}
}
if (!fallbackPage) {
throw new Error('No page found. Make sure the app has loaded content.');
}
allPages = [fallbackPage];
}
// All validation passed - commit state
@@ -1831,7 +1899,7 @@ export class BrowserManager {
* Discovery strategy:
* 1. Read DevToolsActivePort from Chrome's default user data directories
* 2. If found, connect using the port and WebSocket path from that file
* 3. If not found, probe common debugging ports (9222, 9229)
* 3. If not found, probe common debugging ports (9222, 9229, 9333)
* 4. If a port responds, connect via CDP
*/
private async autoConnectViaCDP(): Promise<void> {
@@ -1866,7 +1934,7 @@ export class BrowserManager {
}
// Strategy 2: Probe common debugging ports
const commonPorts = [9222, 9229];
const commonPorts = [9222, 9229, 9333];
for (const port of commonPorts) {
const wsUrl = await this.probeDebugPort(port);
if (wsUrl) {
@@ -1922,6 +1990,9 @@ export class BrowserManager {
const index = this.pages.indexOf(page);
if (index !== -1) {
this.pages.splice(index, 1);
if (index < this.activePageIndex) {
this.activePageIndex--;
}
if (this.activePageIndex >= this.pages.length) {
this.activePageIndex = Math.max(0, this.pages.length - 1);
}
@@ -1935,6 +2006,11 @@ export class BrowserManager {
*/
private setupContextTracking(context: BrowserContext): void {
context.on('page', (page) => {
const pageUrl = this.getSafePageUrl(page);
if (this.isIgnoredCDPPageUrl(pageUrl)) {
return;
}
// Only add if not already tracked (avoids duplicates when newTab() creates pages)
if (!this.pages.includes(page)) {
this.pages.push(page);
+38 -4
View File
@@ -405,7 +405,9 @@ export async function startDaemon(options?: {
continue;
}
// Auto-launch if not already launched and this isn't a launch/close/state_load command
// Auto-launch if not already launched and this isn't a launch/close/state_load command.
// Default behavior for this fork: first try attaching to a resident Chrome on CDP :9333,
// then fall back to launching a local Playwright browser if CDP is unavailable.
if (
!manager.isLaunched() &&
parseResult.command.action !== 'launch' &&
@@ -452,13 +454,13 @@ export async function startDaemon(options?: {
const allowFileAccess = process.env.AGENT_BROWSER_ALLOW_FILE_ACCESS === '1';
// Stealth is always enabled in agent-browser-stealth
const colorSchemeEnv = process.env.AGENT_BROWSER_COLOR_SCHEME;
const colorScheme =
const colorScheme: 'dark' | 'light' | 'no-preference' | undefined =
colorSchemeEnv === 'dark' ||
colorSchemeEnv === 'light' ||
colorSchemeEnv === 'no-preference'
? colorSchemeEnv
: undefined;
await manager.launch({
const launchOptions = {
id: 'auto',
action: 'launch' as const,
headless: process.env.AGENT_BROWSER_HEADED !== '1',
@@ -474,7 +476,39 @@ export async function startDaemon(options?: {
colorScheme,
autoStateFilePath: getSessionAutoStatePath(),
});
};
let launchedViaDefaultCdp = false;
try {
// Keep default CDP attempt minimal. Launch-only options like profile/extensions
// are incompatible with CDP and can cause a false-negative fallback.
const cdpLaunchOptions = {
id: launchOptions.id,
action: launchOptions.action,
cdpPort: 9333,
ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors,
colorScheme: launchOptions.colorScheme,
userAgent: launchOptions.userAgent,
};
await manager.launch({
...cdpLaunchOptions,
});
launchedViaDefaultCdp = 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, falling back to local launch: ${message}`
);
}
}
if (!launchedViaDefaultCdp) {
await manager.launch(launchOptions);
}
}
}