refactor: kill CDP naming, delete chrome-launcher.ts dead code

The connectCDP() method and connectionMode: 'cdp' naming was a legacy
artifact — real Chrome was tried but failed (silently blocks
--load-extension), so the implementation already used Playwright's
bundled Chromium via launchPersistentContext(). The naming was
misleading.

Changes:
- Delete chrome-launcher.ts (361 LOC) — only import was in unreachable
  attemptReconnect() method
- Delete dead attemptReconnect() and reconnecting field
- Delete preExistingTabIds (was for protecting real Chrome tabs we
  never connect to)
- Rename connectCDP() → launchHeaded()
- Rename connectionMode: 'cdp' → 'headed' across all files
- Replace BROWSE_CDP_URL/BROWSE_CDP_PORT env vars with BROWSE_HEADED=1
- Regenerate SKILL.md files for updated command descriptions
- Move BrowserManager unit tests to browser-manager-unit.test.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-22 21:21:19 -07:00
parent 08356929b3
commit 28bc69aba9
11 changed files with 79 additions and 618 deletions
-2
View File
@@ -71,7 +71,6 @@ browse/
│ ├── cookie-import-browser.ts # Decrypt + import cookies from real Chromium browsers
│ ├── cookie-picker-routes.ts # HTTP routes for interactive cookie picker UI
│ ├── cookie-picker-ui.ts # Self-contained HTML/CSS/JS for cookie picker
│ ├── chrome-launcher.ts # Browser discovery, CDP probe, runtime detection
│ ├── activity.ts # Activity streaming (SSE) for Chrome extension
│ └── buffers.ts # CircularBuffer<T> + console/network/dialog capture
├── test/ # Integration tests + HTML fixtures
@@ -346,7 +345,6 @@ Tests spin up a local HTTP server (`browse/test/test-server.ts`) serving HTML fi
| `browse/src/cookie-import-browser.ts` | Decrypt Chromium cookies via macOS Keychain + PBKDF2/AES-128-CBC. Auto-detects installed browsers. |
| `browse/src/cookie-picker-routes.ts` | HTTP routes for `/cookie-picker/*` — browser list, domain search, import, remove. |
| `browse/src/cookie-picker-ui.ts` | Self-contained HTML generator for the interactive cookie picker (dark theme, no frameworks). |
| `browse/src/chrome-launcher.ts` | Browser binary discovery, CDP port probe, runtime detection (Conductor/Claude Code/Codex/terminal). |
| `browse/src/activity.ts` | Activity streaming — `ActivityEntry` type, `CircularBuffer`, privacy filtering, SSE subscriber management. |
| `browse/src/buffers.ts` | `CircularBuffer<T>` (O(1) ring buffer) + console/network/dialog capture with async disk flush. |
+3 -3
View File
@@ -603,9 +603,9 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
### Server
| Command | Description |
|---------|-------------|
| `connect [browser] [--port N]` | Connect to real Chrome/Comet browser via CDP |
| `disconnect` | Disconnect from real browser, return to headless mode |
| `focus [@ref]` | Bring connected browser window to foreground (macOS) |
| `connect` | Launch headed Chromium with Chrome extension |
| `disconnect` | Disconnect headed browser, return to headless mode |
| `focus [@ref]` | Bring headed browser window to foreground (macOS) |
| `handoff [message]` | Open visible Chrome at current page for user takeover |
| `restart` | Restart server |
| `resume` | Re-snapshot after user takeover, return control to AI |
+3 -3
View File
@@ -475,9 +475,9 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
### Server
| Command | Description |
|---------|-------------|
| `connect [browser] [--port N]` | Connect to real Chrome/Comet browser via CDP |
| `disconnect` | Disconnect from real browser, return to headless mode |
| `focus [@ref]` | Bring connected browser window to foreground (macOS) |
| `connect` | Launch headed Chromium with Chrome extension |
| `disconnect` | Disconnect headed browser, return to headless mode |
| `focus [@ref]` | Bring headed browser window to foreground (macOS) |
| `handoff [message]` | Open visible Chrome at current page for user takeover |
| `restart` | Restart server |
| `resume` | Re-snapshot after user takeover, return control to AI |
+18 -61
View File
@@ -61,14 +61,11 @@ export class BrowserManager {
private isHeaded: boolean = false;
private consecutiveFailures: number = 0;
// ─── CDP State ────────────────────────────────────────────
private connectionMode: 'launched' | 'cdp' = 'launched';
private preExistingTabIds: Set<number> = new Set();
private cdpPort: number = 0;
// ─── Headed State ────────────────────────────────────────
private connectionMode: 'launched' | 'headed' = 'launched';
private intentionalDisconnect = false;
private reconnecting = false;
getConnectionMode(): 'launched' | 'cdp' { return this.connectionMode; }
getConnectionMode(): 'launched' | 'headed' { return this.connectionMode; }
/**
* Find the gstack Chrome extension directory.
@@ -140,21 +137,19 @@ export class BrowserManager {
await this.newTab();
}
// ─── CDP Connect ────────────────────────────────────────────
// ─── Headed Mode ─────────────────────────────────────────────
/**
* Launch the user's real Chrome browser via Playwright's channel: 'chrome'.
*
* Uses Playwright's native pipe protocol (not CDP WebSocket) to control
* the system Chrome binary. This avoids CDP protocol version mismatches
* between Playwright and recent Chrome versions.
* Launch Playwright's bundled Chromium in headed mode with the gstack
* Chrome extension auto-loaded. Uses launchPersistentContext() which
* is required for extension loading (launch() + newContext() can't
* load extensions).
*
* The browser launches headed with a visible window the user sees
* every action Claude takes in real time.
*/
async connectCDP(_wsUrl: string, _port: number): Promise<void> {
// Clear old state before repopulating (safe for reconnect)
async launchHeaded(): Promise<void> {
// Clear old state before repopulating
this.pages.clear();
this.preExistingTabIds.clear();
this.refMap.clear();
this.nextTabId = 1;
@@ -186,7 +181,7 @@ export class BrowserManager {
],
});
this.browser = this.context.browser();
this.connectionMode = 'cdp';
this.connectionMode = 'headed';
this.intentionalDisconnect = false;
// Inject visual indicator — subtle top-edge amber gradient
@@ -252,46 +247,16 @@ export class BrowserManager {
});
}
// CDP-specific defaults
// Headed mode defaults
this.dialogAutoAccept = false; // Don't dismiss user's real dialogs
this.isHeaded = true;
this.consecutiveFailures = 0;
}
/**
* Auto-reconnect after unexpected CDP disconnect (e.g., browser restart).
* Non-blocking recursive setTimeout never overlaps or blocks commands.
*/
private async attemptReconnect(remaining = 60): Promise<void> {
if (remaining <= 0 || this.reconnecting || this.intentionalDisconnect) {
if (remaining <= 0) {
console.log('[browse] CDP reconnect failed after 5 minutes. Run `$B connect` to reconnect.');
}
return;
}
this.reconnecting = true;
try {
const { isCdpAvailable } = await import('./chrome-launcher');
const result = await isCdpAvailable(this.cdpPort);
if (result.available && result.wsUrl) {
await this.connectCDP(result.wsUrl, this.cdpPort);
console.log('[browse] Reconnected to real browser');
return;
}
} catch {
// Probe failed — try again
} finally {
this.reconnecting = false;
}
setTimeout(() => this.attemptReconnect(remaining - 1), 5000);
}
async close() {
if (this.browser || (this.connectionMode === 'cdp' && this.context)) {
if (this.connectionMode === 'cdp') {
// CDP/persistent context mode: close the context (which closes the browser)
if (this.browser || (this.connectionMode === 'headed' && this.context)) {
if (this.connectionMode === 'headed') {
// Headed/persistent context mode: close the context (which closes the browser)
this.intentionalDisconnect = true;
if (this.browser) this.browser.removeAllListeners('disconnected');
await Promise.race([
@@ -355,11 +320,6 @@ export class BrowserManager {
const page = this.pages.get(tabId);
if (!page) throw new Error(`Tab ${tabId} not found`);
// CDP mode: block closing pre-existing user tabs
if (this.connectionMode === 'cdp' && this.preExistingTabIds.has(tabId)) {
throw new Error("Cannot close user's pre-existing tab in real-browser mode. Only tabs created by gstack can be closed.");
}
await page.close();
this.pages.delete(tabId);
@@ -599,8 +559,8 @@ export class BrowserManager {
* Falls back to a clean slate on any failure.
*/
async recreateContext(): Promise<string | null> {
if (this.connectionMode === 'cdp') {
throw new Error('Cannot recreate context in real-browser mode. The browser context belongs to the user.');
if (this.connectionMode === 'headed') {
throw new Error('Cannot recreate context in headed mode. Use disconnect first.');
}
if (!this.browser || !this.context) {
throw new Error('Browser not launched');
@@ -668,10 +628,7 @@ export class BrowserManager {
* If step 2 fails return error, headless browser untouched
*/
async handoff(message: string): Promise<string> {
if (this.connectionMode === 'cdp') {
return 'Already controlling real browser via CDP. No handoff needed.';
}
if (this.isHeaded) {
if (this.connectionMode === 'headed' || this.isHeaded) {
return `HANDOFF: Already in headed mode at ${this.getCurrentUrl()}`;
}
if (!this.browser || !this.context) {
-361
View File
@@ -1,361 +0,0 @@
/**
* Chrome/Comet browser discovery + CDP connection
*
* Discovery flow (macOS only):
* 1. Probe localhost:9222 for existing CDP endpoint
* 2. If occupied by non-Chrome, try 9223-9225
* 3. If no CDP: find browser binary, quit gracefully, relaunch with --remote-debugging-port
* 4. On attach failure: rollback relaunch browser WITHOUT debug flag
*
* Reuses the browser registry pattern from cookie-import-browser.ts
*/
import { execSync, spawn } from 'child_process';
// ─── Browser Binary Registry (macOS) ───────────────────────────
export interface BrowserBinary {
name: string;
binary: string;
appName: string; // for osascript 'tell application "X"'
aliases: string[];
realDataDir: string; // user's actual profile location
cdpDataDir: string; // separate dir for --remote-debugging-port (symlinks to real profile)
}
const HOME = process.env.HOME || '/tmp';
const APP_SUPPORT = `${HOME}/Library/Application Support`;
const CDP_BASE = `${HOME}/.gstack/cdp-profile`;
export const BROWSER_BINARIES: BrowserBinary[] = [
{ name: 'Comet', binary: '/Applications/Comet.app/Contents/MacOS/Comet', appName: 'Comet', aliases: ['comet', 'perplexity'], realDataDir: `${APP_SUPPORT}/Comet`, cdpDataDir: `${CDP_BASE}/comet` },
{ name: 'Chrome', binary: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', appName: 'Google Chrome', aliases: ['chrome', 'google-chrome'], realDataDir: `${APP_SUPPORT}/Google/Chrome`, cdpDataDir: `${CDP_BASE}/chrome` },
{ name: 'Arc', binary: '/Applications/Arc.app/Contents/MacOS/Arc', appName: 'Arc', aliases: ['arc'], realDataDir: `${APP_SUPPORT}/Arc/User Data`, cdpDataDir: `${CDP_BASE}/arc` },
{ name: 'Brave', binary: '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser', appName: 'Brave Browser', aliases: ['brave'], realDataDir: `${APP_SUPPORT}/BraveSoftware/Brave-Browser`, cdpDataDir: `${CDP_BASE}/brave` },
{ name: 'Edge', binary: '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', appName: 'Microsoft Edge', aliases: ['edge'], realDataDir: `${APP_SUPPORT}/Microsoft Edge`, cdpDataDir: `${CDP_BASE}/edge` },
];
// ─── CDP Probe ─────────────────────────────────────────────────
export interface CdpProbeResult {
available: boolean;
wsUrl?: string;
browser?: string;
}
/**
* Check if a CDP endpoint is available at the given port.
* Returns the WebSocket debugger URL if found.
*/
export async function isCdpAvailable(port: number): Promise<CdpProbeResult> {
try {
const resp = await fetch(`http://127.0.0.1:${port}/json/version`, {
signal: AbortSignal.timeout(2000),
});
if (!resp.ok) return { available: false };
const data = await resp.json() as Record<string, string>;
const wsUrl = data.webSocketDebuggerUrl;
if (!wsUrl) return { available: false };
return { available: true, wsUrl, browser: data.Browser };
} catch {
return { available: false };
}
}
/**
* Get the WebSocket debugger URL from a CDP port.
* Throws if not available.
*/
export async function getCdpWebSocketUrl(port: number): Promise<string> {
const result = await isCdpAvailable(port);
if (!result.available || !result.wsUrl) {
throw new Error(`No CDP endpoint at port ${port}`);
}
return result.wsUrl;
}
/**
* Try ports 9222-9225 to find an available CDP endpoint.
*/
export async function findCdpPort(): Promise<{ port: number; wsUrl: string; browser?: string } | null> {
for (const port of [9222, 9223, 9224, 9225]) {
const result = await isCdpAvailable(port);
if (result.available && result.wsUrl) {
return { port, wsUrl: result.wsUrl, browser: result.browser };
}
}
return null;
}
// ─── Browser Binary Discovery ──────────────────────────────────
import * as fs from 'fs';
/**
* Find the binary path for a browser by name or alias.
*/
export function findBrowserBinary(nameOrAlias: string): BrowserBinary | null {
const needle = nameOrAlias.toLowerCase();
return BROWSER_BINARIES.find(b =>
b.aliases.includes(needle) || b.name.toLowerCase() === needle
) ?? null;
}
/**
* Find installed browsers (binary exists on disk).
*/
export function findInstalledBrowsers(): BrowserBinary[] {
return BROWSER_BINARIES.filter(b => {
try { return fs.existsSync(b.binary); } catch { return false; }
});
}
/**
* Check if a browser is currently running (macOS: pgrep).
*/
export function isBrowserRunning(browser: BrowserBinary): boolean {
try {
// Use the app name to find the process
execSync(`pgrep -f "${browser.appName}"`, { stdio: 'pipe' });
return true;
} catch {
return false;
}
}
/**
* Set up a CDP data directory with symlinks to the user's real profile.
* Chrome refuses --remote-debugging-port on the default data dir,
* but we can symlink the real profile so cookies/extensions carry over.
*/
function setupCdpDataDir(browser: BrowserBinary): void {
const { mkdirSync, symlinkSync, existsSync } = fs;
mkdirSync(browser.cdpDataDir, { recursive: true });
// Symlink the Default profile (cookies, extensions, history)
const realDefault = `${browser.realDataDir}/Default`;
const cdpDefault = `${browser.cdpDataDir}/Default`;
if (existsSync(realDefault) && !existsSync(cdpDefault)) {
symlinkSync(realDefault, cdpDefault);
}
// Symlink Local State (crypto keys for cookie decryption, etc.)
const realState = `${browser.realDataDir}/Local State`;
const cdpState = `${browser.cdpDataDir}/Local State`;
if (existsSync(realState) && !existsSync(cdpState)) {
symlinkSync(realState, cdpState);
}
}
// ─── Runtime Detection ─────────────────────────────────────────
export type RuntimeEnv = 'conductor' | 'claude-code' | 'codex' | 'terminal';
/**
* Detect the parent runtime environment.
* Conductor and other Electron apps can't use osascript to quit other apps
* due to macOS App Management security restrictions.
*/
export function detectRuntime(): RuntimeEnv {
// Conductor detection — check FIRST because Conductor also has ANTHROPIC_API_KEY
if (
process.env.CONDUCTOR_WORKSPACE_NAME ||
process.env.CONDUCTOR_BIN_DIR ||
process.env.CONDUCTOR_PORT ||
process.env.__CFBundleIdentifier === 'com.conductor.app'
) return 'conductor';
// Claude Code terminal detection
if (process.env.CLAUDE_CODE || process.env.ANTHROPIC_API_KEY) return 'claude-code';
// Codex CLI detection
if (process.env.CODEX_SESSION || process.env.OPENAI_API_KEY) return 'codex';
return 'terminal';
}
/**
* Whether the current runtime can safely quit/relaunch other macOS apps.
* Electron apps (Conductor) trigger macOS App Management dialogs.
* Terminal apps (iTerm, Terminal, Claude Code CLI) can do it freely.
*/
export function canManageApps(): boolean {
const runtime = detectRuntime();
// Terminal-based runtimes can use osascript freely
// Electron-based runtimes (Conductor) trigger App Management dialogs
return runtime === 'terminal' || runtime === 'claude-code' || runtime === 'codex';
}
// ─── Browser Launch with CDP ───────────────────────────────────
export interface LaunchResult {
wsUrl: string;
port: number;
}
export interface ManualRestartNeeded {
needsManualRestart: true;
browser: BrowserBinary;
port: number;
reason: string;
command: string; // The command the user needs to run
}
export type LaunchOutcome = LaunchResult | ManualRestartNeeded;
function isManualRestart(outcome: LaunchOutcome): outcome is ManualRestartNeeded {
return 'needsManualRestart' in outcome;
}
/**
* Launch or connect to a browser with CDP enabled.
*
* Three paths:
* 1. Browser not running launch with --remote-debugging-port (works everywhere)
* 2. Browser running + runtime CAN manage apps quit and relaunch (terminal/CLI)
* 3. Browser running + runtime CANNOT manage apps return ManualRestartNeeded
* with instructions for the user (Conductor/Electron)
*/
export async function launchWithCdp(
browser: BrowserBinary,
port: number = 9222,
): Promise<LaunchOutcome> {
const wasRunning = isBrowserRunning(browser);
if (wasRunning) {
if (!canManageApps()) {
// Can't quit Chrome from Conductor — macOS App Management blocks it
const runtime = detectRuntime();
return {
needsManualRestart: true,
browser,
port,
reason: runtime === 'conductor'
? `Conductor can't restart ${browser.name} due to macOS App Management security. You need to restart it manually.`
: `This runtime can't restart ${browser.name}. You need to restart it manually.`,
command: `"${browser.binary}" --remote-debugging-port=${port} --user-data-dir="${browser.cdpDataDir}" --restore-last-session`,
};
}
// Terminal/CLI runtime — can quit and relaunch
try {
execSync(`osascript -e 'tell application "${browser.appName}" to quit'`, {
stdio: 'pipe',
timeout: 10000,
});
} catch {
// osascript failed even from terminal — fall back to manual
return {
needsManualRestart: true,
browser,
port,
reason: `Failed to quit ${browser.name} via osascript. You need to restart it manually.`,
command: `"${browser.binary}" --remote-debugging-port=${port} --user-data-dir="${browser.cdpDataDir}" --restore-last-session`,
};
}
// Wait for clean shutdown (Chrome with many tabs can take a while)
await new Promise(resolve => setTimeout(resolve, 3000));
// Verify it actually quit — wait up to 10s for processes to exit
const quitStart = Date.now();
while (Date.now() - quitStart < 10000) {
if (!isBrowserRunning(browser)) break;
await new Promise(resolve => setTimeout(resolve, 500));
}
}
// Set up CDP data dir with symlinked profile
// Chrome refuses --remote-debugging-port on its default data dir.
// We create a separate dir and symlink the real profile into it.
setupCdpDataDir(browser);
// Launch with CDP flag + non-default data dir
const child = spawn(browser.binary, [
`--remote-debugging-port=${port}`,
`--user-data-dir=${browser.cdpDataDir}`,
'--restore-last-session',
], {
detached: true,
stdio: 'ignore',
});
child.unref();
// Poll for CDP availability (up to 30s — Chrome with many tabs takes time)
const startTime = Date.now();
while (Date.now() - startTime < 30000) {
const result = await isCdpAvailable(port);
if (result.available && result.wsUrl) {
return { wsUrl: result.wsUrl, port };
}
await new Promise(resolve => setTimeout(resolve, 500));
}
// Rollback: relaunch without debug flag so user gets their browser back
if (wasRunning) {
try {
const rollback = spawn(browser.binary, ['--restore-last-session'], {
detached: true,
stdio: 'ignore',
});
rollback.unref();
} catch {}
}
throw new Error(
`CDP endpoint not available after 30s. ${browser.name} may not support --remote-debugging-port, ` +
`or port ${port} is blocked. Browser has been relaunched without debug flag.`
);
}
/**
* Full discovery algorithm:
* 1. Check for existing CDP on ports 9222-9225
* 2. Find an installed browser (priority order)
* 3. Launch/relaunch with CDP
*
* @param preferredBrowser - Optional browser name (e.g., 'chrome', 'comet')
* @param port - CDP port (default 9222)
*/
export { isManualRestart };
export type { ManualRestartNeeded };
export async function discoverAndConnect(
preferredBrowser?: string,
port: number = 9222,
): Promise<{ wsUrl: string; port: number; browser: string } | ManualRestartNeeded> {
// Step 1: Check for existing CDP
const existing = await findCdpPort();
if (existing) {
return {
wsUrl: existing.wsUrl,
port: existing.port,
browser: existing.browser || 'Unknown',
};
}
// Step 2: Find browser binary
let browser: BrowserBinary | null = null;
if (preferredBrowser) {
browser = findBrowserBinary(preferredBrowser);
if (!browser) {
const installed = findInstalledBrowsers();
const names = installed.map(b => b.name.toLowerCase()).join(', ');
throw new Error(
`Browser '${preferredBrowser}' not found. Installed: ${names || 'none'}`
);
}
} else {
// Auto-detect: first installed browser in priority order
const installed = findInstalledBrowsers();
if (installed.length === 0) {
throw new Error('No supported browser found. Install Chrome, Comet, Arc, Brave, or Edge.');
}
browser = installed[0];
}
// Step 3: Launch with CDP (may return ManualRestartNeeded)
const result = await launchWithCdp(browser, port);
if (isManualRestart(result)) {
return result; // Caller must handle manual restart flow
}
return { ...result, browser: browser.name };
}
+18 -20
View File
@@ -83,8 +83,7 @@ interface ServerState {
startedAt: string;
serverPath: string;
binaryVersion?: string;
mode?: 'launched' | 'cdp';
cdpPort?: number;
mode?: 'launched' | 'headed';
}
// ─── State File ────────────────────────────────────────────────
@@ -236,11 +235,11 @@ async function ensureServer(): Promise<ServerState> {
}
}
// Guard: never silently replace a CDP server with a headless one.
// CDP mode means a user-visible Chrome window is (or was) controlled.
// Guard: never silently replace a headed server with a headless one.
// Headed mode means a user-visible Chrome window is (or was) controlled.
// Silently replacing it would be confusing — tell the user to reconnect.
if (state && state.mode === 'cdp' && isProcessAlive(state.pid)) {
console.error(`[browse] CDP server running (PID ${state.pid}) but not responding.`);
if (state && state.mode === 'headed' && isProcessAlive(state.pid)) {
console.error(`[browse] Headed server running (PID ${state.pid}) but not responding.`);
console.error(`[browse] Run '$B connect' to restart.`);
process.exit(1);
}
@@ -353,23 +352,23 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
const command = args[0];
const commandArgs = args.slice(1);
// ─── CDP Connect (pre-server command) ───────────────────────
// ─── Headed Connect (pre-server command) ────────────────────
// connect must be handled BEFORE ensureServer() because it needs
// to restart the server with real Chrome via Playwright channel:chrome.
// to restart the server in headed mode with the Chrome extension.
if (command === 'connect') {
// Check if already in CDP mode and healthy
// Check if already in headed mode and healthy
const existingState = readState();
if (existingState && existingState.mode === 'cdp' && isProcessAlive(existingState.pid)) {
if (existingState && existingState.mode === 'headed' && isProcessAlive(existingState.pid)) {
try {
const resp = await fetch(`http://127.0.0.1:${existingState.port}/health`, {
signal: AbortSignal.timeout(2000),
});
if (resp.ok) {
console.log('Already connected to real browser.');
console.log('Already connected in headed mode.');
process.exit(0);
}
} catch {
// CDP server alive but not responding — kill and restart
// Headed server alive but not responding — kill and restart
}
}
@@ -392,13 +391,12 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
// Delete stale state file
try { fs.unlinkSync(config.stateFile); } catch {}
console.log('Launching real Chrome browser...');
console.log('Launching headed Chromium with extension...');
try {
// Start server with CDP flag — server.ts will use channel:chrome
// Start server in headed mode with extension auto-loaded
// Use a well-known port so the Chrome extension auto-connects
const newState = await startServer({
BROWSE_CDP_URL: 'channel:chrome',
BROWSE_CDP_PORT: '0',
BROWSE_HEADED: '1',
BROWSE_PORT: '34567',
});
@@ -446,13 +444,13 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
process.exit(0);
}
// ─── CDP Disconnect (pre-server command) ─────────────────
// disconnect must be handled BEFORE ensureServer() because the CDP
// ─── Headed Disconnect (pre-server command) ─────────────────
// disconnect must be handled BEFORE ensureServer() because the headed
// guard blocks all commands when the server is unresponsive.
if (command === 'disconnect') {
const existingState = readState();
if (!existingState || existingState.mode !== 'cdp') {
console.log('Not in CDP mode — nothing to disconnect.');
if (!existingState || existingState.mode !== 'headed') {
console.log('Not in headed mode — nothing to disconnect.');
process.exit(0);
}
// Try graceful shutdown via server
+4 -4
View File
@@ -99,10 +99,10 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
// Handoff
'handoff': { category: 'Server', description: 'Open visible Chrome at current page for user takeover', usage: 'handoff [message]' },
'resume': { category: 'Server', description: 'Re-snapshot after user takeover, return control to AI', usage: 'resume' },
// CDP
'connect': { category: 'Server', description: 'Connect to real Chrome/Comet browser via CDP', usage: 'connect [browser] [--port N]' },
'disconnect': { category: 'Server', description: 'Disconnect from real browser, return to headless mode' },
'focus': { category: 'Server', description: 'Bring connected browser window to foreground (macOS)', usage: 'focus [@ref]' },
// Headed mode
'connect': { category: 'Server', description: 'Launch headed Chromium with Chrome extension', usage: 'connect' },
'disconnect': { category: 'Server', description: 'Disconnect headed browser, return to headless mode' },
'focus': { category: 'Server', description: 'Bring headed browser window to foreground (macOS)', usage: 'focus [@ref]' },
};
// Load-time validation: descriptions must cover exactly the command sets
+11 -12
View File
@@ -265,34 +265,33 @@ export async function handleMetaCommand(
return `RESUMED\n${snapshot}`;
}
// ─── CDP Connect ────────────────────────────────────
// ─── Headed Mode ──────────────────────────────────────
case 'connect': {
// connect is handled as a pre-server command in cli.ts
// If we get here, server is already running — tell the user
if (bm.getConnectionMode() === 'cdp') {
return 'Already connected to real browser via CDP.';
if (bm.getConnectionMode() === 'headed') {
return 'Already in headed mode with extension.';
}
return 'The connect command must be run from the CLI (not sent to a running server). Run: $B connect [browser]';
return 'The connect command must be run from the CLI (not sent to a running server). Run: $B connect';
}
case 'disconnect': {
if (bm.getConnectionMode() !== 'cdp') {
return 'Not in CDP mode — nothing to disconnect.';
if (bm.getConnectionMode() !== 'headed') {
return 'Not in headed mode — nothing to disconnect.';
}
// Signal that we want a restart in headless mode
console.log('[browse] Disconnecting from real browser. Restarting in headless mode.');
console.log('[browse] Disconnecting headed browser. Restarting in headless mode.');
await shutdown();
return 'Disconnected from real browser. Server will restart in headless mode on next command.';
return 'Disconnected. Server will restart in headless mode on next command.';
}
case 'focus': {
if (bm.getConnectionMode() !== 'cdp') {
return 'focus requires CDP mode. Run `$B connect` first.';
if (bm.getConnectionMode() !== 'headed') {
return 'focus requires headed mode. Run `$B connect` first.';
}
try {
const { execSync } = await import('child_process');
// Detect which browser we're connected to from the CDP info
// For now, try common app names
// Try common Chromium-based browser app names to bring to foreground
const appNames = ['Comet', 'Google Chrome', 'Arc', 'Brave Browser', 'Microsoft Edge'];
let activated = false;
for (const appName of appNames) {
+5 -7
View File
@@ -744,12 +744,11 @@ async function start() {
const port = await findPort();
// Launch browser (headless or real Chrome)
const cdpUrl = process.env.BROWSE_CDP_URL;
const cdpPort = parseInt(process.env.BROWSE_CDP_PORT || '0', 10);
if (cdpUrl) {
await browserManager.connectCDP(cdpUrl, cdpPort);
console.log(`[browse] Launched real Chrome browser (headed)`);
// Launch browser (headless or headed with extension)
const headed = process.env.BROWSE_HEADED === '1';
if (headed) {
await browserManager.launchHeaded();
console.log(`[browse] Launched headed Chromium with extension`);
} else {
await browserManager.launch();
}
@@ -1068,7 +1067,6 @@ async function start() {
serverPath: path.resolve(import.meta.dir, 'server.ts'),
binaryVersion: readVersionHash() || undefined,
mode: browserManager.getConnectionMode(),
...(cdpPort ? { cdpPort } : {}),
};
const tmpFile = config.stateFile + '.tmp';
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 });
+17
View File
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'bun:test';
// ─── BrowserManager basic unit tests ─────────────────────────────
describe('BrowserManager defaults', () => {
it('getConnectionMode defaults to launched', async () => {
const { BrowserManager } = await import('../src/browser-manager');
const bm = new BrowserManager();
expect(bm.getConnectionMode()).toBe('launched');
});
it('getRefMap returns empty array initially', async () => {
const { BrowserManager } = await import('../src/browser-manager');
const bm = new BrowserManager();
expect(bm.getRefMap()).toEqual([]);
});
});
-145
View File
@@ -1,145 +0,0 @@
import { describe, it, expect, mock, beforeEach } from 'bun:test';
import {
findBrowserBinary,
findInstalledBrowsers,
isCdpAvailable,
getCdpWebSocketUrl,
findCdpPort,
BROWSER_BINARIES,
} from '../src/chrome-launcher';
// ─── chrome-launcher unit tests ─────────────────────────────────
describe('findBrowserBinary', () => {
it('finds Chrome by alias', () => {
const result = findBrowserBinary('chrome');
expect(result).not.toBeNull();
expect(result!.name).toBe('Chrome');
});
it('finds Chrome by name (case-insensitive)', () => {
const result = findBrowserBinary('Chrome');
expect(result).not.toBeNull();
expect(result!.name).toBe('Chrome');
});
it('finds Comet by alias', () => {
const result = findBrowserBinary('comet');
expect(result).not.toBeNull();
expect(result!.name).toBe('Comet');
});
it('finds Comet by perplexity alias', () => {
const result = findBrowserBinary('perplexity');
expect(result).not.toBeNull();
expect(result!.name).toBe('Comet');
});
it('returns null for unknown browser', () => {
expect(findBrowserBinary('netscape')).toBeNull();
});
it('returns null for empty string', () => {
expect(findBrowserBinary('')).toBeNull();
});
});
describe('BROWSER_BINARIES', () => {
it('has correct priority order (Comet first)', () => {
expect(BROWSER_BINARIES[0].name).toBe('Comet');
expect(BROWSER_BINARIES[1].name).toBe('Chrome');
});
it('all entries have required fields', () => {
for (const browser of BROWSER_BINARIES) {
expect(browser.name).toBeTruthy();
expect(browser.binary).toContain('/Applications/');
expect(browser.appName).toBeTruthy();
expect(browser.aliases.length).toBeGreaterThan(0);
}
});
});
describe('isCdpAvailable', () => {
it('returns false for port with no listener', async () => {
// Port 19999 should not have anything listening
const result = await isCdpAvailable(19999);
expect(result.available).toBe(false);
expect(result.wsUrl).toBeUndefined();
});
it('returns false for invalid port', async () => {
const result = await isCdpAvailable(0);
expect(result.available).toBe(false);
});
});
describe('getCdpWebSocketUrl', () => {
it('throws for unavailable port', async () => {
await expect(getCdpWebSocketUrl(19999)).rejects.toThrow('No CDP endpoint');
});
});
describe('findCdpPort', () => {
it('returns null when no CDP ports are available', async () => {
// This test passes in CI where no Chrome is running with debug port
// In local dev with debug port open, it would find one
const result = await findCdpPort();
// Either null (no CDP) or valid result — both are correct
if (result !== null) {
expect(result.port).toBeGreaterThan(0);
expect(result.wsUrl).toContain('ws://');
}
});
});
// ─── Runtime Detection ──────────────────────────────────────────
describe('detectRuntime', () => {
it('returns a valid runtime type', async () => {
const { detectRuntime } = await import('../src/chrome-launcher');
const runtime = detectRuntime();
expect(['conductor', 'claude-code', 'codex', 'terminal']).toContain(runtime);
});
});
describe('canManageApps', () => {
it('returns a boolean', async () => {
const { canManageApps } = await import('../src/chrome-launcher');
expect(typeof canManageApps()).toBe('boolean');
});
});
describe('isManualRestart', () => {
it('detects manual restart objects', async () => {
const { isManualRestart, BROWSER_BINARIES } = await import('../src/chrome-launcher');
const manualResult = {
needsManualRestart: true as const,
browser: BROWSER_BINARIES[0],
port: 9222,
reason: 'test',
command: 'test',
};
// isManualRestart is not directly exported, but we can test the type guard
expect(manualResult.needsManualRestart).toBe(true);
});
});
// ─── BrowserManager CDP mode guards ─────────────────────────────
describe('BrowserManager CDP mode', () => {
// These tests verify the mode guard logic without actually connecting
// to a real browser. We test the public interface.
it('getConnectionMode defaults to launched', async () => {
const { BrowserManager } = await import('../src/browser-manager');
const bm = new BrowserManager();
expect(bm.getConnectionMode()).toBe('launched');
});
it('getRefMap returns empty array initially', async () => {
const { BrowserManager } = await import('../src/browser-manager');
const bm = new BrowserManager();
expect(bm.getRefMap()).toEqual([]);
});
});