From 8f1e8c6496884185e595840474010accb30b25ea Mon Sep 17 00:00:00 2001 From: Brandon Seaver Date: Wed, 18 Mar 2026 22:35:28 -0400 Subject: [PATCH] Fix binary path corruption from terminal shell integration escapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The binary discovery fallback uses interactive shell (-i flag) to locate claude/whisper binaries via whence/which. Interactive shells source .zshrc which loads shell integrations (iTerm2, VS Code, Warp) that emit OSC escape sequences to stdout, contaminating paths. The same issue affects cli-env.ts PATH construction. This silently breaks subprocess spawning for anyone with terminal shell integration enabled — the app renders but Claude processes never start. Changes: - Add ~/.local/bin/claude to candidates list - Drop -i flag from shell fallbacks, add timeout: 3000 - Extract shared stripShellEscapes() and extractAbsoluteShellPath() helpers in cli-env.ts; use across all binary finders - Fix whisper binary lookup in index.ts (same escape contamination) --- src/main/claude/pty-run-manager.ts | 12 +++++-- src/main/claude/run-manager.ts | 12 +++++-- src/main/cli-env.ts | 52 ++++++++++++++++++++++++++---- src/main/index.ts | 8 +++-- src/main/process-manager.ts | 11 ++++--- 5 files changed, 76 insertions(+), 19 deletions(-) diff --git a/src/main/claude/pty-run-manager.ts b/src/main/claude/pty-run-manager.ts index be217232..c027d551 100644 --- a/src/main/claude/pty-run-manager.ts +++ b/src/main/claude/pty-run-manager.ts @@ -21,7 +21,7 @@ import { join } from 'path' import { execSync } from 'child_process' import { appendFileSync, chmodSync, existsSync, statSync } from 'fs' import type { NormalizedEvent, RunOptions, EnrichedError } from '../../shared/types' -import { getCliEnv } from '../cli-env' +import { extractAbsoluteShellPath, getCliEnv } from '../cli-env' // node-pty is a native module — require at runtime to avoid Vite bundling issues // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -312,6 +312,7 @@ export class PtyRunManager extends EventEmitter { private _findClaudeBinary(): string { const candidates = [ + join(homedir(), '.local/bin/claude'), '/usr/local/bin/claude', '/opt/homebrew/bin/claude', join(homedir(), '.npm-global/bin/claude'), @@ -324,12 +325,17 @@ export class PtyRunManager extends EventEmitter { } catch {} } + // Non-interactive login shell to avoid shell integration escape sequences try { - return execSync('/bin/zsh -ilc "whence -p claude"', { encoding: 'utf-8', env: getCliEnv() }).trim() + const raw = execSync('/bin/zsh -lc "whence -p claude"', { encoding: 'utf-8', env: getCliEnv(), timeout: 3000 }) + const result = extractAbsoluteShellPath(raw) + if (result) return result } catch {} try { - return execSync('/bin/bash -lc "which claude"', { encoding: 'utf-8', env: getCliEnv() }).trim() + const raw = execSync('/bin/bash -lc "which claude"', { encoding: 'utf-8', env: getCliEnv(), timeout: 3000 }) + const result = extractAbsoluteShellPath(raw) + if (result) return result } catch {} return 'claude' diff --git a/src/main/claude/run-manager.ts b/src/main/claude/run-manager.ts index 0068b69e..356f61cd 100644 --- a/src/main/claude/run-manager.ts +++ b/src/main/claude/run-manager.ts @@ -5,7 +5,7 @@ import { join } from 'path' import { StreamParser } from '../stream-parser' import { normalize } from './event-normalizer' import { log as _log } from '../logger' -import { getCliEnv } from '../cli-env' +import { extractAbsoluteShellPath, getCliEnv } from '../cli-env' import type { ClaudeEvent, NormalizedEvent, RunOptions, EnrichedError } from '../../shared/types' const MAX_RING_LINES = 100 @@ -102,6 +102,7 @@ export class RunManager extends EventEmitter { private _findClaudeBinary(): string { const candidates = [ + join(homedir(), '.local/bin/claude'), '/usr/local/bin/claude', '/opt/homebrew/bin/claude', join(homedir(), '.npm-global/bin/claude'), @@ -114,12 +115,17 @@ export class RunManager extends EventEmitter { } catch {} } + // Non-interactive login shell to avoid shell integration escape sequences try { - return execSync('/bin/zsh -ilc "whence -p claude"', { encoding: 'utf-8', env: getCliEnv() }).trim() + const raw = execSync('/bin/zsh -lc "whence -p claude"', { encoding: 'utf-8', env: getCliEnv(), timeout: 3000 }) + const result = extractAbsoluteShellPath(raw) + if (result) return result } catch {} try { - return execSync('/bin/bash -lc "which claude"', { encoding: 'utf-8', env: getCliEnv() }).trim() + const raw = execSync('/bin/bash -lc "which claude"', { encoding: 'utf-8', env: getCliEnv(), timeout: 3000 }) + const result = extractAbsoluteShellPath(raw) + if (result) return result } catch {} return 'claude' diff --git a/src/main/cli-env.ts b/src/main/cli-env.ts index 60efe067..7c1f1bd2 100644 --- a/src/main/cli-env.ts +++ b/src/main/cli-env.ts @@ -1,7 +1,39 @@ import { execSync } from 'child_process' +import { homedir } from 'os' +import { join } from 'path' let cachedPath: string | null = null +/** + * Strip terminal escape sequences (ANSI CSI, OSC, etc.) that interactive shells + * may emit via shell integrations (e.g. iTerm2, VS Code terminal). + * + * NOTE: similar to stripAnsi in pty-run-manager.ts but with additional + * patterns for bare OSC sequences common in captured shell output. + */ +function stripShellEscapes(str: string): string { + return str + .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '') // CSI sequences + .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC sequences (BEL or ST terminated) + .replace(/\x1b[()][0-9A-Za-z]/g, '') // character set selection + .replace(/\x1b[#=>\[\]]/g, '') // misc escapes + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '') // control chars except \n \r \t + .replace(/\][0-9]+;[^\x07\n]*(?:\x07)?/g, '') // bare OSC without leading ESC (common in captured output) +} + +/** Strip shell escape sequences and return the first absolute path found, or null. */ +export function extractAbsoluteShellPath(str: string): string | null { + const cleaned = stripShellEscapes(str).trim() + if (!cleaned) return null + + for (const line of cleaned.split(/\r?\n/)) { + const candidate = line.trim() + if (candidate.startsWith('/')) return candidate + } + + return null +} + function appendPathEntries(target: string[], seen: Set, rawPath: string | undefined): void { if (!rawPath) return for (const entry of rawPath.split(':')) { @@ -21,19 +53,28 @@ export function getCliPath(): string { // Start from current process PATH. appendPathEntries(ordered, seen, process.env.PATH) - // Add common binary locations used on macOS (Homebrew + system). - appendPathEntries(ordered, seen, '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin') + // Add common binary locations used on macOS (Homebrew + system + user-local). + appendPathEntries(ordered, seen, [ + join(homedir(), '.local/bin'), + '/opt/homebrew/bin', + '/usr/local/bin', + '/usr/bin', + '/bin', + '/usr/sbin', + '/sbin', + ].join(':')) - // Try interactive login shell first so nvm/asdf/etc. PATH hooks are loaded. + // Try login shell (non-interactive) so nvm/asdf/etc. PATH hooks are loaded + // without triggering shell integration escape sequences. const pathCommands = [ - '/bin/zsh -ilc "echo $PATH"', '/bin/zsh -lc "echo $PATH"', '/bin/bash -lc "echo $PATH"', ] for (const cmd of pathCommands) { try { - const discovered = execSync(cmd, { encoding: 'utf-8', timeout: 3000 }).trim() + const raw = execSync(cmd, { encoding: 'utf-8', timeout: 3000 }) + const discovered = stripShellEscapes(raw).trim() appendPathEntries(ordered, seen, discovered) } catch { // Keep trying fallbacks. @@ -53,4 +94,3 @@ export function getCliEnv(extraEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { delete env.CLAUDECODE return env } - diff --git a/src/main/index.ts b/src/main/index.ts index 10472226..2ded2347 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -7,7 +7,7 @@ import { ControlPlane } from './claude/control-plane' import { ensureSkills, type SkillStatus } from './skills/installer' import { fetchCatalog, listInstalled, installPlugin, uninstallPlugin } from './marketplace/catalog' import { log as _log, LOG_FILE, flushLogs } from './logger' -import { getCliEnv } from './cli-env' +import { extractAbsoluteShellPath, getCliEnv } from './cli-env' import { IPC } from '../shared/types' import type { RunOptions, NormalizedEvent, EnrichedError } from '../shared/types' @@ -672,12 +672,14 @@ ipcMain.handle(IPC.TRANSCRIBE_AUDIO, async (_event, audioBase64: string) => { if (!whisperBin) { try { - whisperBin = execSync('/bin/zsh -lc "whence -p whisper-cli"', { encoding: 'utf-8' }).trim() + const raw = execSync('/bin/zsh -lc "whence -p whisper-cli"', { encoding: 'utf-8', env: getCliEnv(), timeout: 3000 }) + whisperBin = extractAbsoluteShellPath(raw) || '' } catch {} } if (!whisperBin) { try { - whisperBin = execSync('/bin/zsh -lc "whence -p whisper"', { encoding: 'utf-8' }).trim() + const raw = execSync('/bin/zsh -lc "whence -p whisper"', { encoding: 'utf-8', env: getCliEnv(), timeout: 3000 }) + whisperBin = extractAbsoluteShellPath(raw) || '' } catch {} } diff --git a/src/main/process-manager.ts b/src/main/process-manager.ts index add7af0f..6502aeef 100644 --- a/src/main/process-manager.ts +++ b/src/main/process-manager.ts @@ -4,7 +4,7 @@ import { homedir } from 'os' import { appendFileSync } from 'fs' import { join } from 'path' import { StreamParser } from './stream-parser' -import { getCliEnv } from './cli-env' +import { extractAbsoluteShellPath, getCliEnv } from './cli-env' import type { ClaudeEvent, RunOptions } from '../shared/types' const LOG_FILE = join(homedir(), '.clui-debug.log') @@ -38,6 +38,7 @@ export class ProcessManager extends EventEmitter { private findClaudeBinary(): string { // Try common locations const candidates = [ + join(homedir(), '.local/bin/claude'), '/usr/local/bin/claude', '/opt/homebrew/bin/claude', join(homedir(), '.npm-global/bin/claude'), @@ -51,14 +52,16 @@ export class ProcessManager extends EventEmitter { } catch {} } - // Fallback: ask a login shell + // Non-interactive login shell to avoid shell integration escape sequences try { - const result = execSync('/bin/zsh -ilc "whence -p claude"', { encoding: 'utf-8', env: getCliEnv() }).trim() + const raw = execSync('/bin/zsh -lc "whence -p claude"', { encoding: 'utf-8', env: getCliEnv(), timeout: 3000 }) + const result = extractAbsoluteShellPath(raw) if (result) return result } catch {} try { - const result = execSync('/bin/bash -lc "which claude"', { encoding: 'utf-8', env: getCliEnv() }).trim() + const raw = execSync('/bin/bash -lc "which claude"', { encoding: 'utf-8', env: getCliEnv(), timeout: 3000 }) + const result = extractAbsoluteShellPath(raw) if (result) return result } catch {}