/ utils / Shell.ts
Shell.ts
  1  import { execFileSync, spawn } from 'child_process'
  2  import { constants as fsConstants, readFileSync, unlinkSync } from 'fs'
  3  import { type FileHandle, mkdir, open, realpath } from 'fs/promises'
  4  import memoize from 'lodash-es/memoize.js'
  5  import { isAbsolute, resolve } from 'path'
  6  import { join as posixJoin } from 'path/posix'
  7  import { logEvent } from 'src/services/analytics/index.js'
  8  import {
  9    getOriginalCwd,
 10    getSessionId,
 11    setCwdState,
 12  } from '../bootstrap/state.js'
 13  import { generateTaskId } from '../Task.js'
 14  import { pwd } from './cwd.js'
 15  import { logForDebugging } from './debug.js'
 16  import { errorMessage, isENOENT } from './errors.js'
 17  import { getFsImplementation } from './fsOperations.js'
 18  import { logError } from './log.js'
 19  import {
 20    createAbortedCommand,
 21    createFailedCommand,
 22    type ShellCommand,
 23    wrapSpawn,
 24  } from './ShellCommand.js'
 25  import { getTaskOutputDir } from './task/diskOutput.js'
 26  import { TaskOutput } from './task/TaskOutput.js'
 27  import { which } from './which.js'
 28  
 29  export type { ExecResult } from './ShellCommand.js'
 30  
 31  import { accessSync } from 'fs'
 32  import { onCwdChangedForHooks } from './hooks/fileChangedWatcher.js'
 33  import { getClaudeTempDirName } from './permissions/filesystem.js'
 34  import { getPlatform } from './platform.js'
 35  import { SandboxManager } from './sandbox/sandbox-adapter.js'
 36  import { invalidateSessionEnvCache } from './sessionEnvironment.js'
 37  import { createBashShellProvider } from './shell/bashProvider.js'
 38  import { getCachedPowerShellPath } from './shell/powershellDetection.js'
 39  import { createPowerShellProvider } from './shell/powershellProvider.js'
 40  import type { ShellProvider, ShellType } from './shell/shellProvider.js'
 41  import { subprocessEnv } from './subprocessEnv.js'
 42  import { posixPathToWindowsPath } from './windowsPaths.js'
 43  
 44  const DEFAULT_TIMEOUT = 30 * 60 * 1000 // 30 minutes
 45  
 46  export type ShellConfig = {
 47    provider: ShellProvider
 48  }
 49  
 50  function isExecutable(shellPath: string): boolean {
 51    try {
 52      accessSync(shellPath, fsConstants.X_OK)
 53      return true
 54    } catch (_err) {
 55      // Fallback for Nix and other environments where X_OK check might fail
 56      try {
 57        // Try to execute the shell with --version, which should exit quickly
 58        // Use execFileSync to avoid shell injection vulnerabilities
 59        execFileSync(shellPath, ['--version'], {
 60          timeout: 1000,
 61          stdio: 'ignore',
 62        })
 63        return true
 64      } catch {
 65        return false
 66      }
 67    }
 68  }
 69  
 70  /**
 71   * Determines the best available shell to use.
 72   */
 73  export async function findSuitableShell(): Promise<string> {
 74    // Check for explicit shell override first
 75    const shellOverride = process.env.CLAUDE_CODE_SHELL
 76    if (shellOverride) {
 77      // Validate it's a supported shell type
 78      const isSupported =
 79        shellOverride.includes('bash') || shellOverride.includes('zsh')
 80      if (isSupported && isExecutable(shellOverride)) {
 81        logForDebugging(`Using shell override: ${shellOverride}`)
 82        return shellOverride
 83      } else {
 84        // Note, if we ever want to add support for new shells here we'll need to update or Bash tool parsing to account for this
 85        logForDebugging(
 86          `CLAUDE_CODE_SHELL="${shellOverride}" is not a valid bash/zsh path, falling back to detection`,
 87        )
 88      }
 89    }
 90  
 91    // Check user's preferred shell from environment
 92    const env_shell = process.env.SHELL
 93    // Only consider SHELL if it's bash or zsh
 94    const isEnvShellSupported =
 95      env_shell && (env_shell.includes('bash') || env_shell.includes('zsh'))
 96    const preferBash = env_shell?.includes('bash')
 97  
 98    // Try to locate shells using which (uses Bun.which when available)
 99    const [zshPath, bashPath] = await Promise.all([which('zsh'), which('bash')])
100  
101    // Populate shell paths from which results and fallback locations
102    const shellPaths = ['/bin', '/usr/bin', '/usr/local/bin', '/opt/homebrew/bin']
103  
104    // Order shells based on user preference
105    const shellOrder = preferBash ? ['bash', 'zsh'] : ['zsh', 'bash']
106    const supportedShells = shellOrder.flatMap(shell =>
107      shellPaths.map(path => `${path}/${shell}`),
108    )
109  
110    // Add discovered paths to the beginning of our search list
111    // Put the user's preferred shell type first
112    if (preferBash) {
113      if (bashPath) supportedShells.unshift(bashPath)
114      if (zshPath) supportedShells.push(zshPath)
115    } else {
116      if (zshPath) supportedShells.unshift(zshPath)
117      if (bashPath) supportedShells.push(bashPath)
118    }
119  
120    // Always prioritize SHELL env variable if it's a supported shell type
121    if (isEnvShellSupported && isExecutable(env_shell)) {
122      supportedShells.unshift(env_shell)
123    }
124  
125    const shellPath = supportedShells.find(shell => shell && isExecutable(shell))
126  
127    // If no valid shell found, throw a helpful error
128    if (!shellPath) {
129      const errorMsg =
130        'No suitable shell found. Claude CLI requires a Posix shell environment. ' +
131        'Please ensure you have a valid shell installed and the SHELL environment variable set.'
132      logError(new Error(errorMsg))
133      throw new Error(errorMsg)
134    }
135  
136    return shellPath
137  }
138  
139  async function getShellConfigImpl(): Promise<ShellConfig> {
140    const binShell = await findSuitableShell()
141    const provider = await createBashShellProvider(binShell)
142    return { provider }
143  }
144  
145  // Memoize the entire shell config so it only happens once per session
146  export const getShellConfig = memoize(getShellConfigImpl)
147  
148  export const getPsProvider = memoize(async (): Promise<ShellProvider> => {
149    const psPath = await getCachedPowerShellPath()
150    if (!psPath) {
151      throw new Error('PowerShell is not available')
152    }
153    return createPowerShellProvider(psPath)
154  })
155  
156  const resolveProvider: Record<ShellType, () => Promise<ShellProvider>> = {
157    bash: async () => (await getShellConfig()).provider,
158    powershell: getPsProvider,
159  }
160  
161  export type ExecOptions = {
162    timeout?: number
163    onProgress?: (
164      lastLines: string,
165      allLines: string,
166      totalLines: number,
167      totalBytes: number,
168      isIncomplete: boolean,
169    ) => void
170    preventCwdChanges?: boolean
171    shouldUseSandbox?: boolean
172    shouldAutoBackground?: boolean
173    /** When provided, stdout is piped (not sent to file) and this callback fires on each data chunk. */
174    onStdout?: (data: string) => void
175  }
176  
177  /**
178   * Execute a shell command using the environment snapshot
179   * Creates a new shell process for each command execution
180   */
181  export async function exec(
182    command: string,
183    abortSignal: AbortSignal,
184    shellType: ShellType,
185    options?: ExecOptions,
186  ): Promise<ShellCommand> {
187    const {
188      timeout,
189      onProgress,
190      preventCwdChanges,
191      shouldUseSandbox,
192      shouldAutoBackground,
193      onStdout,
194    } = options ?? {}
195    const commandTimeout = timeout || DEFAULT_TIMEOUT
196  
197    const provider = await resolveProvider[shellType]()
198  
199    const id = Math.floor(Math.random() * 0x10000)
200      .toString(16)
201      .padStart(4, '0')
202  
203    // Sandbox temp directory - use per-user directory name to prevent multi-user permission conflicts
204    const sandboxTmpDir = posixJoin(
205      process.env.CLAUDE_CODE_TMPDIR || '/tmp',
206      getClaudeTempDirName(),
207    )
208  
209    const { commandString: builtCommand, cwdFilePath } =
210      await provider.buildExecCommand(command, {
211        id,
212        sandboxTmpDir: shouldUseSandbox ? sandboxTmpDir : undefined,
213        useSandbox: shouldUseSandbox ?? false,
214      })
215  
216    let commandString = builtCommand
217  
218    let cwd = pwd()
219  
220    // Recover if the current working directory no longer exists on disk.
221    // This can happen when a command deletes its own CWD (e.g., temp dir cleanup).
222    try {
223      await realpath(cwd)
224    } catch {
225      const fallback = getOriginalCwd()
226      logForDebugging(
227        `Shell CWD "${cwd}" no longer exists, recovering to "${fallback}"`,
228      )
229      try {
230        await realpath(fallback)
231        setCwdState(fallback)
232        cwd = fallback
233      } catch {
234        return createFailedCommand(
235          `Working directory "${cwd}" no longer exists. Please restart Claude from an existing directory.`,
236        )
237      }
238    }
239  
240    // If already aborted, don't spawn the process at all
241    if (abortSignal.aborted) {
242      return createAbortedCommand()
243    }
244  
245    const binShell = provider.shellPath
246  
247    // Sandboxed PowerShell: wrapWithSandbox hardcodes `<binShell> -c '<cmd>'` —
248    // using pwsh there would lose -NoProfile -NonInteractive (profile load
249    // inside sandbox → delays, stray output, may hang on prompts). Instead:
250    //   • powershellProvider.buildExecCommand (useSandbox) pre-wraps as
251    //     `pwsh -NoProfile -NonInteractive -EncodedCommand <base64>` — base64
252    //     survives the runtime's shellquote.quote() layer
253    //   • pass /bin/sh as the sandbox's inner shell to exec that invocation
254    //   • outer spawn is also /bin/sh -c to parse the runtime's POSIX output
255    // /bin/sh exists on every platform where sandbox is supported.
256    const isSandboxedPowerShell = shouldUseSandbox && shellType === 'powershell'
257    const sandboxBinShell = isSandboxedPowerShell ? '/bin/sh' : binShell
258  
259    if (shouldUseSandbox) {
260      commandString = await SandboxManager.wrapWithSandbox(
261        commandString,
262        sandboxBinShell,
263        undefined,
264        abortSignal,
265      )
266      // Create sandbox temp directory for sandboxed processes with secure permissions
267      try {
268        const fs = getFsImplementation()
269        await fs.mkdir(sandboxTmpDir, { mode: 0o700 })
270      } catch (error) {
271        logForDebugging(`Failed to create ${sandboxTmpDir} directory: ${error}`)
272      }
273    }
274  
275    const spawnBinary = isSandboxedPowerShell ? '/bin/sh' : binShell
276    const shellArgs = isSandboxedPowerShell
277      ? ['-c', commandString]
278      : provider.getSpawnArgs(commandString)
279    const envOverrides = await provider.getEnvironmentOverrides(command)
280  
281    // When onStdout is provided, use pipe mode: stdout flows through
282    // StreamWrapper → TaskOutput in-memory buffer instead of a file fd.
283    // This lets callers receive real-time stdout callbacks.
284    const usePipeMode = !!onStdout
285    const taskId = generateTaskId('local_bash')
286    const taskOutput = new TaskOutput(taskId, onProgress ?? null, !usePipeMode)
287    await mkdir(getTaskOutputDir(), { recursive: true })
288  
289    // In file mode, both stdout and stderr go to the same file fd.
290    // On POSIX, O_APPEND makes each write atomic (seek-to-end + write), so
291    // stdout and stderr are interleaved chronologically without tearing.
292    // On Windows, 'a' mode strips FILE_WRITE_DATA (only grants FILE_APPEND_DATA)
293    // via libuv's fs__open. MSYS2/Cygwin probes inherited handles with
294    // NtQueryInformationFile(FileAccessInformation) and treats handles without
295    // FILE_WRITE_DATA as read-only, silently discarding all output. Using 'w'
296    // grants FILE_GENERIC_WRITE. Atomicity is preserved because duplicated
297    // handles share the same FILE_OBJECT with FILE_SYNCHRONOUS_IO_NONALERT,
298    // which serializes all I/O through a single kernel lock.
299    // SECURITY: O_NOFOLLOW prevents symlink-following attacks from the sandbox.
300    // On Windows, use string flags — numeric flags can produce EINVAL through libuv.
301    let outputHandle: FileHandle | undefined
302    if (!usePipeMode) {
303      const O_NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0
304      outputHandle = await open(
305        taskOutput.path,
306        process.platform === 'win32'
307          ? 'w'
308          : fsConstants.O_WRONLY |
309              fsConstants.O_CREAT |
310              fsConstants.O_APPEND |
311              O_NOFOLLOW,
312      )
313    }
314  
315    try {
316      const childProcess = spawn(spawnBinary, shellArgs, {
317        env: {
318          ...subprocessEnv(),
319          SHELL: shellType === 'bash' ? binShell : undefined,
320          GIT_EDITOR: 'true',
321          CLAUDECODE: '1',
322          ...envOverrides,
323          ...(process.env.USER_TYPE === 'ant'
324            ? {
325                CLAUDE_CODE_SESSION_ID: getSessionId(),
326              }
327            : {}),
328        },
329        cwd,
330        stdio: usePipeMode
331          ? ['pipe', 'pipe', 'pipe']
332          : ['pipe', outputHandle?.fd, outputHandle?.fd],
333        // Don't pass the signal - we'll handle termination ourselves with tree-kill
334        detached: provider.detached,
335        // Prevent visible console window on Windows (no-op on other platforms)
336        windowsHide: true,
337      })
338  
339      const shellCommand = wrapSpawn(
340        childProcess,
341        abortSignal,
342        commandTimeout,
343        taskOutput,
344        shouldAutoBackground,
345      )
346  
347      // Close our copy of the fd — the child has its own dup.
348      // Must happen after wrapSpawn attaches 'error' listener, since the await
349      // yields and the child's ENOENT 'error' event can fire in that window.
350      // Wrapped in its own try/catch so a close failure (e.g. EIO) doesn't fall
351      // through to the spawn-failure catch block, which would orphan the child.
352      if (outputHandle !== undefined) {
353        try {
354          await outputHandle.close()
355        } catch {
356          // fd may already be closed by the child; safe to ignore
357        }
358      }
359  
360      // In pipe mode, attach the caller's callbacks alongside StreamWrapper.
361      // Both listeners receive the same data chunks (Node.js ReadableStream supports
362      // multiple 'data' listeners). StreamWrapper feeds TaskOutput for persistence;
363      // these callbacks give the caller real-time access.
364      if (childProcess.stdout && onStdout) {
365        childProcess.stdout.on('data', (chunk: string | Buffer) => {
366          onStdout(typeof chunk === 'string' ? chunk : chunk.toString())
367        })
368      }
369  
370      // Attach cleanup to the command result
371      // NOTE: readFileSync/unlinkSync are intentional here — these must complete
372      // synchronously within the .then() microtask so that callers who
373      // `await shellCommand.result` see the updated cwd immediately after.
374      // Using async readFile would introduce a microtask boundary, causing
375      // a race where cwd hasn't been updated yet when the caller continues.
376  
377      // On Windows, cwdFilePath is a POSIX path (for bash's `pwd -P >| $path`),
378      // but Node.js needs a native Windows path for readFileSync/unlinkSync.
379      // Similarly, `pwd -P` outputs a POSIX path that must be converted before setCwd.
380      const nativeCwdFilePath =
381        getPlatform() === 'windows'
382          ? posixPathToWindowsPath(cwdFilePath)
383          : cwdFilePath
384  
385      void shellCommand.result.then(async result => {
386        // On Linux, bwrap creates 0-byte mount-point files on the host to deny
387        // writes to non-existent paths (.bashrc, HEAD, etc.). These persist after
388        // bwrap exits as ghost dotfiles in cwd. Cleanup is synchronous and a no-op
389        // on macOS. Keep before any await so callers awaiting .result see a clean
390        // working tree in the same microtask.
391        if (shouldUseSandbox) {
392          SandboxManager.cleanupAfterCommand()
393        }
394        // Only foreground tasks update the cwd
395        if (result && !preventCwdChanges && !result.backgroundTaskId) {
396          try {
397            let newCwd = readFileSync(nativeCwdFilePath, {
398              encoding: 'utf8',
399            }).trim()
400            if (getPlatform() === 'windows') {
401              newCwd = posixPathToWindowsPath(newCwd)
402            }
403            // cwd is NFC-normalized (setCwdState); newCwd from `pwd -P` may be
404            // NFD on macOS APFS. Normalize before comparing so Unicode paths
405            // don't false-positive as "changed" on every command.
406            if (newCwd.normalize('NFC') !== cwd) {
407              setCwd(newCwd, cwd)
408              invalidateSessionEnvCache()
409              void onCwdChangedForHooks(cwd, newCwd)
410            }
411          } catch {
412            logEvent('tengu_shell_set_cwd', { success: false })
413          }
414        }
415        // Clean up the temp file used for cwd tracking
416        try {
417          unlinkSync(nativeCwdFilePath)
418        } catch {
419          // File may not exist if command failed before pwd -P ran
420        }
421      })
422  
423      return shellCommand
424    } catch (error) {
425      // Close the fd if spawn failed (child never got its dup)
426      if (outputHandle !== undefined) {
427        try {
428          await outputHandle.close()
429        } catch {
430          // May already be closed
431        }
432      }
433      taskOutput.clear()
434  
435      logForDebugging(`Shell exec error: ${errorMessage(error)}`)
436  
437      return createAbortedCommand(undefined, {
438        code: 126, // Standard Unix code for execution errors
439        stderr: errorMessage(error),
440      })
441    }
442  }
443  
444  /**
445   * Set the current working directory
446   */
447  export function setCwd(path: string, relativeTo?: string): void {
448    const resolved = isAbsolute(path)
449      ? path
450      : resolve(relativeTo || getFsImplementation().cwd(), path)
451    // Resolve symlinks to match the behavior of pwd -P.
452    // realpathSync throws ENOENT if the path doesn't exist - convert to a
453    // friendlier error message instead of a separate existsSync pre-check (TOCTOU).
454    let physicalPath: string
455    try {
456      physicalPath = getFsImplementation().realpathSync(resolved)
457    } catch (e) {
458      if (isENOENT(e)) {
459        throw new Error(`Path "${resolved}" does not exist`)
460      }
461      throw e
462    }
463  
464    setCwdState(physicalPath)
465    if (process.env.NODE_ENV !== 'test') {
466      try {
467        logEvent('tengu_shell_set_cwd', {
468          success: true,
469        })
470      } catch (_error) {
471        // Ignore logging errors to prevent test failures
472      }
473    }
474  }