diff --git a/browse/src/chrome-launcher.ts b/browse/src/chrome-launcher.ts index 769b390d..ea6ed346 100644 --- a/browse/src/chrome-launcher.ts +++ b/browse/src/chrome-launcher.ts @@ -117,41 +117,125 @@ export function isBrowserRunning(browser: BrowserBinary): boolean { } } -// ─── Browser Launch with CDP ─────────────────────────────────── +// ─── Runtime Detection ───────────────────────────────────────── + +export type RuntimeEnv = 'conductor' | 'claude-code' | 'codex' | 'terminal'; /** - * Quit a browser gracefully via osascript and relaunch with --remote-debugging-port. - * Returns the CDP WebSocket URL on success. + * 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 sets these env vars for workspace subprocesses + if (process.env.CONDUCTOR_WORKSPACE_ID || process.env.CONDUCTOR_APP) return 'conductor'; + // Check if parent process is Conductor (Electron app) + try { + const ppid = process.ppid; + if (ppid) { + const parentInfo = execSync(`ps -p ${ppid} -o comm= 2>/dev/null`, { stdio: 'pipe' }).toString().trim(); + if (parentInfo.includes('Conductor') || parentInfo.includes('Electron')) return 'conductor'; + } + } catch {} + // 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. * - * If the user's browser is running, this will: - * 1. Quit it gracefully (tabs restored on relaunch) - * 2. Wait 2s for clean shutdown - * 3. Relaunch with --remote-debugging-port - * 4. Poll for CDP availability (up to 15s) - * - * On failure: attempt to relaunch WITHOUT debug flag (rollback). + * 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<{ wsUrl: string; port: number }> { +): Promise { const wasRunning = isBrowserRunning(browser); if (wasRunning) { - // Quit gracefully via osascript + 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} --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 { - throw new Error(`Failed to quit ${browser.name}. Close it manually and try again.`); + // 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} --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)); } - // Wait for clean shutdown - await new Promise(resolve => setTimeout(resolve, 2000)); } - // Relaunch with CDP flag + // Launch with CDP flag const child = spawn(browser.binary, [ `--remote-debugging-port=${port}`, '--restore-last-session', @@ -161,9 +245,9 @@ export async function launchWithCdp( }); child.unref(); - // Poll for CDP availability (up to 15s) + // Poll for CDP availability (up to 30s — Chrome with many tabs takes time) const startTime = Date.now(); - while (Date.now() - startTime < 15000) { + while (Date.now() - startTime < 30000) { const result = await isCdpAvailable(port); if (result.available && result.wsUrl) { return { wsUrl: result.wsUrl, port }; @@ -183,7 +267,7 @@ export async function launchWithCdp( } throw new Error( - `CDP endpoint not available after 15s. ${browser.name} may not support --remote-debugging-port, ` + + `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.` ); } @@ -197,10 +281,13 @@ export async function launchWithCdp( * @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 }> { +): Promise<{ wsUrl: string; port: number; browser: string } | ManualRestartNeeded> { // Step 1: Check for existing CDP const existing = await findCdpPort(); if (existing) { @@ -232,7 +319,10 @@ export async function discoverAndConnect( browser = installed[0]; } - // Step 3: Launch with CDP + // 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 }; } diff --git a/browse/src/cli.ts b/browse/src/cli.ts index b0c7cc16..5e5f3ae9 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -348,7 +348,7 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: // connect must be handled BEFORE ensureServer() because it needs // to restart the server with CDP env vars. if (command === 'connect') { - const { discoverAndConnect } = await import('./chrome-launcher'); + const { discoverAndConnect, isManualRestart, detectRuntime, isCdpAvailable } = await import('./chrome-launcher'); // Parse args: connect [browser] [--port N] let preferredBrowser: string | undefined; @@ -378,9 +378,54 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: } // Discover and connect to browser - console.log(`Discovering browser${preferredBrowser ? ` (${preferredBrowser})` : ''}...`); + const runtime = detectRuntime(); + console.log(`Discovering browser${preferredBrowser ? ` (${preferredBrowser})` : ''} (runtime: ${runtime})...`); try { const result = await discoverAndConnect(preferredBrowser, port); + + // Handle manual restart needed (Conductor / sandboxed apps) + if (isManualRestart(result)) { + console.log(`\n${result.reason}\n`); + console.log(`To connect, quit ${result.browser.name} and restart it with CDP enabled:\n`); + console.log(` 1. Quit ${result.browser.name} (Cmd+Q)`); + console.log(` 2. Open Terminal and run:`); + console.log(` "${result.command}"`); + console.log(` 3. Then run: $B connect ${result.browser.name.toLowerCase()}\n`); + console.log(`Or add this to your shell profile to always launch with CDP:`); + console.log(` alias chrome-cdp='"${result.command}"'\n`); + + // Wait and poll — user might restart Chrome while we're printing + console.log(`Waiting for CDP on port ${result.port}...`); + const pollStart = Date.now(); + while (Date.now() - pollStart < 60000) { + const probe = await isCdpAvailable(result.port); + if (probe.available && probe.wsUrl) { + console.log(`CDP available! Connecting...`); + // Start server with CDP env vars + const newState = await startServer({ + BROWSE_CDP_URL: probe.wsUrl, + BROWSE_CDP_PORT: String(result.port), + }); + const resp = await fetch(`http://127.0.0.1:${newState.port}/command`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${newState.token}`, + }, + body: JSON.stringify({ command: 'tabs', args: [] }), + signal: AbortSignal.timeout(5000), + }); + const tabList = await resp.text(); + console.log(`Connected to ${result.browser.name} via CDP\n${tabList}`); + process.exit(0); + } + await new Promise(resolve => setTimeout(resolve, 2000)); + process.stdout.write('.'); + } + console.log(`\nTimed out waiting for CDP. Run $B connect again after restarting ${result.browser.name}.`); + process.exit(1); + } + console.log(`Found ${result.browser} CDP at port ${result.port}`); // Start server with CDP env vars diff --git a/browse/test/cdp-connect.test.ts b/browse/test/cdp-connect.test.ts index 8d78e4a0..69655d83 100644 --- a/browse/test/cdp-connect.test.ts +++ b/browse/test/cdp-connect.test.ts @@ -93,6 +93,38 @@ describe('findCdpPort', () => { }); }); +// ─── 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', () => {