source dump of claude code
at main 474 lines 17 kB view raw
1import { execFileSync, spawn } from 'child_process' 2import { constants as fsConstants, readFileSync, unlinkSync } from 'fs' 3import { type FileHandle, mkdir, open, realpath } from 'fs/promises' 4import memoize from 'lodash-es/memoize.js' 5import { isAbsolute, resolve } from 'path' 6import { join as posixJoin } from 'path/posix' 7import { logEvent } from 'src/services/analytics/index.js' 8import { 9 getOriginalCwd, 10 getSessionId, 11 setCwdState, 12} from '../bootstrap/state.js' 13import { generateTaskId } from '../Task.js' 14import { pwd } from './cwd.js' 15import { logForDebugging } from './debug.js' 16import { errorMessage, isENOENT } from './errors.js' 17import { getFsImplementation } from './fsOperations.js' 18import { logError } from './log.js' 19import { 20 createAbortedCommand, 21 createFailedCommand, 22 type ShellCommand, 23 wrapSpawn, 24} from './ShellCommand.js' 25import { getTaskOutputDir } from './task/diskOutput.js' 26import { TaskOutput } from './task/TaskOutput.js' 27import { which } from './which.js' 28 29export type { ExecResult } from './ShellCommand.js' 30 31import { accessSync } from 'fs' 32import { onCwdChangedForHooks } from './hooks/fileChangedWatcher.js' 33import { getClaudeTempDirName } from './permissions/filesystem.js' 34import { getPlatform } from './platform.js' 35import { SandboxManager } from './sandbox/sandbox-adapter.js' 36import { invalidateSessionEnvCache } from './sessionEnvironment.js' 37import { createBashShellProvider } from './shell/bashProvider.js' 38import { getCachedPowerShellPath } from './shell/powershellDetection.js' 39import { createPowerShellProvider } from './shell/powershellProvider.js' 40import type { ShellProvider, ShellType } from './shell/shellProvider.js' 41import { subprocessEnv } from './subprocessEnv.js' 42import { posixPathToWindowsPath } from './windowsPaths.js' 43 44const DEFAULT_TIMEOUT = 30 * 60 * 1000 // 30 minutes 45 46export type ShellConfig = { 47 provider: ShellProvider 48} 49 50function 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 */ 73export 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 139async 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 146export const getShellConfig = memoize(getShellConfigImpl) 147 148export 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 156const resolveProvider: Record<ShellType, () => Promise<ShellProvider>> = { 157 bash: async () => (await getShellConfig()).provider, 158 powershell: getPsProvider, 159} 160 161export 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 */ 181export 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 */ 447export 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}