Shell.ts
utils/Shell.ts
475
Lines
16929
Bytes
7
Exports
31
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with shell-safety. It contains 475 lines, 31 detected imports, and 7 detected exports.
Important relationships
Detected exports
ShellConfigfindSuitableShellgetShellConfiggetPsProviderExecOptionsexecsetCwd
Keywords
shellpathcatchbashwhichprovidertaskoutputcommandshellcommandshelloverride
Detected imports
child_processfsfs/promiseslodash-es/memoize.jspathpath/posixsrc/services/analytics/index.js../bootstrap/state.js../Task.js./cwd.js./debug.js./errors.js./fsOperations.js./log.js./ShellCommand.js./task/diskOutput.js./task/TaskOutput.js./which.js./ShellCommand.jsfs./hooks/fileChangedWatcher.js./permissions/filesystem.js./platform.js./sandbox/sandbox-adapter.js./sessionEnvironment.js./shell/bashProvider.js./shell/powershellDetection.js./shell/powershellProvider.js./shell/shellProvider.js./subprocessEnv.js./windowsPaths.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
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 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 session
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
/** 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
}
}
}