init claude-code
This commit is contained in:
+474
@@ -0,0 +1,474 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user