diff --git a/src/services/local-cli-opener.ts b/src/services/local-cli-opener.ts index 47edd3a27..1fa50c404 100644 --- a/src/services/local-cli-opener.ts +++ b/src/services/local-cli-opener.ts @@ -1,4 +1,7 @@ import { execFile } from 'node:child_process'; +import { chmod, lstat, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import type { CliAdapter, CliId } from '../adapters/cli/types.js'; import { createCliAdapterSync } from '../adapters/cli/registry.js'; import { localTerminalCapable } from '../core/local-terminal-opener.js'; @@ -73,9 +76,22 @@ export interface LocalCliOpenerDeps { mode?: LocalCliOpenMode; adapterFactory?: (cliId: LocalCliId) => Pick; runOsascript?: (args: string[]) => Promise<{ ok: boolean; stderr?: string }>; + /** Launch-Services fallback: opens a .command file with a terminal app. + * Used when AppleScript is blocked by missing Automation permission + * (e.g. the daemon runs under PM2/launchd, so TCC never prompts). */ + runOpenCommand?: (args: string[]) => Promise<{ ok: boolean; stderr?: string }>; } const OSASCRIPT = '/usr/bin/osascript'; +const OPEN = '/usr/bin/open'; +const OPEN_TARGETS = [ + { label: 'iTerm', bundleId: 'com.googlecode.iterm2' }, + { label: 'Terminal.app', bundleId: 'com.apple.Terminal' }, +] as const; +const COMMAND_FILE_PREFIX = 'botmux-open-command-'; +const COMMAND_DIR_PATTERN = /^botmux-open-command-[A-Za-z0-9]{6}$/; +const LEGACY_COMMAND_DIR_PATTERN = /^botmux-open-[A-Za-z0-9]{6}$/; +const COMMAND_FILE_TTL_MS = 24 * 60 * 60 * 1000; const ITERM_TARGETS = [ 'application "/Applications/iTerm.app"', 'application id "com.googlecode.iterm2"', @@ -284,10 +300,103 @@ function defaultRunOsascript(args: string[]): Promise<{ ok: boolean; stderr?: st function terminalUnavailableMessage(errors: string[]): string { const detail = [...errors].reverse().find((e) => e.trim().length > 0); - const base = 'Neither iTerm nor Terminal.app could be opened with AppleScript.'; + const base = 'Neither iTerm nor Terminal.app could be opened with AppleScript or Launch Services.'; return detail - ? `${base} Install iTerm or allow Automation access, then retry. Last error: ${detail}` - : `${base} Install iTerm or allow Automation access, then retry.`; + ? `${base} Ensure a supported terminal is available or allow Automation access, then retry. Last error: ${detail}` + : `${base} Ensure a supported terminal is available or allow Automation access, then retry.`; +} + +function defaultRunOpenCommand(args: string[]): Promise<{ ok: boolean; stderr?: string }> { + return new Promise((resolve) => { + execFile(OPEN, args, { timeout: 10_000 }, (err, _stdout, stderr) => { + resolve({ ok: !err, stderr: stderr?.trim() || (err ? String(err) : undefined) }); + }); + }); +} + +async function removeCommandDir(dir: string): Promise { + try { + await rm(dir, { recursive: true, force: true }); + } catch { + // Best-effort cleanup: a stale sweep on a later launch gets another chance. + } +} + +function isCommandDirName(name: string): boolean { + return COMMAND_DIR_PATTERN.test(name) || LEGACY_COMMAND_DIR_PATTERN.test(name); +} + +async function cleanupStaleCommandDirs(root: string, now: number = Date.now()): Promise { + let entries; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (!entry.isDirectory() || !isCommandDirName(entry.name)) continue; + const dir = join(root, entry.name); + try { + const info = await lstat(dir); + if (now - info.mtimeMs >= COMMAND_FILE_TTL_MS) await removeCommandDir(dir); + } catch { + // The directory may have been removed by the launched script. + } + } +} + +function scheduleCommandDirCleanup(dir: string): void { + const timer = setTimeout(() => { + void removeCommandDir(dir); + }, COMMAND_FILE_TTL_MS); + timer.unref(); +} + +/** Launch-Services fallback: write the command to a .command file and open it + * with iTerm or Terminal.app. The script removes its own private temp directory + * only after the terminal begins executing it, avoiding a race with the + * asynchronous Launch Services request and iTerm's confirmation dialog. */ +async function openViaCommandFile( + command: string, + runOpen: LocalCliOpenerDeps['runOpenCommand'], +): Promise { + const root = tmpdir(); + await cleanupStaleCommandDirs(root); + + let dir: string | undefined; + try { + dir = await mkdtemp(join(root, COMMAND_FILE_PREFIX)); + const scriptPath = join(dir, 'open-cli.command'); + const cleanup = `/bin/rm -rf -- ${shellQuote(dir)} >/dev/null 2>&1 || true`; + // Launch Services may start the script with its containing directory as cwd. + // Leave it before self-deleting so attach commands do not inherit a removed cwd. + await writeFile(scriptPath, `#!/bin/bash\ncd /\n${cleanup}\n${command}\n`, { mode: 0o700 }); + await chmod(scriptPath, 0o700); + } catch (err) { + if (dir) await removeCommandDir(dir); + return fail('terminal_unavailable', `Failed to create command file: ${err instanceof Error ? err.message : String(err)}`); + } + + const scriptPath = join(dir, 'open-cli.command'); + const openFn = runOpen ?? defaultRunOpenCommand; + const launchErrors: string[] = []; + for (const target of OPEN_TARGETS) { + let opened: { ok: boolean; stderr?: string }; + try { + opened = await openFn(['-b', target.bundleId, scriptPath]); + } catch (err) { + opened = { ok: false, stderr: err instanceof Error ? err.message : String(err) }; + } + if (opened.ok) { + scheduleCommandDirCleanup(dir); + return { ok: true, command }; + } + launchErrors.push(`${target.label}: ${opened.stderr?.trim() || `${OPEN} failed`}`); + } + + await removeCommandDir(dir); + return fail('terminal_unavailable', launchErrors.join('; ')); } export async function openLocalCliInIterm( @@ -316,5 +425,13 @@ export async function openLocalCliInIterm( if (launched.stderr) errors.push(launched.stderr); } + // AppleScript failed (typically -1743 Automation permission denied). + // Fall back to Launch Services: open a self-cleaning .command file with iTerm + // or Terminal.app. This does not need Automation permission, which covers + // PM2/launchd environments where TCC never prompts for the daemon process. + const fallback = await openViaCommandFile(built.command, deps.runOpenCommand); + if (fallback.ok) return fallback; + errors.push(fallback.message); + return fail('terminal_unavailable', terminalUnavailableMessage(errors)); } diff --git a/test/local-cli-opener.test.ts b/test/local-cli-opener.test.ts index 3e3725d2b..a11b7d357 100644 --- a/test/local-cli-opener.test.ts +++ b/test/local-cli-opener.test.ts @@ -3,6 +3,10 @@ * Run: pnpm vitest run test/local-cli-opener.test.ts */ import { describe, it, expect, vi } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; import { appleScriptQuote, buildItermAppleScript, @@ -19,6 +23,17 @@ import { import { createCliAdapterSync } from '../src/adapters/cli/registry.js'; import type { DaemonSession } from '../src/core/types.js'; +async function withCommandFileTempRoot(run: (root: string) => Promise): Promise { + const root = mkdtempSync(join(tmpdir(), 'botmux-local-cli-opener-test-')); + vi.stubEnv('TMPDIR', root); + try { + return await run(root); + } finally { + vi.unstubAllEnvs(); + rmSync(root, { recursive: true, force: true }); + } +} + function ds(overrides: Partial = {}): DaemonSession { return { larkAppId: 'app_test', @@ -410,18 +425,126 @@ describe('local-cli-opener', () => { }); it('reports a local terminal error when neither iTerm nor Terminal.app can be opened', async () => { - const runOsascript = vi.fn(async () => ({ ok: false, stderr: 'automation denied' })); - const result = await openLocalCliInIterm(ds(), { - platform: 'darwin', - mode: 'resume', - runOsascript, - adapterFactory: () => ({ buildResumeCommand: () => 'codex resume sid' }), + await withCommandFileTempRoot(async () => { + const runOsascript = vi.fn(async () => ({ ok: false, stderr: 'automation denied' })); + const scriptPaths: string[] = []; + const runOpenCommand = vi.fn(async (args: string[]) => { + scriptPaths.push(args[2]); + return { ok: false, stderr: 'open failed' }; + }); + const result = await openLocalCliInIterm(ds(), { + platform: 'darwin', + mode: 'resume', + runOsascript, + runOpenCommand, + adapterFactory: () => ({ buildResumeCommand: () => 'codex resume sid' }), + }); + + expect(result.ok).toBe(false); + expect(!result.ok && result.error).toBe('terminal_unavailable'); + expect(!result.ok && result.message).toContain('AppleScript or Launch Services'); + expect(!result.ok && result.message).toContain('iTerm: open failed'); + expect(!result.ok && result.message).toContain('Terminal.app: open failed'); + expect(runOsascript).toHaveBeenCalledTimes(5); + expect(runOpenCommand).toHaveBeenCalledTimes(2); + expect(runOpenCommand.mock.calls.map(([args]) => args.slice(0, 2))).toEqual([ + ['-b', 'com.googlecode.iterm2'], + ['-b', 'com.apple.Terminal'], + ]); + expect(scriptPaths[0]).toBe(scriptPaths[1]); + expect(existsSync(dirname(scriptPaths[0]))).toBe(false); }); + }); - expect(result.ok).toBe(false); - expect(!result.ok && result.error).toBe('terminal_unavailable'); - expect(!result.ok && result.message).toContain('Terminal.app'); - expect(runOsascript).toHaveBeenCalledTimes(5); + it('falls back to an async iTerm Launch Services request with a self-cleaning command file', async () => { + await withCommandFileTempRoot(async (root) => { + const fakeBin = mkdtempSync(join(root, 'bin-')); + symlinkSync('/usr/bin/true', join(fakeBin, 'codex')); + const runOsascript = vi.fn(async () => ({ ok: false, stderr: 'automation denied (-1743)' })); + const runOpenCommand = vi.fn(async () => ({ ok: true })); + const result = await openLocalCliInIterm(ds({ workingDir: root }), { + platform: 'darwin', + mode: 'resume', + runOsascript, + runOpenCommand, + adapterFactory: () => ({ buildResumeCommand: () => 'codex resume sid' }), + }); + + expect(result.ok).toBe(true); + expect(runOsascript).toHaveBeenCalledTimes(5); + expect(runOpenCommand).toHaveBeenCalledTimes(1); + const args = runOpenCommand.mock.calls[0][0]; + const scriptPath = args[2]; + const scriptDir = dirname(scriptPath); + expect(args.slice(0, 2)).toEqual(['-b', 'com.googlecode.iterm2']); + expect(scriptPath).toMatch(/botmux-open-command-.*open-cli\.command$/); + expect(statSync(scriptPath).mode & 0o777).toBe(0o700); + const script = readFileSync(scriptPath, 'utf8'); + const cleanup = `/bin/rm -rf -- '${scriptDir}' >/dev/null 2>&1 || true`; + expect(script).toContain('#!/bin/bash\ncd /\n'); + expect(script).toContain(cleanup); + expect(script.indexOf(cleanup)).toBeLessThan(script.indexOf("codex resume 'sid'")); + execFileSync(scriptPath, [], { env: { ...process.env, PATH: `${fakeBin}:/usr/bin:/bin` } }); + expect(existsSync(scriptDir)).toBe(false); + }); + }); + + it('falls back to Terminal.app when iTerm is unavailable through Launch Services', async () => { + await withCommandFileTempRoot(async () => { + const runOsascript = vi.fn(async () => ({ ok: false, stderr: 'automation denied (-1743)' })); + const runOpenCommand = vi + .fn() + .mockResolvedValueOnce({ ok: false, stderr: 'iTerm is not installed' }) + .mockResolvedValueOnce({ ok: true }); + const result = await openLocalCliInIterm(ds(), { + platform: 'darwin', + mode: 'resume', + runOsascript, + runOpenCommand, + adapterFactory: () => ({ buildResumeCommand: () => 'codex resume sid' }), + }); + + expect(result.ok).toBe(true); + expect(runOpenCommand).toHaveBeenCalledTimes(2); + expect(runOpenCommand.mock.calls.map(([args]) => args.slice(0, 2))).toEqual([ + ['-b', 'com.googlecode.iterm2'], + ['-b', 'com.apple.Terminal'], + ]); + expect(runOpenCommand.mock.calls[0][0][2]).toBe(runOpenCommand.mock.calls[1][0][2]); + }); + }); + + it('sweeps stale command dirs without touching similarly named botmux temp dirs', async () => { + await withCommandFileTempRoot(async (root) => { + const stale = mkdtempSync(join(root, 'botmux-open-command-')); + const legacyStale = mkdtempSync(join(root, 'botmux-open-')); + const recent = mkdtempSync(join(root, 'botmux-open-command-')); + const unrelated = mkdtempSync(join(root, 'botmux-open-local-cli-')); + const similarlyNamed = join(root, 'botmux-open-command-user-data'); + mkdirSync(similarlyNamed); + const sentinel = join(similarlyNamed, 'keep.txt'); + writeFileSync(sentinel, 'keep'); + const old = new Date(Date.now() - 48 * 60 * 60 * 1000); + utimesSync(stale, old, old); + utimesSync(legacyStale, old, old); + utimesSync(unrelated, old, old); + utimesSync(similarlyNamed, old, old); + + const result = await openLocalCliInIterm(ds(), { + platform: 'darwin', + mode: 'resume', + runOsascript: vi.fn(async () => ({ ok: false, stderr: 'automation denied' })), + runOpenCommand: vi.fn(async () => ({ ok: false, stderr: 'open failed' })), + adapterFactory: () => ({ buildResumeCommand: () => 'codex resume sid' }), + }); + + expect(result.ok).toBe(false); + expect(existsSync(stale)).toBe(false); + expect(existsSync(legacyStale)).toBe(false); + expect(existsSync(recent)).toBe(true); + expect(existsSync(unrelated)).toBe(true); + expect(existsSync(sentinel)).toBe(true); + }); }); it('launches TRAE in iTerm with traex resume instead of URL schemes', async () => {