From 4e6c38c758b0998b54f6ee69508006b22f7fb08b Mon Sep 17 00:00:00 2001 From: 47Seek <229621367+47seek@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:17:58 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(local-cli):=20AppleScript=20=E8=A2=AB?= =?UTF-8?q?=E6=8B=92=E6=97=B6=E9=99=8D=E7=BA=A7=E5=88=B0=20Launch=20Servic?= =?UTF-8?q?es=20=E6=89=93=E5=BC=80=20iTerm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit daemon 通过 PM2/launchd 后台运行时,osascript 发送 Apple事件的负责进程是 node/PM2 而非 iTerm,macOS TCC 永不弹 Automation 授权弹窗,导致 -1743 错误且自动化设置里找不到 iTerm/Terminal。 新增 openViaCommandFile 降级路径:AppleScript 全部失败后,将命令写入临时 .command 文件并通过 `open -a iTerm` 打开。Launch Services 无需 Automation 权限,覆盖后台 daemon 场景。 影响面: - 仅影响 macOS 本机 CLI 打开流程(openLocalCliInIterm) - AppleScript 成功时行为不变,仅在全部失败后触发降级 - Linux 路径不受影响(darwin 平台检查在前) - 新增 runOpenCommand 依赖注入点,默认走 spawnSync('open', ['-a', 'iTerm', path]) 测试:新增 2 个单测覆盖降级成功与降级失败场景,原有 5 个 osascript 调用断言保持不变。vitest run --project unit test/local-cli-opener.test.ts (25 passed) + test/card-handler-open-local-cli.test.ts (19 passed)。 --- src/services/local-cli-opener.ts | 47 +++++++++++++++++++++++++++++++- test/local-cli-opener.test.ts | 21 ++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/services/local-cli-opener.ts b/src/services/local-cli-opener.ts index 47edd3a27..9712d7d0e 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 { execFile, spawnSync } from 'node:child_process'; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +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,6 +76,10 @@ 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 iTerm. + * Used when AppleScript is blocked by missing Automation permission + * (e.g. the daemon runs under PM2/launchd, so TCC never prompts). */ + runOpenCommand?: (scriptPath: string) => { ok: boolean; stderr?: string }; } const OSASCRIPT = '/usr/bin/osascript'; @@ -290,6 +297,36 @@ function terminalUnavailableMessage(errors: string[]): string { : `${base} Install iTerm or allow Automation access, then retry.`; } +function defaultRunOpenCommand(scriptPath: string): { ok: boolean; stderr?: string } { + const result = spawnSync('open', ['-a', 'iTerm', scriptPath], { timeout: 10_000 }); + return { ok: result.status === 0, stderr: result.stderr?.toString() || undefined }; +} + +/** Launch-Services fallback: write the command to a .command file and open it + * with iTerm via `open -a iTerm`. This does not require Automation permission, + * so it works when the daemon runs under PM2/launchd and AppleScript can never + * get TCC authorization. */ +function openViaCommandFile( + command: string, + runOpen: LocalCliOpenerDeps['runOpenCommand'], +): LocalCliOpenResult { + let dir: string | undefined; + try { + dir = mkdtempSync(join(tmpdir(), 'botmux-open-')); + const scriptPath = join(dir, 'open-cli.command'); + writeFileSync(scriptPath, `#!/bin/bash\n${command}\n`); + chmodSync(scriptPath, 0o755); + const openFn = runOpen ?? defaultRunOpenCommand; + const opened = openFn(scriptPath); + if (opened.ok) return { ok: true, command }; + try { rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } + return fail('terminal_unavailable', opened.stderr || 'open -a iTerm failed'); + } catch (err) { + if (dir) { try { rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } } + return fail('terminal_unavailable', `Failed to create command file: ${err instanceof Error ? err.message : String(err)}`); + } +} + export async function openLocalCliInIterm( ds: DaemonSession, deps: LocalCliOpenerDeps & { cliId?: CliId } = {}, @@ -316,5 +353,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 .command file with iTerm, which + // needs no Automation permission. This is essential when the daemon runs + // under PM2/launchd where TCC never prompts for a terminal app. + const fallback = 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..d6325c33b 100644 --- a/test/local-cli-opener.test.ts +++ b/test/local-cli-opener.test.ts @@ -411,10 +411,12 @@ 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 runOpenCommand = vi.fn(() => ({ ok: false, stderr: 'open failed' })); const result = await openLocalCliInIterm(ds(), { platform: 'darwin', mode: 'resume', runOsascript, + runOpenCommand, adapterFactory: () => ({ buildResumeCommand: () => 'codex resume sid' }), }); @@ -422,6 +424,25 @@ describe('local-cli-opener', () => { expect(!result.ok && result.error).toBe('terminal_unavailable'); expect(!result.ok && result.message).toContain('Terminal.app'); expect(runOsascript).toHaveBeenCalledTimes(5); + expect(runOpenCommand).toHaveBeenCalledTimes(1); + }); + + it('falls back to Launch Services (open -a iTerm) when AppleScript is blocked', async () => { + const runOsascript = vi.fn(async () => ({ ok: false, stderr: 'automation denied (-1743)' })); + const runOpenCommand = vi.fn(() => ({ ok: true })); + const result = await openLocalCliInIterm(ds(), { + 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 scriptPath = runOpenCommand.mock.calls[0][0]; + expect(scriptPath).toMatch(/botmux-open-.*open-cli\.command$/); }); it('launches TRAE in iTerm with traex resume instead of URL schemes', async () => { From c03a7a194ada68aed64db67787401f8cb834ce96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Thu, 16 Jul 2026 19:48:20 -0700 Subject: [PATCH 2/3] =?UTF-8?q?fix(local-cli):=20=E5=AE=8C=E5=96=84=20Laun?= =?UTF-8?q?ch=20Services=20=E9=99=8D=E7=BA=A7=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/local-cli-opener.ts | 127 ++++++++++++++++++++------ test/local-cli-opener.test.ts | 150 +++++++++++++++++++++++++------ 2 files changed, 222 insertions(+), 55 deletions(-) diff --git a/src/services/local-cli-opener.ts b/src/services/local-cli-opener.ts index 9712d7d0e..a02cb6789 100644 --- a/src/services/local-cli-opener.ts +++ b/src/services/local-cli-opener.ts @@ -1,5 +1,5 @@ -import { execFile, spawnSync } from 'node:child_process'; -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +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'; @@ -76,13 +76,21 @@ 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 iTerm. + /** 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?: (scriptPath: string) => { ok: boolean; stderr?: string }; + 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 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"', @@ -291,40 +299,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(scriptPath: string): { ok: boolean; stderr?: string } { - const result = spawnSync('open', ['-a', 'iTerm', scriptPath], { timeout: 10_000 }); - return { ok: result.status === 0, stderr: result.stderr?.toString() || undefined }; +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 name.startsWith(COMMAND_FILE_PREFIX) || 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 via `open -a iTerm`. This does not require Automation permission, - * so it works when the daemon runs under PM2/launchd and AppleScript can never - * get TCC authorization. */ -function openViaCommandFile( + * 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'], -): LocalCliOpenResult { +): Promise { + const root = tmpdir(); + await cleanupStaleCommandDirs(root); + let dir: string | undefined; try { - dir = mkdtempSync(join(tmpdir(), 'botmux-open-')); + dir = await mkdtemp(join(root, COMMAND_FILE_PREFIX)); const scriptPath = join(dir, 'open-cli.command'); - writeFileSync(scriptPath, `#!/bin/bash\n${command}\n`); - chmodSync(scriptPath, 0o755); - const openFn = runOpen ?? defaultRunOpenCommand; - const opened = openFn(scriptPath); - if (opened.ok) return { ok: true, command }; - try { rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } - return fail('terminal_unavailable', opened.stderr || 'open -a iTerm failed'); + 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) { try { rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } } + 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( @@ -354,10 +425,10 @@ export async function openLocalCliInIterm( } // AppleScript failed (typically -1743 Automation permission denied). - // Fall back to Launch Services: open a .command file with iTerm, which - // needs no Automation permission. This is essential when the daemon runs - // under PM2/launchd where TCC never prompts for a terminal app. - const fallback = openViaCommandFile(built.command, deps.runOpenCommand); + // 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); diff --git a/test/local-cli-opener.test.ts b/test/local-cli-opener.test.ts index d6325c33b..9052aeb42 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, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, utimesSync } 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,39 +425,120 @@ 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 runOpenCommand = vi.fn(() => ({ ok: false, stderr: 'open failed' })); - const result = await openLocalCliInIterm(ds(), { - platform: 'darwin', - mode: 'resume', - runOsascript, - runOpenCommand, - 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); - expect(runOpenCommand).toHaveBeenCalledTimes(1); + 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 Launch Services (open -a iTerm) when AppleScript is blocked', async () => { - const runOsascript = vi.fn(async () => ({ ok: false, stderr: 'automation denied (-1743)' })); - const runOpenCommand = vi.fn(() => ({ ok: true })); - const result = await openLocalCliInIterm(ds(), { - platform: 'darwin', - mode: 'resume', - runOsascript, - runOpenCommand, - adapterFactory: () => ({ buildResumeCommand: () => 'codex resume sid' }), + 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]); }); + }); - expect(result.ok).toBe(true); - expect(runOsascript).toHaveBeenCalledTimes(5); - expect(runOpenCommand).toHaveBeenCalledTimes(1); - const scriptPath = runOpenCommand.mock.calls[0][0]; - expect(scriptPath).toMatch(/botmux-open-.*open-cli\.command$/); + 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 old = new Date(Date.now() - 48 * 60 * 60 * 1000); + utimesSync(stale, old, old); + utimesSync(legacyStale, old, old); + utimesSync(unrelated, 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); + }); }); it('launches TRAE in iTerm with traex resume instead of URL schemes', async () => { From faf6177c93bedd7386399c3f3430403578f4bebf Mon Sep 17 00:00:00 2001 From: 47Seek <229621367+47seek@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:22:12 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(local-cli):=20=E6=94=B6=E7=B4=A7?= =?UTF-8?q?=E4=B8=B4=E6=97=B6=E7=9B=AE=E5=BD=95=E6=B8=85=E7=90=86=E8=8C=83?= =?UTF-8?q?=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/local-cli-opener.ts | 3 ++- test/local-cli-opener.test.ts | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/services/local-cli-opener.ts b/src/services/local-cli-opener.ts index a02cb6789..1fa50c404 100644 --- a/src/services/local-cli-opener.ts +++ b/src/services/local-cli-opener.ts @@ -89,6 +89,7 @@ const OPEN_TARGETS = [ { 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 = [ @@ -322,7 +323,7 @@ async function removeCommandDir(dir: string): Promise { } function isCommandDirName(name: string): boolean { - return name.startsWith(COMMAND_FILE_PREFIX) || LEGACY_COMMAND_DIR_PATTERN.test(name); + return COMMAND_DIR_PATTERN.test(name) || LEGACY_COMMAND_DIR_PATTERN.test(name); } async function cleanupStaleCommandDirs(root: string, now: number = Date.now()): Promise { diff --git a/test/local-cli-opener.test.ts b/test/local-cli-opener.test.ts index 9052aeb42..a11b7d357 100644 --- a/test/local-cli-opener.test.ts +++ b/test/local-cli-opener.test.ts @@ -4,7 +4,7 @@ */ import { describe, it, expect, vi } from 'vitest'; import { execFileSync } from 'node:child_process'; -import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, utimesSync } from 'node:fs'; +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 { @@ -520,10 +520,15 @@ describe('local-cli-opener', () => { 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', @@ -538,6 +543,7 @@ describe('local-cli-opener', () => { expect(existsSync(legacyStale)).toBe(false); expect(existsSync(recent)).toBe(true); expect(existsSync(unrelated)).toBe(true); + expect(existsSync(sentinel)).toBe(true); }); });