Shell.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. import { execFileSync, spawn } from 'child_process'
  2. import { constants as fsConstants, readFileSync, unlinkSync } from 'fs'
  3. import { type FileHandle, mkdir, open, realpath } from 'fs/promises'
  4. import memoize from 'lodash-es/memoize.js'
  5. import { isAbsolute, resolve } from 'path'
  6. import { join as posixJoin } from 'path/posix'
  7. import { logEvent } from 'src/services/analytics/index.js'
  8. import {
  9. getOriginalCwd,
  10. getSessionId,
  11. setCwdState,
  12. } from '../bootstrap/state.js'
  13. import { generateTaskId } from '../Task.js'
  14. import { pwd } from './cwd.js'
  15. import { logForDebugging } from './debug.js'
  16. import { errorMessage, isENOENT } from './errors.js'
  17. import { getFsImplementation } from './fsOperations.js'
  18. import { logError } from './log.js'
  19. import {
  20. createAbortedCommand,
  21. createFailedCommand,
  22. type ShellCommand,
  23. wrapSpawn,
  24. } from './ShellCommand.js'
  25. import { getTaskOutputDir } from './task/diskOutput.js'
  26. import { TaskOutput } from './task/TaskOutput.js'
  27. import { which } from './which.js'
  28. export type { ExecResult } from './ShellCommand.js'
  29. import { accessSync } from 'fs'
  30. import { onCwdChangedForHooks } from './hooks/fileChangedWatcher.js'
  31. import { getClaudeTempDirName } from './permissions/filesystem.js'
  32. import { getPlatform } from './platform.js'
  33. import { SandboxManager } from './sandbox/sandbox-adapter.js'
  34. import { invalidateSessionEnvCache } from './sessionEnvironment.js'
  35. import { createBashShellProvider } from './shell/bashProvider.js'
  36. import { getCachedPowerShellPath } from './shell/powershellDetection.js'
  37. import { createPowerShellProvider } from './shell/powershellProvider.js'
  38. import type { ShellProvider, ShellType } from './shell/shellProvider.js'
  39. import { subprocessEnv } from './subprocessEnv.js'
  40. import { posixPathToWindowsPath } from './windowsPaths.js'
  41. const DEFAULT_TIMEOUT = 30 * 60 * 1000 // 30 minutes
  42. export type ShellConfig = {
  43. provider: ShellProvider
  44. }
  45. function isExecutable(shellPath: string): boolean {
  46. try {
  47. accessSync(shellPath, fsConstants.X_OK)
  48. return true
  49. } catch (_err) {
  50. // Fallback for Nix and other environments where X_OK check might fail
  51. try {
  52. // Try to execute the shell with --version, which should exit quickly
  53. // Use execFileSync to avoid shell injection vulnerabilities
  54. execFileSync(shellPath, ['--version'], {
  55. timeout: 1000,
  56. stdio: 'ignore',
  57. })
  58. return true
  59. } catch {
  60. return false
  61. }
  62. }
  63. }
  64. /**
  65. * Determines the best available shell to use.
  66. */
  67. export async function findSuitableShell(): Promise<string> {
  68. // Check for explicit shell override first
  69. const shellOverride = process.env.CLAUDE_CODE_SHELL
  70. if (shellOverride) {
  71. // Validate it's a supported shell type
  72. const isSupported =
  73. shellOverride.includes('bash') || shellOverride.includes('zsh')
  74. if (isSupported && isExecutable(shellOverride)) {
  75. logForDebugging(`Using shell override: ${shellOverride}`)
  76. return shellOverride
  77. } else {
  78. // 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
  79. logForDebugging(
  80. `CLAUDE_CODE_SHELL="${shellOverride}" is not a valid bash/zsh path, falling back to detection`,
  81. )
  82. }
  83. }
  84. // Check user's preferred shell from environment
  85. const env_shell = process.env.SHELL
  86. // Only consider SHELL if it's bash or zsh
  87. const isEnvShellSupported =
  88. env_shell && (env_shell.includes('bash') || env_shell.includes('zsh'))
  89. const preferBash = env_shell?.includes('bash')
  90. // Try to locate shells using which (uses Bun.which when available)
  91. const [zshPath, bashPath] = await Promise.all([which('zsh'), which('bash')])
  92. // Populate shell paths from which results and fallback locations
  93. const shellPaths = ['/bin', '/usr/bin', '/usr/local/bin', '/opt/homebrew/bin']
  94. // Order shells based on user preference
  95. const shellOrder = preferBash ? ['bash', 'zsh'] : ['zsh', 'bash']
  96. const supportedShells = shellOrder.flatMap(shell =>
  97. shellPaths.map(path => `${path}/${shell}`),
  98. )
  99. // Add discovered paths to the beginning of our search list
  100. // Put the user's preferred shell type first
  101. if (preferBash) {
  102. if (bashPath) supportedShells.unshift(bashPath)
  103. if (zshPath) supportedShells.push(zshPath)
  104. } else {
  105. if (zshPath) supportedShells.unshift(zshPath)
  106. if (bashPath) supportedShells.push(bashPath)
  107. }
  108. // Always prioritize SHELL env variable if it's a supported shell type
  109. if (isEnvShellSupported && isExecutable(env_shell)) {
  110. supportedShells.unshift(env_shell)
  111. }
  112. const shellPath = supportedShells.find(shell => shell && isExecutable(shell))
  113. // If no valid shell found, throw a helpful error
  114. if (!shellPath) {
  115. const errorMsg =
  116. 'No suitable shell found. Claude CLI requires a Posix shell environment. ' +
  117. 'Please ensure you have a valid shell installed and the SHELL environment variable set.'
  118. logError(new Error(errorMsg))
  119. throw new Error(errorMsg)
  120. }
  121. return shellPath
  122. }
  123. async function getShellConfigImpl(): Promise<ShellConfig> {
  124. const binShell = await findSuitableShell()
  125. const provider = await createBashShellProvider(binShell)
  126. return { provider }
  127. }
  128. // Memoize the entire shell config so it only happens once per session
  129. export const getShellConfig = memoize(getShellConfigImpl)
  130. export const getPsProvider = memoize(async (): Promise<ShellProvider> => {
  131. const psPath = await getCachedPowerShellPath()
  132. if (!psPath) {
  133. throw new Error('PowerShell is not available')
  134. }
  135. return createPowerShellProvider(psPath)
  136. })
  137. const resolveProvider: Record<ShellType, () => Promise<ShellProvider>> = {
  138. bash: async () => (await getShellConfig()).provider,
  139. powershell: getPsProvider,
  140. }
  141. export type ExecOptions = {
  142. timeout?: number
  143. onProgress?: (
  144. lastLines: string,
  145. allLines: string,
  146. totalLines: number,
  147. totalBytes: number,
  148. isIncomplete: boolean,
  149. ) => void
  150. preventCwdChanges?: boolean
  151. shouldUseSandbox?: boolean
  152. shouldAutoBackground?: boolean
  153. /** When provided, stdout is piped (not sent to file) and this callback fires on each data chunk. */
  154. onStdout?: (data: string) => void
  155. }
  156. /**
  157. * Execute a shell command using the environment snapshot
  158. * Creates a new shell process for each command execution
  159. */
  160. export async function exec(
  161. command: string,
  162. abortSignal: AbortSignal,
  163. shellType: ShellType,
  164. options?: ExecOptions,
  165. ): Promise<ShellCommand> {
  166. const {
  167. timeout,
  168. onProgress,
  169. preventCwdChanges,
  170. shouldUseSandbox,
  171. shouldAutoBackground,
  172. onStdout,
  173. } = options ?? {}
  174. const commandTimeout = timeout || DEFAULT_TIMEOUT
  175. const provider = await resolveProvider[shellType]()
  176. const id = Math.floor(Math.random() * 0x10000)
  177. .toString(16)
  178. .padStart(4, '0')
  179. // Sandbox temp directory - use per-user directory name to prevent multi-user permission conflicts
  180. const sandboxTmpDir = posixJoin(
  181. process.env.CLAUDE_CODE_TMPDIR || '/tmp',
  182. getClaudeTempDirName(),
  183. )
  184. const { commandString: builtCommand, cwdFilePath } =
  185. await provider.buildExecCommand(command, {
  186. id,
  187. sandboxTmpDir: shouldUseSandbox ? sandboxTmpDir : undefined,
  188. useSandbox: shouldUseSandbox ?? false,
  189. })
  190. let commandString = builtCommand
  191. let cwd = pwd()
  192. // Recover if the current working directory no longer exists on disk.
  193. // This can happen when a command deletes its own CWD (e.g., temp dir cleanup).
  194. try {
  195. await realpath(cwd)
  196. } catch {
  197. const fallback = getOriginalCwd()
  198. logForDebugging(
  199. `Shell CWD "${cwd}" no longer exists, recovering to "${fallback}"`,
  200. )
  201. try {
  202. await realpath(fallback)
  203. setCwdState(fallback)
  204. cwd = fallback
  205. } catch {
  206. return createFailedCommand(
  207. `Working directory "${cwd}" no longer exists. Please restart Claude from an existing directory.`,
  208. )
  209. }
  210. }
  211. // If already aborted, don't spawn the process at all
  212. if (abortSignal.aborted) {
  213. return createAbortedCommand()
  214. }
  215. const binShell = provider.shellPath
  216. // Sandboxed PowerShell: wrapWithSandbox hardcodes `<binShell> -c '<cmd>'` —
  217. // using pwsh there would lose -NoProfile -NonInteractive (profile load
  218. // inside sandbox → delays, stray output, may hang on prompts). Instead:
  219. // • powershellProvider.buildExecCommand (useSandbox) pre-wraps as
  220. // `pwsh -NoProfile -NonInteractive -EncodedCommand <base64>` — base64
  221. // survives the runtime's shellquote.quote() layer
  222. // • pass /bin/sh as the sandbox's inner shell to exec that invocation
  223. // • outer spawn is also /bin/sh -c to parse the runtime's POSIX output
  224. // /bin/sh exists on every platform where sandbox is supported.
  225. const isSandboxedPowerShell = shouldUseSandbox && shellType === 'powershell'
  226. const sandboxBinShell = isSandboxedPowerShell ? '/bin/sh' : binShell
  227. if (shouldUseSandbox) {
  228. commandString = await SandboxManager.wrapWithSandbox(
  229. commandString,
  230. sandboxBinShell,
  231. undefined,
  232. abortSignal,
  233. )
  234. // Create sandbox temp directory for sandboxed processes with secure permissions
  235. try {
  236. const fs = getFsImplementation()
  237. await fs.mkdir(sandboxTmpDir, { mode: 0o700 })
  238. } catch (error) {
  239. logForDebugging(`Failed to create ${sandboxTmpDir} directory: ${error}`)
  240. }
  241. }
  242. const spawnBinary = isSandboxedPowerShell ? '/bin/sh' : binShell
  243. const shellArgs = isSandboxedPowerShell
  244. ? ['-c', commandString]
  245. : provider.getSpawnArgs(commandString)
  246. const envOverrides = await provider.getEnvironmentOverrides(command)
  247. // When onStdout is provided, use pipe mode: stdout flows through
  248. // StreamWrapper → TaskOutput in-memory buffer instead of a file fd.
  249. // This lets callers receive real-time stdout callbacks.
  250. const usePipeMode = !!onStdout
  251. const taskId = generateTaskId('local_bash')
  252. const taskOutput = new TaskOutput(taskId, onProgress ?? null, !usePipeMode)
  253. await mkdir(getTaskOutputDir(), { recursive: true })
  254. // In file mode, both stdout and stderr go to the same file fd.
  255. // On POSIX, O_APPEND makes each write atomic (seek-to-end + write), so
  256. // stdout and stderr are interleaved chronologically without tearing.
  257. // On Windows, 'a' mode strips FILE_WRITE_DATA (only grants FILE_APPEND_DATA)
  258. // via libuv's fs__open. MSYS2/Cygwin probes inherited handles with
  259. // NtQueryInformationFile(FileAccessInformation) and treats handles without
  260. // FILE_WRITE_DATA as read-only, silently discarding all output. Using 'w'
  261. // grants FILE_GENERIC_WRITE. Atomicity is preserved because duplicated
  262. // handles share the same FILE_OBJECT with FILE_SYNCHRONOUS_IO_NONALERT,
  263. // which serializes all I/O through a single kernel lock.
  264. // SECURITY: O_NOFOLLOW prevents symlink-following attacks from the sandbox.
  265. // On Windows, use string flags — numeric flags can produce EINVAL through libuv.
  266. let outputHandle: FileHandle | undefined
  267. if (!usePipeMode) {
  268. const O_NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0
  269. outputHandle = await open(
  270. taskOutput.path,
  271. process.platform === 'win32'
  272. ? 'w'
  273. : fsConstants.O_WRONLY |
  274. fsConstants.O_CREAT |
  275. fsConstants.O_APPEND |
  276. O_NOFOLLOW,
  277. )
  278. }
  279. try {
  280. const childProcess = spawn(spawnBinary, shellArgs, {
  281. env: {
  282. ...subprocessEnv(),
  283. SHELL: shellType === 'bash' ? binShell : undefined,
  284. GIT_EDITOR: 'true',
  285. CLAUDECODE: '1',
  286. ...envOverrides,
  287. ...(process.env.USER_TYPE === 'ant'
  288. ? {
  289. CLAUDE_CODE_SESSION_ID: getSessionId(),
  290. }
  291. : {}),
  292. },
  293. cwd,
  294. stdio: usePipeMode
  295. ? ['pipe', 'pipe', 'pipe']
  296. : ['pipe', outputHandle?.fd, outputHandle?.fd],
  297. // Don't pass the signal - we'll handle termination ourselves with tree-kill
  298. detached: provider.detached,
  299. // Prevent visible console window on Windows (no-op on other platforms)
  300. windowsHide: true,
  301. })
  302. const shellCommand = wrapSpawn(
  303. childProcess,
  304. abortSignal,
  305. commandTimeout,
  306. taskOutput,
  307. shouldAutoBackground,
  308. )
  309. // Close our copy of the fd — the child has its own dup.
  310. // Must happen after wrapSpawn attaches 'error' listener, since the await
  311. // yields and the child's ENOENT 'error' event can fire in that window.
  312. // Wrapped in its own try/catch so a close failure (e.g. EIO) doesn't fall
  313. // through to the spawn-failure catch block, which would orphan the child.
  314. if (outputHandle !== undefined) {
  315. try {
  316. await outputHandle.close()
  317. } catch {
  318. // fd may already be closed by the child; safe to ignore
  319. }
  320. }
  321. // In pipe mode, attach the caller's callbacks alongside StreamWrapper.
  322. // Both listeners receive the same data chunks (Node.js ReadableStream supports
  323. // multiple 'data' listeners). StreamWrapper feeds TaskOutput for persistence;
  324. // these callbacks give the caller real-time access.
  325. if (childProcess.stdout && onStdout) {
  326. childProcess.stdout.on('data', (chunk: string | Buffer) => {
  327. onStdout(typeof chunk === 'string' ? chunk : chunk.toString())
  328. })
  329. }
  330. // Attach cleanup to the command result
  331. // NOTE: readFileSync/unlinkSync are intentional here — these must complete
  332. // synchronously within the .then() microtask so that callers who
  333. // `await shellCommand.result` see the updated cwd immediately after.
  334. // Using async readFile would introduce a microtask boundary, causing
  335. // a race where cwd hasn't been updated yet when the caller continues.
  336. // On Windows, cwdFilePath is a POSIX path (for bash's `pwd -P >| $path`),
  337. // but Node.js needs a native Windows path for readFileSync/unlinkSync.
  338. // Similarly, `pwd -P` outputs a POSIX path that must be converted before setCwd.
  339. const nativeCwdFilePath =
  340. getPlatform() === 'windows'
  341. ? posixPathToWindowsPath(cwdFilePath)
  342. : cwdFilePath
  343. void shellCommand.result.then(async result => {
  344. // On Linux, bwrap creates 0-byte mount-point files on the host to deny
  345. // writes to non-existent paths (.bashrc, HEAD, etc.). These persist after
  346. // bwrap exits as ghost dotfiles in cwd. Cleanup is synchronous and a no-op
  347. // on macOS. Keep before any await so callers awaiting .result see a clean
  348. // working tree in the same microtask.
  349. if (shouldUseSandbox) {
  350. SandboxManager.cleanupAfterCommand()
  351. }
  352. // Only foreground tasks update the cwd
  353. if (result && !preventCwdChanges && !result.backgroundTaskId) {
  354. try {
  355. let newCwd = readFileSync(nativeCwdFilePath, {
  356. encoding: 'utf8',
  357. }).trim()
  358. if (getPlatform() === 'windows') {
  359. newCwd = posixPathToWindowsPath(newCwd)
  360. }
  361. // cwd is NFC-normalized (setCwdState); newCwd from `pwd -P` may be
  362. // NFD on macOS APFS. Normalize before comparing so Unicode paths
  363. // don't false-positive as "changed" on every command.
  364. if (newCwd.normalize('NFC') !== cwd) {
  365. setCwd(newCwd, cwd)
  366. invalidateSessionEnvCache()
  367. void onCwdChangedForHooks(cwd, newCwd)
  368. }
  369. } catch {
  370. logEvent('tengu_shell_set_cwd', { success: false })
  371. }
  372. }
  373. // Clean up the temp file used for cwd tracking
  374. try {
  375. unlinkSync(nativeCwdFilePath)
  376. } catch {
  377. // File may not exist if command failed before pwd -P ran
  378. }
  379. })
  380. return shellCommand
  381. } catch (error) {
  382. // Close the fd if spawn failed (child never got its dup)
  383. if (outputHandle !== undefined) {
  384. try {
  385. await outputHandle.close()
  386. } catch {
  387. // May already be closed
  388. }
  389. }
  390. taskOutput.clear()
  391. logForDebugging(`Shell exec error: ${errorMessage(error)}`)
  392. return createAbortedCommand(undefined, {
  393. code: 126, // Standard Unix code for execution errors
  394. stderr: errorMessage(error),
  395. })
  396. }
  397. }
  398. /**
  399. * Set the current working directory
  400. */
  401. export function setCwd(path: string, relativeTo?: string): void {
  402. const resolved = isAbsolute(path)
  403. ? path
  404. : resolve(relativeTo || getFsImplementation().cwd(), path)
  405. // Resolve symlinks to match the behavior of pwd -P.
  406. // realpathSync throws ENOENT if the path doesn't exist - convert to a
  407. // friendlier error message instead of a separate existsSync pre-check (TOCTOU).
  408. let physicalPath: string
  409. try {
  410. physicalPath = getFsImplementation().realpathSync(resolved)
  411. } catch (e) {
  412. if (isENOENT(e)) {
  413. throw new Error(`Path "${resolved}" does not exist`)
  414. }
  415. throw e
  416. }
  417. setCwdState(physicalPath)
  418. if (process.env.NODE_ENV !== 'test') {
  419. try {
  420. logEvent('tengu_shell_set_cwd', {
  421. success: true,
  422. })
  423. } catch (_error) {
  424. // Ignore logging errors to prevent test failures
  425. }
  426. }
  427. }