| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474 |
- import { execFileSync, spawn } from 'child_process'
- import { constants as fsConstants, readFileSync, unlinkSync } from 'fs'
- import { type FileHandle, mkdir, open, realpath } from 'fs/promises'
- import memoize from 'lodash-es/memoize.js'
- import { isAbsolute, resolve } from 'path'
- import { join as posixJoin } from 'path/posix'
- import { logEvent } from 'src/services/analytics/index.js'
- import {
- getOriginalCwd,
- getSessionId,
- setCwdState,
- } from '../bootstrap/state.js'
- import { generateTaskId } from '../Task.js'
- import { pwd } from './cwd.js'
- import { logForDebugging } from './debug.js'
- import { errorMessage, isENOENT } from './errors.js'
- import { getFsImplementation } from './fsOperations.js'
- import { logError } from './log.js'
- import {
- createAbortedCommand,
- createFailedCommand,
- type ShellCommand,
- wrapSpawn,
- } from './ShellCommand.js'
- import { getTaskOutputDir } from './task/diskOutput.js'
- import { TaskOutput } from './task/TaskOutput.js'
- import { which } from './which.js'
- export type { ExecResult } from './ShellCommand.js'
- import { accessSync } from 'fs'
- import { onCwdChangedForHooks } from './hooks/fileChangedWatcher.js'
- import { getClaudeTempDirName } from './permissions/filesystem.js'
- import { getPlatform } from './platform.js'
- import { SandboxManager } from './sandbox/sandbox-adapter.js'
- import { invalidateSessionEnvCache } from './sessionEnvironment.js'
- import { createBashShellProvider } from './shell/bashProvider.js'
- import { getCachedPowerShellPath } from './shell/powershellDetection.js'
- import { createPowerShellProvider } from './shell/powershellProvider.js'
- import type { ShellProvider, ShellType } from './shell/shellProvider.js'
- import { subprocessEnv } from './subprocessEnv.js'
- import { posixPathToWindowsPath } from './windowsPaths.js'
- const DEFAULT_TIMEOUT = 30 * 60 * 1000 // 30 minutes
- export type ShellConfig = {
- provider: ShellProvider
- }
- function isExecutable(shellPath: string): boolean {
- try {
- accessSync(shellPath, fsConstants.X_OK)
- return true
- } catch (_err) {
- // Fallback for Nix and other environments where X_OK check might fail
- try {
- // Try to execute the shell with --version, which should exit quickly
- // Use execFileSync to avoid shell injection vulnerabilities
- execFileSync(shellPath, ['--version'], {
- timeout: 1000,
- stdio: 'ignore',
- })
- return true
- } catch {
- return false
- }
- }
- }
- /**
- * Determines the best available shell to use.
- */
- export async function findSuitableShell(): Promise<string> {
- // Check for explicit shell override first
- const shellOverride = process.env.CLAUDE_CODE_SHELL
- if (shellOverride) {
- // Validate it's a supported shell type
- const isSupported =
- shellOverride.includes('bash') || shellOverride.includes('zsh')
- if (isSupported && isExecutable(shellOverride)) {
- logForDebugging(`Using shell override: ${shellOverride}`)
- return shellOverride
- } else {
- // 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
- logForDebugging(
- `CLAUDE_CODE_SHELL="${shellOverride}" is not a valid bash/zsh path, falling back to detection`,
- )
- }
- }
- // Check user's preferred shell from environment
- const env_shell = process.env.SHELL
- // Only consider SHELL if it's bash or zsh
- const isEnvShellSupported =
- env_shell && (env_shell.includes('bash') || env_shell.includes('zsh'))
- const preferBash = env_shell?.includes('bash')
- // Try to locate shells using which (uses Bun.which when available)
- const [zshPath, bashPath] = await Promise.all([which('zsh'), which('bash')])
- // Populate shell paths from which results and fallback locations
- const shellPaths = ['/bin', '/usr/bin', '/usr/local/bin', '/opt/homebrew/bin']
- // Order shells based on user preference
- const shellOrder = preferBash ? ['bash', 'zsh'] : ['zsh', 'bash']
- const supportedShells = shellOrder.flatMap(shell =>
- shellPaths.map(path => `${path}/${shell}`),
- )
- // Add discovered paths to the beginning of our search list
- // Put the user's preferred shell type first
- if (preferBash) {
- if (bashPath) supportedShells.unshift(bashPath)
- if (zshPath) supportedShells.push(zshPath)
- } else {
- if (zshPath) supportedShells.unshift(zshPath)
- if (bashPath) supportedShells.push(bashPath)
- }
- // Always prioritize SHELL env variable if it's a supported shell type
- if (isEnvShellSupported && isExecutable(env_shell)) {
- supportedShells.unshift(env_shell)
- }
- const shellPath = supportedShells.find(shell => shell && isExecutable(shell))
- // If no valid shell found, throw a helpful error
- if (!shellPath) {
- const errorMsg =
- 'No suitable shell found. Claude CLI requires a Posix shell environment. ' +
- 'Please ensure you have a valid shell installed and the SHELL environment variable set.'
- logError(new Error(errorMsg))
- throw new Error(errorMsg)
- }
- return shellPath
- }
- async function getShellConfigImpl(): Promise<ShellConfig> {
- const binShell = await findSuitableShell()
- const provider = await createBashShellProvider(binShell)
- return { provider }
- }
- // Memoize the entire shell config so it only happens once per session
- export const getShellConfig = memoize(getShellConfigImpl)
- export const getPsProvider = memoize(async (): Promise<ShellProvider> => {
- const psPath = await getCachedPowerShellPath()
- if (!psPath) {
- throw new Error('PowerShell is not available')
- }
- return createPowerShellProvider(psPath)
- })
- const resolveProvider: Record<ShellType, () => Promise<ShellProvider>> = {
- bash: async () => (await getShellConfig()).provider,
- powershell: getPsProvider,
- }
- export type ExecOptions = {
- timeout?: number
- onProgress?: (
- lastLines: string,
- allLines: string,
- totalLines: number,
- totalBytes: number,
- isIncomplete: boolean,
- ) => void
- preventCwdChanges?: boolean
- shouldUseSandbox?: boolean
- shouldAutoBackground?: boolean
- /** When provided, stdout is piped (not sent to file) and this callback fires on each data chunk. */
- onStdout?: (data: string) => void
- }
- /**
- * Execute a shell command using the environment snapshot
- * Creates a new shell process for each command execution
- */
- export async function exec(
- command: string,
- abortSignal: AbortSignal,
- shellType: ShellType,
- options?: ExecOptions,
- ): Promise<ShellCommand> {
- const {
- timeout,
- onProgress,
- preventCwdChanges,
- shouldUseSandbox,
- shouldAutoBackground,
- onStdout,
- } = options ?? {}
- const commandTimeout = timeout || DEFAULT_TIMEOUT
- const provider = await resolveProvider[shellType]()
- const id = Math.floor(Math.random() * 0x10000)
- .toString(16)
- .padStart(4, '0')
- // Sandbox temp directory - use per-user directory name to prevent multi-user permission conflicts
- const sandboxTmpDir = posixJoin(
- process.env.CLAUDE_CODE_TMPDIR || '/tmp',
- getClaudeTempDirName(),
- )
- const { commandString: builtCommand, cwdFilePath } =
- await provider.buildExecCommand(command, {
- id,
- sandboxTmpDir: shouldUseSandbox ? sandboxTmpDir : undefined,
- useSandbox: shouldUseSandbox ?? false,
- })
- let commandString = builtCommand
- let cwd = pwd()
- // Recover if the current working directory no longer exists on disk.
- // This can happen when a command deletes its own CWD (e.g., temp dir cleanup).
- try {
- await realpath(cwd)
- } catch {
- const fallback = getOriginalCwd()
- logForDebugging(
- `Shell CWD "${cwd}" no longer exists, recovering to "${fallback}"`,
- )
- try {
- await realpath(fallback)
- setCwdState(fallback)
- cwd = fallback
- } catch {
- return createFailedCommand(
- `Working directory "${cwd}" no longer exists. Please restart Claude from an existing directory.`,
- )
- }
- }
- // If already aborted, don't spawn the process at all
- if (abortSignal.aborted) {
- return createAbortedCommand()
- }
- const binShell = provider.shellPath
- // Sandboxed PowerShell: wrapWithSandbox hardcodes `<binShell> -c '<cmd>'` —
- // using pwsh there would lose -NoProfile -NonInteractive (profile load
- // inside sandbox → delays, stray output, may hang on prompts). Instead:
- // • powershellProvider.buildExecCommand (useSandbox) pre-wraps as
- // `pwsh -NoProfile -NonInteractive -EncodedCommand <base64>` — base64
- // survives the runtime's shellquote.quote() layer
- // • pass /bin/sh as the sandbox's inner shell to exec that invocation
- // • outer spawn is also /bin/sh -c to parse the runtime's POSIX output
- // /bin/sh exists on every platform where sandbox is supported.
- const isSandboxedPowerShell = shouldUseSandbox && shellType === 'powershell'
- const sandboxBinShell = isSandboxedPowerShell ? '/bin/sh' : binShell
- if (shouldUseSandbox) {
- commandString = await SandboxManager.wrapWithSandbox(
- commandString,
- sandboxBinShell,
- undefined,
- abortSignal,
- )
- // Create sandbox temp directory for sandboxed processes with secure permissions
- try {
- const fs = getFsImplementation()
- await fs.mkdir(sandboxTmpDir, { mode: 0o700 })
- } catch (error) {
- logForDebugging(`Failed to create ${sandboxTmpDir} directory: ${error}`)
- }
- }
- const spawnBinary = isSandboxedPowerShell ? '/bin/sh' : binShell
- const shellArgs = isSandboxedPowerShell
- ? ['-c', commandString]
- : provider.getSpawnArgs(commandString)
- const envOverrides = await provider.getEnvironmentOverrides(command)
- // When onStdout is provided, use pipe mode: stdout flows through
- // StreamWrapper → TaskOutput in-memory buffer instead of a file fd.
- // This lets callers receive real-time stdout callbacks.
- const usePipeMode = !!onStdout
- const taskId = generateTaskId('local_bash')
- const taskOutput = new TaskOutput(taskId, onProgress ?? null, !usePipeMode)
- await mkdir(getTaskOutputDir(), { recursive: true })
- // In file mode, both stdout and stderr go to the same file fd.
- // On POSIX, O_APPEND makes each write atomic (seek-to-end + write), so
- // stdout and stderr are interleaved chronologically without tearing.
- // On Windows, 'a' mode strips FILE_WRITE_DATA (only grants FILE_APPEND_DATA)
- // via libuv's fs__open. MSYS2/Cygwin probes inherited handles with
- // NtQueryInformationFile(FileAccessInformation) and treats handles without
- // FILE_WRITE_DATA as read-only, silently discarding all output. Using 'w'
- // grants FILE_GENERIC_WRITE. Atomicity is preserved because duplicated
- // handles share the same FILE_OBJECT with FILE_SYNCHRONOUS_IO_NONALERT,
- // which serializes all I/O through a single kernel lock.
- // SECURITY: O_NOFOLLOW prevents symlink-following attacks from the sandbox.
- // On Windows, use string flags — numeric flags can produce EINVAL through libuv.
- let outputHandle: FileHandle | undefined
- if (!usePipeMode) {
- const O_NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0
- outputHandle = await open(
- taskOutput.path,
- process.platform === 'win32'
- ? 'w'
- : fsConstants.O_WRONLY |
- fsConstants.O_CREAT |
- fsConstants.O_APPEND |
- O_NOFOLLOW,
- )
- }
- try {
- const childProcess = spawn(spawnBinary, shellArgs, {
- env: {
- ...subprocessEnv(),
- SHELL: shellType === 'bash' ? binShell : undefined,
- GIT_EDITOR: 'true',
- CLAUDECODE: '1',
- ...envOverrides,
- ...(process.env.USER_TYPE === 'ant'
- ? {
- CLAUDE_CODE_SESSION_ID: getSessionId(),
- }
- : {}),
- },
- cwd,
- stdio: usePipeMode
- ? ['pipe', 'pipe', 'pipe']
- : ['pipe', outputHandle?.fd, outputHandle?.fd],
- // Don't pass the signal - we'll handle termination ourselves with tree-kill
- detached: provider.detached,
- // Prevent visible console window on Windows (no-op on other platforms)
- windowsHide: true,
- })
- const shellCommand = wrapSpawn(
- childProcess,
- abortSignal,
- commandTimeout,
- taskOutput,
- shouldAutoBackground,
- )
- // Close our copy of the fd — the child has its own dup.
- // Must happen after wrapSpawn attaches 'error' listener, since the await
- // yields and the child's ENOENT 'error' event can fire in that window.
- // Wrapped in its own try/catch so a close failure (e.g. EIO) doesn't fall
- // through to the spawn-failure catch block, which would orphan the child.
- if (outputHandle !== undefined) {
- try {
- await outputHandle.close()
- } catch {
- // fd may already be closed by the child; safe to ignore
- }
- }
- // In pipe mode, attach the caller's callbacks alongside StreamWrapper.
- // Both listeners receive the same data chunks (Node.js ReadableStream supports
- // multiple 'data' listeners). StreamWrapper feeds TaskOutput for persistence;
- // these callbacks give the caller real-time access.
- if (childProcess.stdout && onStdout) {
- childProcess.stdout.on('data', (chunk: string | Buffer) => {
- onStdout(typeof chunk === 'string' ? chunk : chunk.toString())
- })
- }
- // Attach cleanup to the command result
- // NOTE: readFileSync/unlinkSync are intentional here — these must complete
- // synchronously within the .then() microtask so that callers who
- // `await shellCommand.result` see the updated cwd immediately after.
- // Using async readFile would introduce a microtask boundary, causing
- // a race where cwd hasn't been updated yet when the caller continues.
- // On Windows, cwdFilePath is a POSIX path (for bash's `pwd -P >| $path`),
- // but Node.js needs a native Windows path for readFileSync/unlinkSync.
- // Similarly, `pwd -P` outputs a POSIX path that must be converted before setCwd.
- const nativeCwdFilePath =
- getPlatform() === 'windows'
- ? posixPathToWindowsPath(cwdFilePath)
- : cwdFilePath
- void shellCommand.result.then(async result => {
- // On Linux, bwrap creates 0-byte mount-point files on the host to deny
- // writes to non-existent paths (.bashrc, HEAD, etc.). These persist after
- // bwrap exits as ghost dotfiles in cwd. Cleanup is synchronous and a no-op
- // on macOS. Keep before any await so callers awaiting .result see a clean
- // working tree in the same microtask.
- if (shouldUseSandbox) {
- SandboxManager.cleanupAfterCommand()
- }
- // Only foreground tasks update the cwd
- if (result && !preventCwdChanges && !result.backgroundTaskId) {
- try {
- let newCwd = readFileSync(nativeCwdFilePath, {
- encoding: 'utf8',
- }).trim()
- if (getPlatform() === 'windows') {
- newCwd = posixPathToWindowsPath(newCwd)
- }
- // cwd is NFC-normalized (setCwdState); newCwd from `pwd -P` may be
- // NFD on macOS APFS. Normalize before comparing so Unicode paths
- // don't false-positive as "changed" on every command.
- if (newCwd.normalize('NFC') !== cwd) {
- setCwd(newCwd, cwd)
- invalidateSessionEnvCache()
- void onCwdChangedForHooks(cwd, newCwd)
- }
- } catch {
- logEvent('tengu_shell_set_cwd', { success: false })
- }
- }
- // Clean up the temp file used for cwd tracking
- try {
- unlinkSync(nativeCwdFilePath)
- } catch {
- // File may not exist if command failed before pwd -P ran
- }
- })
- return shellCommand
- } catch (error) {
- // Close the fd if spawn failed (child never got its dup)
- if (outputHandle !== undefined) {
- try {
- await outputHandle.close()
- } catch {
- // May already be closed
- }
- }
- taskOutput.clear()
- logForDebugging(`Shell exec error: ${errorMessage(error)}`)
- return createAbortedCommand(undefined, {
- code: 126, // Standard Unix code for execution errors
- stderr: errorMessage(error),
- })
- }
- }
- /**
- * Set the current working directory
- */
- export function setCwd(path: string, relativeTo?: string): void {
- const resolved = isAbsolute(path)
- ? path
- : resolve(relativeTo || getFsImplementation().cwd(), path)
- // Resolve symlinks to match the behavior of pwd -P.
- // realpathSync throws ENOENT if the path doesn't exist - convert to a
- // friendlier error message instead of a separate existsSync pre-check (TOCTOU).
- let physicalPath: string
- try {
- physicalPath = getFsImplementation().realpathSync(resolved)
- } catch (e) {
- if (isENOENT(e)) {
- throw new Error(`Path "${resolved}" does not exist`)
- }
- throw e
- }
- setCwdState(physicalPath)
- if (process.env.NODE_ENV !== 'test') {
- try {
- logEvent('tengu_shell_set_cwd', {
- success: true,
- })
- } catch (_error) {
- // Ignore logging errors to prevent test failures
- }
- }
- }
|