Update readme with stealth FAQ

This commit is contained in:
leeguooooo
2026-03-02 18:32:28 +09:00
parent 907ca8c808
commit 44c0361fcd
15 changed files with 548 additions and 66 deletions
+134
View File
@@ -54,6 +54,56 @@ describe('BrowserManager', () => {
await newBrowser.close();
});
it('should switch from local session when auto-connect is explicitly requested', async () => {
const testBrowser = new BrowserManager();
await testBrowser.launch({ id: 'test', action: 'launch', headless: true });
const closeSpy = vi.spyOn(testBrowser, 'close');
const autoConnectSpy = vi
.spyOn(testBrowser as any, 'autoConnectViaCDP')
.mockResolvedValue(undefined);
await testBrowser.launch({ id: 'test', action: 'launch', autoConnect: true });
expect(closeSpy).toHaveBeenCalledTimes(1);
expect(autoConnectSpy).toHaveBeenCalledTimes(1);
autoConnectSpy.mockRestore();
closeSpy.mockRestore();
await testBrowser.close();
});
it('should not relaunch when already connected via healthy CDP and auto-connect is requested', async () => {
const addInitScript = vi.fn().mockResolvedValue(undefined);
const mockPage = { url: () => 'http://example.com', on: vi.fn(), isClosed: () => false };
const mockContext = {
pages: () => [mockPage],
on: vi.fn(),
setDefaultTimeout: vi.fn(),
addInitScript,
};
const mockBrowser = {
contexts: () => [mockContext],
close: vi.fn().mockResolvedValue(undefined),
isConnected: vi.fn(() => true),
};
const connectSpy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
const cdpBrowser = new BrowserManager();
await cdpBrowser.launch({ id: 'test', action: 'launch', cdpPort: 9222 });
expect(connectSpy).toHaveBeenCalledTimes(1);
const closeSpy = vi.spyOn(cdpBrowser, 'close');
await cdpBrowser.launch({ id: 'test', action: 'launch', autoConnect: true });
expect(closeSpy).not.toHaveBeenCalled();
expect(connectSpy).toHaveBeenCalledTimes(1);
closeSpy.mockRestore();
await cdpBrowser.close();
connectSpy.mockRestore();
});
it('should report local stealth policy capabilities', async () => {
const testBrowser = new BrowserManager();
await testBrowser.launch({ headless: true });
@@ -97,6 +147,90 @@ describe('BrowserManager', () => {
spy.mockRestore();
});
it('should reject CDP endpoints with only blank pages when meaningful tabs are required', async () => {
const mockPage = { url: () => 'about:blank', on: vi.fn(), isClosed: () => false };
const mockContext = {
pages: () => [mockPage],
on: vi.fn(),
setDefaultTimeout: vi.fn(),
addInitScript: vi.fn().mockResolvedValue(undefined),
};
const mockBrowser = {
contexts: () => [mockContext],
close: vi.fn().mockResolvedValue(undefined),
isConnected: vi.fn(() => true),
};
const connectSpy = vi.spyOn(chromium, 'connectOverCDP').mockResolvedValue(mockBrowser as any);
const cdpBrowser = new BrowserManager();
await expect(
(cdpBrowser as any).connectViaCDP('9222', {
allowCreatePageFallback: false,
requireMeaningfulPage: true,
})
).rejects.toThrow('No existing user tabs found on this CDP endpoint.');
expect(mockBrowser.close).toHaveBeenCalledTimes(1);
connectSpy.mockRestore();
});
it('should skip auto-connect candidates without user tabs and continue discovery', async () => {
const cdpBrowser = new BrowserManager();
const dirsSpy = vi
.spyOn(cdpBrowser as any, 'getChromeUserDataDirs')
.mockReturnValue(['/tmp/chrome-a', '/tmp/chrome-b']);
const activePortSpy = vi.spyOn(cdpBrowser as any, 'readDevToolsActivePort');
activePortSpy
.mockReturnValueOnce({ port: 9222, wsPath: '/devtools/browser/a' })
.mockReturnValueOnce({ port: 9333, wsPath: '/devtools/browser/b' });
const probeSpy = vi.spyOn(cdpBrowser as any, 'probeDebugPort');
probeSpy
.mockResolvedValueOnce('ws://127.0.0.1:9222/devtools/browser/a')
.mockResolvedValueOnce('ws://127.0.0.1:9333/devtools/browser/b');
const connectViaCDPSpy = vi.spyOn(cdpBrowser as any, 'connectViaCDP');
connectViaCDPSpy
.mockRejectedValueOnce(new Error('No existing user tabs found on this CDP endpoint.'))
.mockResolvedValueOnce(undefined);
await (cdpBrowser as any).autoConnectViaCDP();
expect(connectViaCDPSpy).toHaveBeenCalledTimes(2);
expect(connectViaCDPSpy.mock.calls[0][1]).toMatchObject({
allowCreatePageFallback: false,
requireMeaningfulPage: true,
});
expect(connectViaCDPSpy.mock.calls[1][1]).toMatchObject({
allowCreatePageFallback: false,
requireMeaningfulPage: true,
});
dirsSpy.mockRestore();
activePortSpy.mockRestore();
probeSpy.mockRestore();
connectViaCDPSpy.mockRestore();
});
it('should prefer port 9333 before DevToolsActivePort discovery in auto-connect', async () => {
const cdpBrowser = new BrowserManager();
const probeSpy = vi.spyOn(cdpBrowser as any, 'probeDebugPort');
probeSpy.mockResolvedValueOnce('ws://127.0.0.1:9333/devtools/browser/preferred');
const connectViaCDPSpy = vi
.spyOn(cdpBrowser as any, 'connectViaCDP')
.mockResolvedValue(undefined);
const dirsSpy = vi.spyOn(cdpBrowser as any, 'getChromeUserDataDirs');
await (cdpBrowser as any).autoConnectViaCDP();
expect(probeSpy).toHaveBeenCalledWith(9333);
expect(connectViaCDPSpy).toHaveBeenCalledTimes(1);
expect(connectViaCDPSpy.mock.calls[0][0]).toContain('9333');
expect(dirsSpy).not.toHaveBeenCalled();
probeSpy.mockRestore();
connectViaCDPSpy.mockRestore();
dirsSpy.mockRestore();
});
it('should ignore legacy stealth=false and keep CDP stealth capabilities enabled', async () => {
const addInitScript = vi.fn().mockResolvedValue(undefined);
const mockPage = { url: () => 'http://example.com', on: vi.fn(), isClosed: () => false };
+124 -8
View File
@@ -671,6 +671,16 @@ export class BrowserManager {
return !this.isIgnoredCDPPageUrl(url);
}
private isMeaningfulCDPPage(page: Page): boolean {
if (page.isClosed()) return false;
const url = this.getSafePageUrl(page).trim().toLowerCase();
if (!url) return false;
if (url === 'about:blank' || url.startsWith('about:blank#')) return false;
if (url === 'chrome://newtab/' || url.startsWith('chrome://newtab')) return false;
if (url === 'chrome://new-tab-page/' || url.startsWith('chrome://new-tab-page')) return false;
return !this.isIgnoredCDPPageUrl(url);
}
private collectUsableCDPPages(contexts: BrowserContext[]): Page[] {
return contexts
.flatMap((context) => context.pages())
@@ -1594,7 +1604,13 @@ export class BrowserManager {
}
if (this.isLaunched()) {
// Explicit --auto-connect should switch away from managed/local/provider sessions
// so commands always target a discovered user browser.
const shouldSwitchToAutoConnect =
!!options.autoConnect &&
(this.cdpEndpoint === null || this.stealthConnectionKind !== 'cdp');
const needsRelaunch =
shouldSwitchToAutoConnect ||
(!cdpEndpoint && !options.autoConnect && this.cdpEndpoint !== null) ||
(!!cdpEndpoint && this.needsCdpReconnect(cdpEndpoint)) ||
(!!options.autoConnect && !this.isCdpConnectionAlive());
@@ -1923,7 +1939,11 @@ export class BrowserManager {
*/
private async connectViaCDP(
cdpEndpoint: string | undefined,
options?: { timeout?: number }
options?: {
timeout?: number;
allowCreatePageFallback?: boolean;
requireMeaningfulPage?: boolean;
}
): Promise<void> {
this.stealthConnectionKind = 'cdp';
if (!cdpEndpoint) {
@@ -1969,8 +1989,12 @@ export class BrowserManager {
}
let allPages = this.collectUsableCDPPages(contexts);
const allowCreatePageFallback = options?.allowCreatePageFallback ?? true;
if (allPages.length === 0) {
if (!allowCreatePageFallback) {
throw new Error('No existing user tabs found on this CDP endpoint.');
}
// 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;
@@ -1996,6 +2020,14 @@ export class BrowserManager {
allPages = [fallbackPage];
}
if (options?.requireMeaningfulPage) {
const meaningfulPages = allPages.filter((page) => this.isMeaningfulCDPPage(page));
if (meaningfulPages.length === 0) {
throw new Error('No existing user tabs found on this CDP endpoint.');
}
allPages = meaningfulPages;
}
// All validation passed - commit state
this.browser = browser;
this.cdpEndpoint = cdpEndpoint;
@@ -2105,6 +2137,35 @@ export class BrowserManager {
* 4. If a port responds, connect via CDP
*/
private async autoConnectViaCDP(): Promise<void> {
let sawEndpointWithoutUserTabs = false;
// Strategy 0: Prefer project-default resident CDP port first.
// This keeps user + agent on the same browser session when 9333 is available.
{
const wsUrl = await this.probeDebugPort(9333);
if (wsUrl) {
try {
await this.connectViaCDP(wsUrl, {
allowCreatePageFallback: false,
requireMeaningfulPage: true,
});
return;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('No existing user tabs found on this CDP endpoint')) {
sawEndpointWithoutUserTabs = true;
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(
`[DEBUG] Skipping preferred CDP endpoint without user tabs (${wsUrl}): ${message}`
);
}
} else if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(`[DEBUG] Failed preferred CDP candidate (${wsUrl}): ${message}`);
}
}
}
}
// Strategy 1: Check DevToolsActivePort files
const userDataDirs = this.getChromeUserDataDirs();
for (const dir of userDataDirs) {
@@ -2113,8 +2174,25 @@ export class BrowserManager {
// Try HTTP discovery first (works with --remote-debugging-port mode)
const wsUrl = await this.probeDebugPort(activePort.port);
if (wsUrl) {
await this.connectViaCDP(wsUrl);
return;
try {
await this.connectViaCDP(wsUrl, {
allowCreatePageFallback: false,
requireMeaningfulPage: true,
});
return;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('No existing user tabs found on this CDP endpoint')) {
sawEndpointWithoutUserTabs = true;
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(
`[DEBUG] Skipping CDP endpoint without user tabs (${wsUrl}): ${message}`
);
}
} else if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(`[DEBUG] Failed CDP candidate (${wsUrl}): ${message}`);
}
}
}
// HTTP probe failed -- Chrome M144+ chrome://inspect remote debugging uses a
// WebSocket-only server with no HTTP endpoints. Connect using the WebSocket
@@ -2127,24 +2205,62 @@ export class BrowserManager {
`attempting direct WebSocket connection to ${directWsUrl}`
);
}
await this.connectViaCDP(directWsUrl, { timeout: 60_000 });
await this.connectViaCDP(directWsUrl, {
timeout: 60_000,
allowCreatePageFallback: false,
requireMeaningfulPage: true,
});
return;
} catch {
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('No existing user tabs found on this CDP endpoint')) {
sawEndpointWithoutUserTabs = true;
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(
`[DEBUG] Skipping CDP endpoint without user tabs (${directWsUrl}): ${message}`
);
}
} else if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(`[DEBUG] Failed CDP candidate (${directWsUrl}): ${message}`);
}
// Direct WebSocket also failed, try next directory
}
}
}
// Strategy 2: Probe common debugging ports
const commonPorts = [9222, 9229, 9333];
const commonPorts = [9222, 9229];
for (const port of commonPorts) {
const wsUrl = await this.probeDebugPort(port);
if (wsUrl) {
await this.connectViaCDP(wsUrl);
return;
try {
await this.connectViaCDP(wsUrl, {
allowCreatePageFallback: false,
requireMeaningfulPage: true,
});
return;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('No existing user tabs found on this CDP endpoint')) {
sawEndpointWithoutUserTabs = true;
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(
`[DEBUG] Skipping CDP endpoint without user tabs (${wsUrl}): ${message}`
);
}
} else if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error(`[DEBUG] Failed CDP candidate (${wsUrl}): ${message}`);
}
}
}
}
if (sawEndpointWithoutUserTabs) {
throw new Error(
'Found CDP endpoints, but none exposed existing user tabs. Ensure you are attaching to the same Chrome instance/profile you are using manually.'
);
}
// Nothing found
const platform = os.platform();
let hint: string;
+31 -8
View File
@@ -406,8 +406,7 @@ export async function startDaemon(options?: {
}
// 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.
// Default behavior for this fork: attach to an existing browser only.
if (
!manager.isLaunched() &&
parseResult.command.action !== 'launch' &&
@@ -477,10 +476,10 @@ export async function startDaemon(options?: {
autoStateFilePath: getSessionAutoStatePath(),
};
let launchedViaDefaultCdp = false;
let attachedToExistingBrowser = false;
try {
// Keep default CDP attempt minimal. Launch-only options like extensions
// are incompatible with CDP and can cause a false-negative fallback.
// are incompatible with CDP and can cause false-negative attach failures.
const cdpLaunchOptions = {
id: launchOptions.id,
action: launchOptions.action,
@@ -492,7 +491,7 @@ export async function startDaemon(options?: {
await manager.launch({
...cdpLaunchOptions,
});
launchedViaDefaultCdp = true;
attachedToExistingBrowser = true;
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error('[DEBUG] Auto-launch connected via default CDP port 9333');
}
@@ -500,13 +499,37 @@ export async function startDaemon(options?: {
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}`
`[DEBUG] Default CDP port 9333 unavailable, trying auto-connect discovery: ${message}`
);
}
}
if (!launchedViaDefaultCdp) {
await manager.launch(launchOptions);
if (!attachedToExistingBrowser) {
try {
await manager.launch({
id: launchOptions.id,
action: launchOptions.action,
autoConnect: true,
ignoreHTTPSErrors: launchOptions.ignoreHTTPSErrors,
colorScheme: launchOptions.colorScheme,
userAgent: launchOptions.userAgent,
});
attachedToExistingBrowser = true;
if (process.env.AGENT_BROWSER_DEBUG === '1') {
console.error('[DEBUG] Auto-launch connected via auto-connect discovery');
}
} catch (error) {
if (process.env.AGENT_BROWSER_DEBUG === '1') {
const message = error instanceof Error ? error.message : String(error);
console.error(`[DEBUG] Auto-connect discovery failed: ${message}`);
}
}
}
if (!attachedToExistingBrowser) {
throw new Error(
'Project policy requires using your existing browser. Could not connect to CDP at localhost:9333 and auto-discovery also failed.'
);
}
}
}