Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
17 kB · 474 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475import { 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 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?: 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 } }}