Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 120 additions & 3 deletions src/services/local-cli-opener.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -73,9 +76,22 @@ export interface LocalCliOpenerDeps {
mode?: LocalCliOpenMode;
adapterFactory?: (cliId: LocalCliId) => Pick<CliAdapter, 'buildResumeCommand'>;
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"',
Expand Down Expand Up @@ -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<void> {
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<void> {
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<LocalCliOpenResult> {
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(
Expand Down Expand Up @@ -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));
}
143 changes: 133 additions & 10 deletions test/local-cli-opener.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,6 +23,17 @@ import {
import { createCliAdapterSync } from '../src/adapters/cli/registry.js';
import type { DaemonSession } from '../src/core/types.js';

async function withCommandFileTempRoot<T>(run: (root: string) => Promise<T>): Promise<T> {
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> = {}): DaemonSession {
return {
larkAppId: 'app_test',
Expand Down Expand Up @@ -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 () => {
Expand Down