mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-20 11:19:56 +08: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:
@@ -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.
|
* Detect the parent runtime environment.
|
||||||
* Returns the CDP WebSocket URL on success.
|
* 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:
|
* Three paths:
|
||||||
* 1. Quit it gracefully (tabs restored on relaunch)
|
* 1. Browser not running → launch with --remote-debugging-port (works everywhere)
|
||||||
* 2. Wait 2s for clean shutdown
|
* 2. Browser running + runtime CAN manage apps → quit and relaunch (terminal/CLI)
|
||||||
* 3. Relaunch with --remote-debugging-port
|
* 3. Browser running + runtime CANNOT manage apps → return ManualRestartNeeded
|
||||||
* 4. Poll for CDP availability (up to 15s)
|
* with instructions for the user (Conductor/Electron)
|
||||||
*
|
|
||||||
* On failure: attempt to relaunch WITHOUT debug flag (rollback).
|
|
||||||
*/
|
*/
|
||||||
export async function launchWithCdp(
|
export async function launchWithCdp(
|
||||||
browser: BrowserBinary,
|
browser: BrowserBinary,
|
||||||
port: number = 9222,
|
port: number = 9222,
|
||||||
): Promise<{ wsUrl: string; port: number }> {
|
): Promise<LaunchOutcome> {
|
||||||
const wasRunning = isBrowserRunning(browser);
|
const wasRunning = isBrowserRunning(browser);
|
||||||
|
|
||||||
if (wasRunning) {
|
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 {
|
try {
|
||||||
execSync(`osascript -e 'tell application "${browser.appName}" to quit'`, {
|
execSync(`osascript -e 'tell application "${browser.appName}" to quit'`, {
|
||||||
stdio: 'pipe',
|
stdio: 'pipe',
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
} catch {
|
} 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, [
|
const child = spawn(browser.binary, [
|
||||||
`--remote-debugging-port=${port}`,
|
`--remote-debugging-port=${port}`,
|
||||||
'--restore-last-session',
|
'--restore-last-session',
|
||||||
@@ -161,9 +245,9 @@ export async function launchWithCdp(
|
|||||||
});
|
});
|
||||||
child.unref();
|
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();
|
const startTime = Date.now();
|
||||||
while (Date.now() - startTime < 15000) {
|
while (Date.now() - startTime < 30000) {
|
||||||
const result = await isCdpAvailable(port);
|
const result = await isCdpAvailable(port);
|
||||||
if (result.available && result.wsUrl) {
|
if (result.available && result.wsUrl) {
|
||||||
return { wsUrl: result.wsUrl, port };
|
return { wsUrl: result.wsUrl, port };
|
||||||
@@ -183,7 +267,7 @@ export async function launchWithCdp(
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
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.`
|
`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 preferredBrowser - Optional browser name (e.g., 'chrome', 'comet')
|
||||||
* @param port - CDP port (default 9222)
|
* @param port - CDP port (default 9222)
|
||||||
*/
|
*/
|
||||||
|
export { isManualRestart };
|
||||||
|
export type { ManualRestartNeeded };
|
||||||
|
|
||||||
export async function discoverAndConnect(
|
export async function discoverAndConnect(
|
||||||
preferredBrowser?: string,
|
preferredBrowser?: string,
|
||||||
port: number = 9222,
|
port: number = 9222,
|
||||||
): Promise<{ wsUrl: string; port: number; browser: string }> {
|
): Promise<{ wsUrl: string; port: number; browser: string } | ManualRestartNeeded> {
|
||||||
// Step 1: Check for existing CDP
|
// Step 1: Check for existing CDP
|
||||||
const existing = await findCdpPort();
|
const existing = await findCdpPort();
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -232,7 +319,10 @@ export async function discoverAndConnect(
|
|||||||
browser = installed[0];
|
browser = installed[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Launch with CDP
|
// Step 3: Launch with CDP (may return ManualRestartNeeded)
|
||||||
const result = await launchWithCdp(browser, port);
|
const result = await launchWithCdp(browser, port);
|
||||||
|
if (isManualRestart(result)) {
|
||||||
|
return result; // Caller must handle manual restart flow
|
||||||
|
}
|
||||||
return { ...result, browser: browser.name };
|
return { ...result, browser: browser.name };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -348,7 +348,7 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
|||||||
// connect must be handled BEFORE ensureServer() because it needs
|
// connect must be handled BEFORE ensureServer() because it needs
|
||||||
// to restart the server with CDP env vars.
|
// to restart the server with CDP env vars.
|
||||||
if (command === 'connect') {
|
if (command === 'connect') {
|
||||||
const { discoverAndConnect } = await import('./chrome-launcher');
|
const { discoverAndConnect, isManualRestart, detectRuntime, isCdpAvailable } = await import('./chrome-launcher');
|
||||||
|
|
||||||
// Parse args: connect [browser] [--port N]
|
// Parse args: connect [browser] [--port N]
|
||||||
let preferredBrowser: string | undefined;
|
let preferredBrowser: string | undefined;
|
||||||
@@ -378,9 +378,54 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Discover and connect to browser
|
// Discover and connect to browser
|
||||||
console.log(`Discovering browser${preferredBrowser ? ` (${preferredBrowser})` : ''}...`);
|
const runtime = detectRuntime();
|
||||||
|
console.log(`Discovering browser${preferredBrowser ? ` (${preferredBrowser})` : ''} (runtime: ${runtime})...`);
|
||||||
try {
|
try {
|
||||||
const result = await discoverAndConnect(preferredBrowser, port);
|
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}`);
|
console.log(`Found ${result.browser} CDP at port ${result.port}`);
|
||||||
|
|
||||||
// Start server with CDP env vars
|
// 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 ─────────────────────────────
|
// ─── BrowserManager CDP mode guards ─────────────────────────────
|
||||||
|
|
||||||
describe('BrowserManager CDP mode', () => {
|
describe('BrowserManager CDP mode', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user