Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/cli-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions docs/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
113 changes: 113 additions & 0 deletions src/browser/runtime/local-cloak/chrome-launch.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
195 changes: 195 additions & 0 deletions src/browser/runtime/local-cloak/chrome-launch.ts
Original file line number Diff line number Diff line change
@@ -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<number>;
launch(executablePath: string, args: string[], platform: NodeJS.Platform): Promise<number | undefined>;
findProcesses(identity: ChromeProcessIdentity, platform: NodeJS.Platform): Promise<number[]>;
listenerOwnedBy(port: number, pid: number, platform: NodeJS.Platform): Promise<boolean>;
endpointReady(endpoint: string): Promise<boolean>;
connectOverCDP(endpoint: string): Promise<Browser>;
terminate(pid: number, platform: NodeJS.Platform, force?: boolean): Promise<void>;
activate(executablePath: string, platform: NodeJS.Platform): Promise<void>;
delay(ms: number): Promise<void>;
now(): number;
platform: NodeJS.Platform;
}

const chromeContextActivators = new WeakMap<BrowserContext, () => Promise<void>>();

export async function activateChromeContext(context: BrowserContext): Promise<void> {
await chromeContextActivators.get(context)?.();
}

export async function allocateNonzeroLoopbackPort(): Promise<number> {
const server = createServer();
await new Promise<void>((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<void>((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<number | undefined> {
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<void>((resolve, reject) => {
child.once('error', reject);
child.once('spawn', resolve);
});
const pid = child.pid;
child.unref();
return pid;
}

async function endpointReady(endpoint: string): Promise<boolean> {
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<void> {
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<BrowserContext> {
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<void> {
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);
}
25 changes: 25 additions & 0 deletions src/browser/runtime/local-cloak/chrome-process.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading