Update readme with stealth FAQ
This commit is contained in:
@@ -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
@@ -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
@@ -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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user