mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-05 13:15:24 +02:00
fix: detect Conductor runtime, skip osascript quit for sandboxed apps
macOS App Management blocks Electron apps (Conductor) from quitting other apps via osascript. Now detects the runtime environment: - terminal/claude-code/codex: can manage apps freely - conductor: prints manual restart instructions + polls for 60s detectRuntime() checks env vars and parent process. When Chrome needs restart but we can't quit it, prints step-by-step instructions and waits for the user to restart Chrome with --remote-debugging-port. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+111
-21
@@ -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<LaunchOutcome> {
|
||||
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 };
|
||||
}
|
||||
|
||||
+47
-2
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user