mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 11:45:20 +02:00
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:
@@ -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. |
|
||||
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user