diff --git a/CHANGELOG.md b/CHANGELOG.md index 295d0c47..ffca2b30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### Changed +- Installed Google Chrome now uses a Webcmd-owned explicit nonzero loopback CDP port instead of Playwright's debugging pipe. Normal headed Chrome therefore retains its native `navigator.webdriver === false` value; Cloak, SLAB, and custom executables are unchanged. - `WEBCMD_BROWSER_BINARY_PATH` can select a compatible Chromium executable for local browser Sessions; it takes precedence over the existing `CLOAKBROWSER_BINARY_PATH` override and isolates each browser build's profile data from managed Cloak profiles. - Hosted help and completion advertise Cloud-owned core commands only when the authenticated manifest advertises them. - Hosted command lists retain excluded commands as `LOCAL` rows and return a local-only error instead of plugin-install guidance. diff --git a/docs/cli-reference.mdx b/docs/cli-reference.mdx index 80e8091d..8390734f 100644 --- a/docs/cli-reference.mdx +++ b/docs/cli-reference.mdx @@ -70,7 +70,7 @@ webcmd --profile work \ webcmd --profile work session close work-project-k7 ``` -Local browser commands use the browser selected by `webcmd setup --mode local --browser ...`. Cloak stays bundled and default, `--browser chrome` reuses an installed Google Chrome, `--browser slab` is the macOS alpha opt-in, and an absolute path selects a compatible local Chromium fork. If Google Chrome is unavailable, setup links to the official installer and leaves the existing selection unchanged. Hosted browser commands use Webcmd Cloud and Browser Use; `web fetch` still runs locally. +Local browser commands use the browser selected by `webcmd setup --mode local --browser ...`. Cloak stays bundled and default, `--browser chrome` reuses an installed Google Chrome through a Webcmd-owned nonzero loopback CDP endpoint, `--browser slab` is the macOS alpha opt-in, and an absolute path selects a compatible local Chromium fork. The Chrome transport preserves the browser's native `navigator.webdriver` value in a normal headed launch; it is not a claim that Chrome is generally undetectable. If Google Chrome is unavailable, setup links to the official installer and leaves the existing selection unchanged. Hosted browser commands use Webcmd Cloud and Browser Use; `web fetch` still runs locally. ## Browser Programs diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx index b1c2afa0..525f422b 100644 --- a/docs/troubleshooting.mdx +++ b/docs/troubleshooting.mdx @@ -147,6 +147,10 @@ Useful environment variables: `webcmd setup --mode local --browser chrome` uses an existing normal Google Chrome installation and keeps its profiles under `~/.webcmd/chrome/profiles`. +Webcmd launches that Chrome through an explicit nonzero CDP port bound to +loopback. In a normal headed launch this avoids the native WebDriver signal +caused by Playwright's debugging pipe; it does not remove every possible +automation fingerprint. Interactive setup labels Chrome as `installed` or `install required`. Webcmd does not install Chrome automatically; when it is missing and selected, setup links to `https://www.google.com/chrome/` and preserves the current browser diff --git a/src/browser/runtime/local-cloak/chrome-launch.test.ts b/src/browser/runtime/local-cloak/chrome-launch.test.ts new file mode 100644 index 00000000..94d3f6c2 --- /dev/null +++ b/src/browser/runtime/local-cloak/chrome-launch.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Browser, BrowserContext } from 'playwright-core'; +import { + chromeLaunchArgs, + launchChromePersistentContext, + type ChromeLaunchDependencies, +} from './chrome-launch.js'; + +function runtime() { + const context = { close: vi.fn() } as unknown as BrowserContext; + const browser = { + contexts: vi.fn(() => [context]), + close: vi.fn().mockResolvedValue(undefined), + } as unknown as Browser; + return { browser, context }; +} + +function dependencies(browser: Browser): ChromeLaunchDependencies { + let now = 0; + return { + buildLaunchOptions: vi.fn().mockResolvedValue({ + executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + args: ['--fingerprint=123', '--enable-automation', '--remote-debugging-pipe'], + }), + humanizeBrowser: vi.fn().mockResolvedValue(undefined), + allocatePort: vi.fn().mockResolvedValue(43123), + launch: vi.fn().mockResolvedValue(undefined), + findProcesses: vi.fn().mockImplementation(async identity => identity.port === undefined ? [] : [987]), + listenerOwnedBy: vi.fn().mockResolvedValue(true), + endpointReady: vi.fn().mockResolvedValue(true), + connectOverCDP: vi.fn().mockResolvedValue(browser), + terminate: vi.fn().mockResolvedValue(undefined), + activate: vi.fn().mockResolvedValue(undefined), + delay: vi.fn().mockImplementation(async (ms: number) => { now += ms; }), + now: vi.fn(() => now), + platform: 'darwin', + }; +} + +describe('chromeLaunchArgs', () => { + it('uses an explicit nonzero loopback port without automation transports', () => { + const args = chromeLaunchArgs([ + '--fingerprint=123', '--enable-automation', '--remote-debugging-pipe', '--remote-debugging-port=0', '--headless', + '--remote-debugging-address=0.0.0.0', '--headless=old', + ], '/profiles/work', 43123); + expect(args).toContain('--remote-debugging-address=127.0.0.1'); + expect(args).toContain('--remote-debugging-port=43123'); + expect(args).toContain('--user-data-dir=/profiles/work'); + expect(args).not.toContain('--remote-debugging-port=0'); + expect(args).not.toContain('--remote-debugging-pipe'); + expect(args).not.toContain('--enable-automation'); + expect(args).not.toContain('--headless'); + expect(args).not.toContain('--headless=old'); + expect(args).not.toContain('--remote-debugging-address=0.0.0.0'); + }); +}); + +describe('launchChromePersistentContext', () => { + it('verifies listener ownership before attaching and cleans up on close', async () => { + const { browser, context } = runtime(); + const deps = dependencies(browser); + const result = await launchChromePersistentContext({ userDataDir: '/profiles/work', headless: false }, deps); + + expect(deps.launch).toHaveBeenCalledWith( + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + expect.arrayContaining(['--remote-debugging-port=43123', '--user-data-dir=/profiles/work']), + 'darwin', + ); + expect(deps.listenerOwnedBy).toHaveBeenCalledWith(43123, 987, 'darwin'); + expect(deps.connectOverCDP).toHaveBeenCalledWith('http://127.0.0.1:43123'); + expect(result).toBe(context); + + await result.close(); + expect(browser.close).toHaveBeenCalledOnce(); + expect(deps.terminate).toHaveBeenCalledWith(987, 'darwin', false); + }); + + it('fails closed and retries when a valid endpoint belongs to another process', async () => { + const { browser } = runtime(); + const deps = dependencies(browser); + vi.mocked(deps.allocatePort).mockResolvedValueOnce(43123).mockResolvedValueOnce(43124).mockResolvedValueOnce(43125); + vi.mocked(deps.listenerOwnedBy).mockResolvedValue(false); + + await expect(launchChromePersistentContext({ userDataDir: '/profiles/work', headless: false }, deps)) + .rejects.toThrow('Failed to launch Webcmd-managed Chrome after 3 attempts'); + expect(deps.connectOverCDP).not.toHaveBeenCalled(); + expect(deps.launch).toHaveBeenCalledTimes(3); + }); + + it('reports a locked Webcmd Chrome Profile before launching another process', async () => { + const { browser } = runtime(); + const deps = dependencies(browser); + vi.mocked(deps.findProcesses).mockResolvedValue([444]); + + await expect(launchChromePersistentContext({ userDataDir: '/profiles/work', headless: false }, deps)) + .rejects.toThrow('Opening in existing browser session'); + expect(deps.launch).not.toHaveBeenCalled(); + }); + + it('uses the directly launched pid when a platform wrapper changes the process command', async () => { + const { browser, context } = runtime(); + const deps = dependencies(browser); + vi.mocked(deps.launch).mockResolvedValue(987); + vi.mocked(deps.findProcesses).mockResolvedValue([]); + + const result = await launchChromePersistentContext({ userDataDir: '/profiles/work', headless: false }, deps); + + expect(result).toBe(context); + expect(deps.listenerOwnedBy).toHaveBeenCalledWith(43123, 987, 'darwin'); + await result.close(); + expect(deps.terminate).toHaveBeenCalledWith(987, 'darwin', false); + }); +}); diff --git a/src/browser/runtime/local-cloak/chrome-launch.ts b/src/browser/runtime/local-cloak/chrome-launch.ts new file mode 100644 index 00000000..c7219ab0 --- /dev/null +++ b/src/browser/runtime/local-cloak/chrome-launch.ts @@ -0,0 +1,195 @@ +import { execFile, spawn } from 'node:child_process'; +import { createServer } from 'node:net'; +import path from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { promisify } from 'node:util'; +import { buildLaunchOptions, humanizeBrowser } from 'cloakbrowser'; +import type { LaunchPersistentContextOptions } from 'cloakbrowser'; +import { chromium } from 'playwright-core'; +import type { Browser, BrowserContext } from 'playwright-core'; +import { + findExactChromeProcesses, + listenerBelongsToProcess, + terminateChromeProcessTree, + type ChromeProcessIdentity, +} from './chrome-process.js'; + +const execFileAsync = promisify(execFile); +const MAX_LAUNCH_ATTEMPTS = 3; +const READINESS_TIMEOUT_MS = 10_000; + +export interface ChromeLaunchDependencies { + buildLaunchOptions: typeof buildLaunchOptions; + humanizeBrowser: typeof humanizeBrowser; + allocatePort(): Promise; + launch(executablePath: string, args: string[], platform: NodeJS.Platform): Promise; + findProcesses(identity: ChromeProcessIdentity, platform: NodeJS.Platform): Promise; + listenerOwnedBy(port: number, pid: number, platform: NodeJS.Platform): Promise; + endpointReady(endpoint: string): Promise; + connectOverCDP(endpoint: string): Promise; + terminate(pid: number, platform: NodeJS.Platform, force?: boolean): Promise; + activate(executablePath: string, platform: NodeJS.Platform): Promise; + delay(ms: number): Promise; + now(): number; + platform: NodeJS.Platform; +} + +const chromeContextActivators = new WeakMap Promise>(); + +export async function activateChromeContext(context: BrowserContext): Promise { + await chromeContextActivators.get(context)?.(); +} + +export async function allocateNonzeroLoopbackPort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + if (!Number.isInteger(port) || port <= 0) throw new Error('Failed to allocate a nonzero Chrome CDP port'); + return port; +} + +export function chromeLaunchArgs(baseArgs: readonly string[], userDataDir: string, port: number): string[] { + const filtered = baseArgs.filter(arg => arg !== '--enable-automation' + && !arg.startsWith('--headless') + && arg !== '--remote-debugging-pipe' + && !arg.startsWith('--remote-debugging-port=') + && !arg.startsWith('--remote-debugging-address=') + && !arg.startsWith('--user-data-dir=')); + return [ + ...filtered, + `--user-data-dir=${userDataDir}`, + '--remote-debugging-address=127.0.0.1', + `--remote-debugging-port=${port}`, + 'about:blank', + ]; +} + +async function launchProcess(executablePath: string, args: string[], platform: NodeJS.Platform): Promise { + if (platform === 'darwin') { + const marker = `${path.sep}Contents${path.sep}MacOS${path.sep}`; + const index = executablePath.lastIndexOf(marker); + if (index < 0) throw new Error(`Configured Chrome executable is not inside a macOS app bundle: ${executablePath}`); + await execFileAsync('/usr/bin/open', ['-g', '-n', executablePath.slice(0, index), '--args', ...args]); + return undefined; + } + const child = spawn(executablePath, args, { detached: false, stdio: 'ignore', windowsHide: true }); + await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('spawn', resolve); + }); + const pid = child.pid; + child.unref(); + return pid; +} + +async function endpointReady(endpoint: string): Promise { + try { + const response = await fetch(`${endpoint}/json/version`, { signal: AbortSignal.timeout(500) }); + if (!response.ok) return false; + const body = await response.json() as { webSocketDebuggerUrl?: unknown }; + return typeof body.webSocketDebuggerUrl === 'string'; + } catch { + return false; + } +} + +async function activate(executablePath: string, platform: NodeJS.Platform): Promise { + if (platform !== 'darwin') return; + const marker = `${path.sep}Contents${path.sep}MacOS${path.sep}`; + const index = executablePath.lastIndexOf(marker); + if (index >= 0) await execFileAsync('/usr/bin/open', [executablePath.slice(0, index)]); +} + +const defaultDependencies: ChromeLaunchDependencies = { + buildLaunchOptions, + humanizeBrowser, + allocatePort: allocateNonzeroLoopbackPort, + launch: launchProcess, + findProcesses: findExactChromeProcesses, + listenerOwnedBy: listenerBelongsToProcess, + endpointReady, + connectOverCDP: endpoint => chromium.connectOverCDP(endpoint), + terminate: terminateChromeProcessTree, + activate, + delay: ms => delay(ms), + now: Date.now, + platform: process.platform, +}; + +export async function launchChromePersistentContext( + options: LaunchPersistentContextOptions, + deps: ChromeLaunchDependencies = defaultDependencies, +): Promise { + const launchOptions = await deps.buildLaunchOptions(options); + const executablePath = launchOptions.executablePath; + if (!executablePath) throw new Error('Configured Chrome executable path is missing'); + if ((await deps.findProcesses({ executablePath, userDataDir: options.userDataDir }, deps.platform)).length > 0) { + throw new Error('Opening in existing browser session. The Webcmd Chrome Profile is already in use.'); + } + + let lastError: unknown; + for (let attempt = 1; attempt <= MAX_LAUNCH_ATTEMPTS; attempt += 1) { + const port = await deps.allocatePort(); + const identity = { executablePath, userDataDir: options.userDataDir, port }; + let browser: Browser | undefined; + let pids: number[] = []; + let launchedPid: number | undefined; + try { + const args = chromeLaunchArgs(launchOptions.args ?? [], options.userDataDir, port); + launchedPid = await deps.launch(executablePath, args, deps.platform); + const endpoint = `http://127.0.0.1:${port}`; + const deadline = deps.now() + READINESS_TIMEOUT_MS; + while (deps.now() < deadline) { + pids = await deps.findProcesses(identity, deps.platform); + if (launchedPid && !pids.includes(launchedPid)) pids.push(launchedPid); + if (pids.length === 1 + && await deps.endpointReady(endpoint) + && await deps.listenerOwnedBy(port, pids[0], deps.platform)) break; + await deps.delay(50); + } + if (pids.length !== 1 + || !await deps.endpointReady(endpoint) + || !await deps.listenerOwnedBy(port, pids[0], deps.platform)) { + throw new Error('Timed out verifying the Webcmd-owned Chrome CDP endpoint'); + } + + browser = await deps.connectOverCDP(endpoint); + await deps.humanizeBrowser(browser, options); + const context = browser.contexts()[0]; + if (!context) throw new Error('Chrome did not expose a persistent default context'); + chromeContextActivators.set(context, () => deps.activate(executablePath, deps.platform)); + context.close = async () => { + try { + await browser!.close(); + } finally { + chromeContextActivators.delete(context); + await terminateOwnedProcesses(deps, identity, pids); + } + }; + return context; + } catch (error) { + lastError = error; + await browser?.close().catch(() => {}); + await terminateOwnedProcesses(deps, identity, launchedPid ? [...pids, launchedPid] : pids); + } + } + throw new Error(`Failed to launch Webcmd-managed Chrome after ${MAX_LAUNCH_ATTEMPTS} attempts`, { cause: lastError }); +} + +async function terminateOwnedProcesses( + deps: ChromeLaunchDependencies, + identity: ChromeProcessIdentity, + knownPids: number[], +): Promise { + const pids = [...new Set([...knownPids, ...await deps.findProcesses(identity, deps.platform)])]; + for (const pid of pids) await deps.terminate(pid, deps.platform, false); + if (pids.length === 0) return; + await deps.delay(250); + const survivors = await deps.findProcesses(identity, deps.platform); + for (const pid of survivors) await deps.terminate(pid, deps.platform, true); +} diff --git a/src/browser/runtime/local-cloak/chrome-process.test.ts b/src/browser/runtime/local-cloak/chrome-process.test.ts new file mode 100644 index 00000000..ffeaeef0 --- /dev/null +++ b/src/browser/runtime/local-cloak/chrome-process.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { matchChromeProcessCommand } from './chrome-process.js'; + +describe('matchChromeProcessCommand', () => { + const identity = { + executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + userDataDir: '/profiles/work profile', + port: 43123, + }; + + it('requires the configured executable, exact profile, and exact port', () => { + const command = `${identity.executablePath} --user-data-dir=${identity.userDataDir} --remote-debugging-port=43123 about:blank`; + expect(matchChromeProcessCommand(command, identity)).toBe(true); + expect(matchChromeProcessCommand(command, { ...identity, userDataDir: '/profiles/work' })).toBe(false); + expect(matchChromeProcessCommand(command, { ...identity, port: 43124 })).toBe(false); + expect(matchChromeProcessCommand(command, { ...identity, executablePath: '/Applications/Chromium' })).toBe(false); + }); + + it('never matches by Chrome basename alone', () => { + expect(matchChromeProcessCommand( + 'Google Chrome --user-data-dir=/profiles/work profile --remote-debugging-port=43123', + identity, + )).toBe(false); + }); +}); diff --git a/src/browser/runtime/local-cloak/chrome-process.ts b/src/browser/runtime/local-cloak/chrome-process.ts new file mode 100644 index 00000000..e5c6d10f --- /dev/null +++ b/src/browser/runtime/local-cloak/chrome-process.ts @@ -0,0 +1,186 @@ +import fs from 'node:fs'; +import { execFile } from 'node:child_process'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +export interface ChromeProcessIdentity { + executablePath: string; + userDataDir: string; + port?: number; +} + +function commandStartsWithExecutable(command: string, executablePath: string): boolean { + return command.startsWith(`${executablePath} `) + || command.startsWith(`"${executablePath}" `) + || command.startsWith(`'${executablePath}' `); +} + +export function matchChromeProcessCommand(command: string, identity: ChromeProcessIdentity): boolean { + if (!commandStartsWithExecutable(command.trim(), identity.executablePath)) return false; + if (extractArgumentValue(command, '--user-data-dir') !== identity.userDataDir) return false; + return identity.port === undefined + || extractArgumentValue(command, '--remote-debugging-port') === String(identity.port); +} + +function extractArgumentValue(command: string, name: string): string | undefined { + const marker = ` ${name}=`; + const start = command.indexOf(marker); + if (start < 0) return undefined; + const valueStart = start + marker.length; + const quote = command[valueStart]; + if (quote === '"' || quote === "'") { + const end = command.indexOf(quote, valueStart + 1); + return end < 0 ? undefined : command.slice(valueStart + 1, end); + } + const nextArgument = command.indexOf(' --', valueStart); + const nextUrl = command.indexOf(' about:', valueStart); + const candidates = [nextArgument, nextUrl].filter(index => index >= 0); + const end = candidates.length > 0 ? Math.min(...candidates) : command.length; + return command.slice(valueStart, end).trimEnd(); +} + +export async function findExactChromeProcesses( + identity: ChromeProcessIdentity, + platform: NodeJS.Platform = process.platform, +): Promise { + const canonicalIdentity = { + ...identity, + executablePath: canonicalPath(identity.executablePath), + userDataDir: canonicalPath(identity.userDataDir), + }; + const commands = await processCommands(platform); + const matches = commands.flatMap(({ pid, command }) => { + if (pid === process.pid) return []; + if (matchChromeProcessCommand(command, identity)) return [pid]; + if (matchChromeProcessCommand(command, canonicalIdentity)) return [pid]; + return platform === 'linux' && linuxWrapperProcessMatches(pid, command, canonicalIdentity) ? [pid] : []; + }); + return [...new Set(matches)]; +} + +function linuxWrapperProcessMatches(pid: number, command: string, identity: ChromeProcessIdentity): boolean { + if (extractArgumentValue(command, '--user-data-dir') !== identity.userDataDir) return false; + if (identity.port !== undefined + && extractArgumentValue(command, '--remote-debugging-port') !== String(identity.port)) return false; + let actualExecutable = ''; + try { actualExecutable = fs.realpathSync.native(`/proc/${pid}/exe`); } catch { return false; } + const configuredName = path.basename(identity.executablePath).toLowerCase(); + const actualName = path.basename(actualExecutable).toLowerCase(); + return path.dirname(actualExecutable) === path.dirname(identity.executablePath) + && /^google-chrome(?:-stable)?$/u.test(configuredName) + && actualName === 'chrome'; +} + +export async function listenerBelongsToProcess( + port: number, + pid: number, + platform: NodeJS.Platform = process.platform, +): Promise { + if (!Number.isInteger(port) || port <= 0 || !Number.isInteger(pid) || pid <= 0) return false; + if (platform === 'win32') return windowsListenerBelongsToProcess(port, pid); + if (platform === 'linux') return linuxListenerBelongsToProcess(port, pid); + return lsofListenerBelongsToProcess(port, pid); +} + +export async function terminateChromeProcessTree( + pid: number, + platform: NodeJS.Platform = process.platform, + force = false, +): Promise { + if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) return; + if (platform === 'win32') { + await execFileAsync('taskkill', ['/PID', String(pid), '/T', ...(force ? ['/F'] : [])]).catch(() => {}); + return; + } + try { + process.kill(pid, force ? 'SIGKILL' : 'SIGTERM'); + } catch { + // Already exited or not signalable. Callers decide whether a retry is needed. + } +} + +async function processCommands(platform: NodeJS.Platform): Promise> { + if (platform === 'win32') { + const script = 'Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress'; + const { stdout } = await execFileAsync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { + encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, timeout: 3000, + }).catch(() => ({ stdout: '' })); + try { + const parsed = JSON.parse(String(stdout)) as unknown; + const rows = Array.isArray(parsed) ? parsed : [parsed]; + return rows.flatMap((row) => { + const value = row as { ProcessId?: unknown; CommandLine?: unknown }; + const pid = Number(value.ProcessId); + return Number.isInteger(pid) && typeof value.CommandLine === 'string' + ? [{ pid, command: value.CommandLine }] + : []; + }); + } catch { + return []; + } + } + const { stdout } = await execFileAsync('ps', ['-axo', 'pid=,command='], { + encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, timeout: 3000, + }).catch(() => ({ stdout: '' })); + return String(stdout).split('\n').flatMap((line) => { + const match = line.match(/^\s*(\d+)\s+(.+)$/u); + return match ? [{ pid: Number(match[1]), command: match[2] }] : []; + }); +} + +async function lsofListenerBelongsToProcess(port: number, pid: number): Promise { + const { stdout } = await execFileAsync('lsof', ['-nP', '-a', '-p', String(pid), `-iTCP:${port}`, '-sTCP:LISTEN'], { + encoding: 'utf8', timeout: 3000, + }).catch(() => ({ stdout: '' })); + return String(stdout).split('\n').some(line => new RegExp(`^\\S+\\s+${pid}\\s`, 'u').test(line)); +} + +async function linuxListenerBelongsToProcess(port: number, pid: number): Promise { + const inode = linuxListeningSocketInode(port); + if (!inode) return false; + try { + return fs.readdirSync(`/proc/${pid}/fd`).some((fd) => { + try { + return fs.readlinkSync(`/proc/${pid}/fd/${fd}`) === `socket:[${inode}]`; + } catch { + return false; + } + }); + } catch { + return false; + } +} + +function linuxListeningSocketInode(port: number): string | undefined { + const portHex = port.toString(16).toUpperCase().padStart(4, '0'); + for (const file of ['/proc/net/tcp', '/proc/net/tcp6']) { + let content = ''; + try { content = fs.readFileSync(file, 'utf8'); } catch { continue; } + for (const line of content.split('\n').slice(1)) { + const fields = line.trim().split(/\s+/u); + const local = fields[1]?.split(':'); + if (local?.[1] === portHex && fields[3] === '0A' && fields[9]) return fields[9]; + } + } + return undefined; +} + +async function windowsListenerBelongsToProcess(port: number, pid: number): Promise { + const { stdout } = await execFileAsync('netstat', ['-ano', '-p', 'tcp'], { + encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, timeout: 3000, + }).catch(() => ({ stdout: '' })); + return String(stdout).split(/\r?\n/u).some((line) => { + const fields = line.trim().split(/\s+/u); + return fields.length >= 5 + && fields[0]?.toUpperCase() === 'TCP' + && fields[1]?.endsWith(`:${port}`) === true + && fields[3]?.toUpperCase() === 'LISTENING' + && Number(fields[4]) === pid; + }); +} + +function canonicalPath(input: string): string { + try { return fs.realpathSync.native(input); } catch { return input; } +} diff --git a/src/browser/runtime/local-cloak/provider.ts b/src/browser/runtime/local-cloak/provider.ts index 35ced9c2..d58fcc26 100644 --- a/src/browser/runtime/local-cloak/provider.ts +++ b/src/browser/runtime/local-cloak/provider.ts @@ -7,6 +7,7 @@ import { CloakSessionManager, resolveCloakBrowserVersion, } from './session-manager.js'; +import type { LaunchChromePersistentContext } from './session-manager.js'; export interface LocalCloakRuntimeProviderOptions { baseDir?: string; @@ -15,6 +16,7 @@ export interface LocalCloakRuntimeProviderOptions { runtimeName?: 'cloak' | 'chrome' | 'custom'; launchPersistentContext?: LaunchPersistentContext; launchBackgroundPersistentContext?: LaunchPersistentContext; + launchChromePersistentContext?: LaunchChromePersistentContext; } export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { @@ -31,8 +33,10 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { baseDir: opts.baseDir, profileNamespace: opts.profileNamespace, executablePath: opts.executablePath, + runtimeKind: opts.runtimeName, launchPersistentContext: opts.launchPersistentContext, launchBackgroundPersistentContext: opts.launchBackgroundPersistentContext, + launchChromePersistentContext: opts.launchChromePersistentContext, hasActiveHandoff: profileId => this.sessions.list(profileId, 100).some(session => ( Boolean(session.handoff) && Date.parse(session.handoff!.expiresAt) > Date.now() )), diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index b50b679e..13e5fe21 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -586,6 +586,33 @@ describe('CloakSessionManager', () => { expect(launchPersistentContext).toHaveBeenCalledTimes(normalCalls); }); + it('selects the dedicated launcher only for runtime kind chrome', async () => { + const chromeRuntime = fakeContext(); + const customRuntime = fakeContext(); + const launchChromePersistentContext = vi.fn().mockResolvedValue(chromeRuntime.context); + const launchPersistentContext = vi.fn().mockResolvedValue(customRuntime.context); + const chrome = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + runtimeKind: 'chrome', + executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + launchChromePersistentContext, + launchPersistentContext, + }); + const custom = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + runtimeKind: 'custom', + executablePath: '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser', + launchChromePersistentContext, + launchPersistentContext, + }); + + await chrome.getPage({ profileId: 'chrome', session: 'work', surface: 'browser' }); + await custom.getPage({ profileId: 'custom', session: 'work', surface: 'browser' }); + + expect(launchChromePersistentContext).toHaveBeenCalledOnce(); + expect(launchPersistentContext).toHaveBeenCalledOnce(); + }); + it('reactivates a background-launched context for foreground tab selection', async () => { const launched = fakeContext(); const activateBackgroundContext = vi.fn().mockResolvedValue(undefined); diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index 9271dc64..eec44dff 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -16,6 +16,8 @@ import { log } from '../../../logger.js'; import { CliError, EXIT_CODES } from '../../../errors.js'; import { isClosedContextError } from '../../run/types.js'; import { configureCloakBrowserBinary } from '../../browser-binary.js'; +import { activateChromeContext, launchChromePersistentContext } from './chrome-launch.js'; +import { findExactChromeProcesses, terminateChromeProcessTree } from './chrome-process.js'; const UNRESOLVED = Symbol('unresolved'); const TARGET_PAGE_MATCH_TIMEOUT_MS = 1_000; @@ -45,6 +47,7 @@ export function resolveCloakBrowserVersion(): string | undefined { } export type LaunchPersistentContext = typeof cloakLaunchPersistentContext; +export type LaunchChromePersistentContext = typeof launchChromePersistentContext; export type RecoverLockedProfile = (userDataDir: string) => Promise; export interface SessionKeyInput { @@ -159,8 +162,10 @@ export interface CloakSessionManagerOptions { baseDir?: string; profileNamespace?: string; executablePath?: string; + runtimeKind?: 'cloak' | 'chrome' | 'custom'; launchPersistentContext?: LaunchPersistentContext; launchBackgroundPersistentContext?: LaunchPersistentContext; + launchChromePersistentContext?: LaunchChromePersistentContext; activateBackgroundContext?: typeof activateDarwinBackgroundContext; recoverLockedProfile?: RecoverLockedProfile; platform?: NodeJS.Platform; @@ -200,6 +205,7 @@ export class CloakSessionManager { private readonly launchPersistentContext: LaunchPersistentContext; private readonly launchBackgroundPersistentContext: LaunchPersistentContext; + private readonly launchChromePersistentContext: LaunchChromePersistentContext; private readonly activateBackgroundContext: typeof activateDarwinBackgroundContext; private readonly platform: NodeJS.Platform; private readonly recoverLockedProfile: RecoverLockedProfile; @@ -224,9 +230,14 @@ export class CloakSessionManager { constructor(private readonly opts: CloakSessionManagerOptions = {}) { this.launchPersistentContext = opts.launchPersistentContext ?? cloakLaunchPersistentContext; this.launchBackgroundPersistentContext = opts.launchBackgroundPersistentContext ?? launchDarwinBackgroundPersistentContext; - this.activateBackgroundContext = opts.activateBackgroundContext ?? activateDarwinBackgroundContext; + this.launchChromePersistentContext = opts.launchChromePersistentContext ?? launchChromePersistentContext; + this.activateBackgroundContext = opts.activateBackgroundContext + ?? (opts.runtimeKind === 'chrome' ? activateChromeContext : activateDarwinBackgroundContext); this.platform = opts.platform ?? process.platform; - this.recoverLockedProfile = opts.recoverLockedProfile ?? recoverLockedCloakProfile; + this.recoverLockedProfile = opts.recoverLockedProfile + ?? (opts.runtimeKind === 'chrome' && opts.executablePath + ? userDataDir => recoverLockedChromeProfile(opts.executablePath!, userDataDir, this.platform) + : recoverLockedCloakProfile); this.hasActiveHandoff = opts.hasActiveHandoff ?? (() => false); } @@ -744,9 +755,11 @@ export class CloakSessionManager { // DevToolsActivePort file. Compatible Chromium forks may be app bundles but // not implement that contract, so custom executables use Playwright's // normal persistent launcher instead. - const launchPersistentContext = this.platform === 'darwin' && windowMode === 'background' && !this.opts.executablePath - ? this.launchBackgroundPersistentContext - : this.launchPersistentContext; + const launchPersistentContext = this.opts.runtimeKind === 'chrome' + ? this.launchChromePersistentContext + : this.platform === 'darwin' && windowMode === 'background' && !this.opts.executablePath + ? this.launchBackgroundPersistentContext + : this.launchPersistentContext; let context: BrowserContext; try { context = await launchPersistentContext(launchOptions); @@ -1396,6 +1409,26 @@ async function recoverLockedCloakProfile(userDataDir: string): Promise return waitForProfileProcessesToExit(userDataDir, 1500); } +async function recoverLockedChromeProfile( + executablePath: string, + userDataDir: string, + platform: NodeJS.Platform, +): Promise { + const identity = { executablePath, userDataDir }; + const initial = await findExactChromeProcesses(identity, platform); + if (initial.length === 0) return false; + for (const pid of initial) await terminateChromeProcessTree(pid, platform, false); + const deadline = Date.now() + 2500; + while (Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 100)); + if ((await findExactChromeProcesses(identity, platform)).length === 0) return true; + } + for (const pid of await findExactChromeProcesses(identity, platform)) { + await terminateChromeProcessTree(pid, platform, true); + } + return (await findExactChromeProcesses(identity, platform)).length === 0; +} + async function waitForProfileProcessesToExit(userDataDir: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { diff --git a/tests/e2e/chrome-webdriver.test.ts b/tests/e2e/chrome-webdriver.test.ts new file mode 100644 index 00000000..b07a8ab7 --- /dev/null +++ b/tests/e2e/chrome-webdriver.test.ts @@ -0,0 +1,35 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { BrowserContext } from 'playwright-core'; +import { findInstalledGoogleChrome } from '../../src/browser/google-chrome.js'; +import { launchChromePersistentContext } from '../../src/browser/runtime/local-cloak/chrome-launch.js'; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))); +}); + +describe.skipIf(process.env.WEBCMD_LIVE_CHROME !== '1')('installed Chrome native webdriver live gate', () => { + it('keeps navigator.webdriver false through the production nonzero-CDP launcher', async () => { + const executablePath = await findInstalledGoogleChrome(); + if (!executablePath) throw new Error('Google Chrome is required when WEBCMD_LIVE_CHROME=1'); + const userDataDir = await mkdtemp(join(tmpdir(), 'webcmd-live-chrome-')); + tempDirs.push(userDataDir); + const previousBinary = process.env.CLOAKBROWSER_BINARY_PATH; + process.env.CLOAKBROWSER_BINARY_PATH = executablePath; + let context: BrowserContext | undefined; + try { + context = await launchChromePersistentContext({ userDataDir, headless: false, humanize: true }); + const page = context.pages()[0] ?? await context.newPage(); + await page.goto('data:text/html,webcmd-chrome-webdriver-check'); + expect(await page.evaluate(() => navigator.webdriver)).toBe(false); + } finally { + await context?.close(); + if (previousBinary === undefined) delete process.env.CLOAKBROWSER_BINARY_PATH; + else process.env.CLOAKBROWSER_BINARY_PATH = previousBinary; + } + }, 30_000); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 1e4a2f1b..d73a6f49 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -32,6 +32,7 @@ export default defineConfig({ 'tests/e2e/adapter-authoring-parity.test.ts', 'tests/e2e/article-download-pipeline.test.ts', 'tests/e2e/slab-alpha-install.test.ts', + 'tests/e2e/chrome-webdriver.test.ts', 'tests/e2e/cloak-runtime.test.ts', 'tests/e2e/cloak-session-concurrency.test.ts', 'tests/e2e/browser-run.test.ts',