| 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 |
|
|
| export type ShellConfig = { |
| provider: ShellProvider |
| } |
|
|
| function isExecutable(shellPath: string): boolean { |
| try { |
| accessSync(shellPath, fsConstants.X_OK) |
| return true |
| } catch (_err) { |
| |
| try { |
| |
| |
| execFileSync(shellPath, ['--version'], { |
| timeout: 1000, |
| stdio: 'ignore', |
| }) |
| return true |
| } catch { |
| return false |
| } |
| } |
| } |
|
|
| |
| |
| |
| export async function findSuitableShell(): Promise<string> { |
| |
| const shellOverride = process.env.CLAUDE_CODE_SHELL |
| if (shellOverride) { |
| |
| const isSupported = |
| shellOverride.includes('bash') || shellOverride.includes('zsh') |
| if (isSupported && isExecutable(shellOverride)) { |
| logForDebugging(`Using shell override: ${shellOverride}`) |
| return shellOverride |
| } else { |
| |
| logForDebugging( |
| `CLAUDE_CODE_SHELL="${shellOverride}" is not a valid bash/zsh path, falling back to detection`, |
| ) |
| } |
| } |
|
|
| |
| const env_shell = process.env.SHELL |
| |
| const isEnvShellSupported = |
| env_shell && (env_shell.includes('bash') || env_shell.includes('zsh')) |
| const preferBash = env_shell?.includes('bash') |
|
|
| |
| const [zshPath, bashPath] = await Promise.all([which('zsh'), which('bash')]) |
|
|
| |
| const shellPaths = ['/bin', '/usr/bin', '/usr/local/bin', '/opt/homebrew/bin'] |
|
|
| |
| const shellOrder = preferBash ? ['bash', 'zsh'] : ['zsh', 'bash'] |
| const supportedShells = shellOrder.flatMap(shell => |
| shellPaths.map(path => `${path}/${shell}`), |
| ) |
|
|
| |
| |
| if (preferBash) { |
| if (bashPath) supportedShells.unshift(bashPath) |
| if (zshPath) supportedShells.push(zshPath) |
| } else { |
| if (zshPath) supportedShells.unshift(zshPath) |
| if (bashPath) supportedShells.push(bashPath) |
| } |
|
|
| |
| if (isEnvShellSupported && isExecutable(env_shell)) { |
| supportedShells.unshift(env_shell) |
| } |
|
|
| const shellPath = supportedShells.find(shell => shell && isExecutable(shell)) |
|
|
| |
| 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 } |
| } |
|
|
| |
| 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 |
| |
| cwd?: string |
| |
| onStdout?: (data: string) => void |
| } |
|
|
| |
| |
| |
| |
| export async function exec( |
| command: string, |
| abortSignal: AbortSignal, |
| shellType: ShellType, |
| options?: ExecOptions, |
| ): Promise<ShellCommand> { |
| const { |
| timeout, |
| onProgress, |
| preventCwdChanges, |
| shouldUseSandbox, |
| shouldAutoBackground, |
| cwd: fixedCwd, |
| onStdout, |
| } = options ?? {} |
| const commandTimeout = timeout || DEFAULT_TIMEOUT |
|
|
| const provider = await resolveProvider[shellType]() |
|
|
| const id = Math.floor(Math.random() * 0x10000) |
| .toString(16) |
| .padStart(4, '0') |
|
|
| |
| 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 = fixedCwd ?? pwd() |
|
|
| |
| |
| 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 (abortSignal.aborted) { |
| return createAbortedCommand() |
| } |
|
|
| const binShell = provider.shellPath |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const isSandboxedPowerShell = shouldUseSandbox && shellType === 'powershell' |
| const sandboxBinShell = isSandboxedPowerShell ? '/bin/sh' : binShell |
|
|
| if (shouldUseSandbox) { |
| |
| |
| |
| try { |
| const fs = getFsImplementation() |
| await fs.mkdir(sandboxTmpDir, { mode: 0o700 }) |
| } catch (error) { |
| logForDebugging(`Failed to create ${sandboxTmpDir} directory: ${error}`) |
| } |
| commandString = await SandboxManager.wrapWithSandbox( |
| commandString, |
| sandboxBinShell, |
| undefined, |
| abortSignal, |
| ) |
| } |
|
|
| const spawnBinary = isSandboxedPowerShell ? '/bin/sh' : binShell |
| const shellArgs = isSandboxedPowerShell |
| ? ['-c', commandString] |
| : provider.getSpawnArgs(commandString) |
| const envOverrides = await provider.getEnvironmentOverrides(command) |
|
|
| |
| |
| |
| const usePipeMode = !!onStdout |
| const taskId = generateTaskId('local_bash') |
| const taskOutput = new TaskOutput(taskId, onProgress ?? null, !usePipeMode) |
| await mkdir(getTaskOutputDir(), { recursive: true }) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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], |
| |
| detached: provider.detached, |
| |
| windowsHide: true, |
| }) |
|
|
| const shellCommand = wrapSpawn( |
| childProcess, |
| abortSignal, |
| commandTimeout, |
| taskOutput, |
| shouldAutoBackground, |
| ) |
|
|
| |
| |
| |
| |
| |
| if (outputHandle !== undefined) { |
| try { |
| await outputHandle.close() |
| } catch { |
| |
| } |
| } |
|
|
| |
| |
| |
| |
| if (childProcess.stdout && onStdout) { |
| childProcess.stdout.on('data', (chunk: string | Buffer) => { |
| onStdout(typeof chunk === 'string' ? chunk : chunk.toString()) |
| }) |
| } |
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| const nativeCwdFilePath = |
| getPlatform() === 'windows' |
| ? posixPathToWindowsPath(cwdFilePath) |
| : cwdFilePath |
|
|
| void shellCommand.result.then(async result => { |
| |
| |
| |
| |
| |
| if (shouldUseSandbox) { |
| SandboxManager.cleanupAfterCommand() |
| } |
| |
| if (result && !preventCwdChanges && !result.backgroundTaskId) { |
| try { |
| let newCwd = readFileSync(nativeCwdFilePath, { |
| encoding: 'utf8', |
| }).trim() |
| if (getPlatform() === 'windows') { |
| newCwd = posixPathToWindowsPath(newCwd) |
| } |
| |
| |
| |
| if (newCwd.normalize('NFC') !== cwd) { |
| setCwd(newCwd, cwd) |
| invalidateSessionEnvCache() |
| void onCwdChangedForHooks(cwd, newCwd) |
| } |
| } catch { |
| logEvent('tengu_shell_set_cwd', { success: false }) |
| } |
| } |
| |
| try { |
| unlinkSync(nativeCwdFilePath) |
| } catch { |
| |
| } |
| }) |
|
|
| return shellCommand |
| } catch (error) { |
| |
| if (outputHandle !== undefined) { |
| try { |
| await outputHandle.close() |
| } catch { |
| |
| } |
| } |
| taskOutput.clear() |
|
|
| logForDebugging(`Shell exec error: ${errorMessage(error)}`) |
|
|
| return createAbortedCommand(undefined, { |
| code: 126, |
| stderr: errorMessage(error), |
| }) |
| } |
| } |
|
|
| |
| |
| |
| export function setCwd(path: string, relativeTo?: string): void { |
| const resolved = isAbsolute(path) |
| ? path |
| : resolve(relativeTo || getFsImplementation().cwd(), path) |
| |
| |
| |
| 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) { |
| |
| } |
| } |
| } |
|
|