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 minutesexport 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 failtry {// Try to execute the shell with --version, which should exit quickly// Use execFileSync to avoid shell injection vulnerabilitiesexecFileSync(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 firstconst shellOverride = process.env.CLAUDE_CODE_SHELLif (shellOverride) {// Validate it's a supported shell typeconst 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 thislogForDebugging(`CLAUDE_CODE_SHELL="${shellOverride}" is not a valid bash/zsh path, falling back to detection`,)}}// Check user's preferred shell from environmentconst env_shell = process.env.SHELL// Only consider SHELL if it's bash or zshconst 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 locationsconst shellPaths = ['/bin', '/usr/bin', '/usr/local/bin', '/opt/homebrew/bin']// Order shells based on user preferenceconst 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 firstif (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 typeif (isEnvShellSupported && isExecutable(env_shell)) {supportedShells.unshift(env_shell)}const shellPath = supportedShells.find(shell => shell && isExecutable(shell))// If no valid shell found, throw a helpful errorif (!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 sessionexport 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?: numberonProgress?: (lastLines: string,allLines: string,totalLines: number,totalBytes: number,isIncomplete: boolean,) => voidpreventCwdChanges?: booleanshouldUseSandbox?: booleanshouldAutoBackground?: 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_TIMEOUTconst 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 conflictsconst 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 = builtCommandlet 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 allif (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' : binShellif (shouldUseSandbox) {commandString = await SandboxManager.wrapWithSandbox(commandString,sandboxBinShell,undefined,abortSignal,)// Create sandbox temp directory for sandboxed processes with secure permissionstry {const fs = getFsImplementation()await fs.mkdir(sandboxTmpDir, { mode: 0o700 })} catch (error) {logForDebugging(`Failed to create ${sandboxTmpDir} directory: ${error}`)}}const spawnBinary = isSandboxedPowerShell ? '/bin/sh' : binShellconst 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 = !!onStdoutconst 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 | undefinedif (!usePipeMode) {const O_NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0outputHandle = 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-killdetached: 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): cwdFilePathvoid 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 cwdif (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 trackingtry {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 errorsstderr: 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: stringtry {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}}}
此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。