/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { spawn } from 'child_process'; import { TextDecoder } from 'util'; import os from 'os'; import stripAnsi from 'strip-ansi'; import { getCachedEncodingForBuffer } from '../utils/systemEncoding.js'; import { isBinary } from '../utils/textUtils.js'; const SIGKILL_TIMEOUT_MS = 200; /** A structured result from a shell command execution. */ export interface ShellExecutionResult { /** The raw, unprocessed output buffer. */ rawOutput: Buffer; /** The combined, decoded stdout and stderr as a string. */ output: string; /** The decoded stdout as a string. */ stdout: string; /** The decoded stderr as a string. */ stderr: string; /** The process exit code, or null if terminated by a signal. */ exitCode: number | null; /** The signal that terminated the process, if any. */ signal: NodeJS.Signals | null; /** An error object if the process failed to spawn. */ error: Error | null; /** A boolean indicating if the command was aborted by the user. */ aborted: boolean; /** The process ID of the spawned shell. */ pid: number | undefined; } /** A handle for an ongoing shell execution. */ export interface ShellExecutionHandle { /** The process ID of the spawned shell. */ pid: number | undefined; /** A promise that resolves with the complete execution result. */ result: Promise; } /** * Describes a structured event emitted during shell command execution. */ export type ShellOutputEvent = | { /** The event contains a chunk of output data. */ type: 'data'; /** The stream from which the data originated. */ stream: 'stdout' | 'stderr'; /** The decoded string chunk. */ chunk: string; } | { /** Signals that the output stream has been identified as binary. */ type: 'binary_detected'; } | { /** Provides progress updates for a binary stream. */ type: 'binary_progress'; /** The total number of bytes received so far. */ bytesReceived: number; }; /** * A centralized service for executing shell commands with robust process * management, cross-platform compatibility, and streaming output capabilities. * */ export class ShellExecutionService { /** * Executes a shell command using `spawn`, capturing all output and lifecycle events. * * @param commandToExecute The exact command string to run. * @param cwd The working directory to execute the command in. * @param onOutputEvent A callback for streaming structured events about the execution, including data chunks and status updates. * @param abortSignal An AbortSignal to terminate the process and its children. * @returns An object containing the process ID (pid) and a promise that * resolves with the complete execution result. */ static execute( commandToExecute: string, cwd: string, onOutputEvent: (event: ShellOutputEvent) => void, abortSignal: AbortSignal, ): ShellExecutionHandle { const isWindows = os.platform() === 'win32'; const child = spawn(commandToExecute, [], { cwd, stdio: ['ignore', 'pipe', 'pipe'], // Use bash unless in Windows (since it doesn't support bash). // For windows, just use the default. shell: isWindows ? true : 'bash', // Use process groups on non-Windows for robust killing. // Windows process termination is handled by `taskkill /t`. detached: !isWindows, env: { ...process.env, GEMINI_CLI: '1', }, }); const result = new Promise((resolve) => { // Use decoders to handle multi-byte characters safely (for streaming output). let stdoutDecoder: TextDecoder | null = null; let stderrDecoder: TextDecoder | null = null; let stdout = ''; let stderr = ''; const outputChunks: Buffer[] = []; let error: Error | null = null; let exited = false; let isStreamingRawContent = true; const MAX_SNIFF_SIZE = 4096; let sniffedBytes = 0; const handleOutput = (data: Buffer, stream: 'stdout' | 'stderr') => { if (!stdoutDecoder || !stderrDecoder) { const encoding = getCachedEncodingForBuffer(data); try { stdoutDecoder = new TextDecoder(encoding); stderrDecoder = new TextDecoder(encoding); } catch { // If the encoding is not supported, fall back to utf-8. // This can happen on some platforms for certain encodings like 'utf-32le'. stdoutDecoder = new TextDecoder('utf-8'); stderrDecoder = new TextDecoder('utf-8'); } } outputChunks.push(data); // Binary detection logic. This only runs until we've made a determination. if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) { const sniffBuffer = Buffer.concat(outputChunks.slice(0, 20)); sniffedBytes = sniffBuffer.length; if (isBinary(sniffBuffer)) { // Change state to stop streaming raw content. isStreamingRawContent = false; onOutputEvent({ type: 'binary_detected' }); } } const decodedChunk = stream === 'stdout' ? stdoutDecoder.decode(data, { stream: true }) : stderrDecoder.decode(data, { stream: true }); const strippedChunk = stripAnsi(decodedChunk); if (stream === 'stdout') { stdout += strippedChunk; } else { stderr += strippedChunk; } if (isStreamingRawContent) { onOutputEvent({ type: 'data', stream, chunk: strippedChunk }); } else { const totalBytes = outputChunks.reduce( (sum, chunk) => sum + chunk.length, 0, ); onOutputEvent({ type: 'binary_progress', bytesReceived: totalBytes }); } }; child.stdout.on('data', (data) => handleOutput(data, 'stdout')); child.stderr.on('data', (data) => handleOutput(data, 'stderr')); child.on('error', (err) => { const { stdout, stderr, finalBuffer } = cleanup(); error = err; resolve({ error, stdout, stderr, rawOutput: finalBuffer, output: stdout + (stderr ? `\n${stderr}` : ''), exitCode: 1, signal: null, aborted: false, pid: child.pid, }); }); const abortHandler = async () => { if (child.pid && !exited) { if (isWindows) { spawn('taskkill', ['/pid', child.pid.toString(), '/f', '/t']); } else { try { // Kill the entire process group (negative PID). // SIGTERM first, then SIGKILL if it doesn't die. process.kill(-child.pid, 'SIGTERM'); await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS)); if (!exited) { process.kill(-child.pid, 'SIGKILL'); } } catch (_e) { // Fall back to killing just the main process if group kill fails. if (!exited) child.kill('SIGKILL'); } } } }; abortSignal.addEventListener('abort', abortHandler, { once: true }); child.on('exit', (code: number, signal: NodeJS.Signals) => { const { stdout, stderr, finalBuffer } = cleanup(); resolve({ rawOutput: finalBuffer, output: stdout + (stderr ? `\n${stderr}` : ''), stdout, stderr, exitCode: code, signal, error, aborted: abortSignal.aborted, pid: child.pid, }); }); /** * Cleans up a process (and it's accompanying state) that is exiting or * erroring and returns output formatted output buffers and strings */ function cleanup() { exited = true; abortSignal.removeEventListener('abort', abortHandler); if (stdoutDecoder) { stdout += stripAnsi(stdoutDecoder.decode()); } if (stderrDecoder) { stderr += stripAnsi(stderrDecoder.decode()); } const finalBuffer = Buffer.concat(outputChunks); return { stdout, stderr, finalBuffer }; } }); return { pid: child.pid, result }; } }