| |
| |
| |
| |
| |
|
|
| import stripAnsi from 'strip-ansi'; |
| import { getPty, type PtyImplementation } from '../utils/getPty.js'; |
| import { spawn as cpSpawn, type ChildProcess } from 'node:child_process'; |
| import { TextDecoder } from 'node:util'; |
| import type { Writable } from 'node:stream'; |
| import os from 'node:os'; |
| import fs, { mkdirSync } from 'node:fs'; |
| import path from 'node:path'; |
| import type { IPty } from '@lydell/node-pty'; |
| import { |
| getShellConfiguration, |
| resolveExecutable, |
| type ShellType, |
| } from '../utils/shell-utils.js'; |
| import { isBinary, truncateString } from '../utils/textUtils.js'; |
| import pkg from '@xterm/headless'; |
| import { debugLogger } from '../utils/debugLogger.js'; |
| import { Storage } from '../config/storage.js'; |
| import { |
| serializeTerminalToObject, |
| type AnsiOutput, |
| } from '../utils/terminalSerializer.js'; |
| import { |
| sanitizeEnvironment, |
| type EnvironmentSanitizationConfig, |
| } from './environmentSanitization.js'; |
| import { |
| NoopSandboxManager, |
| type SandboxManager, |
| type SandboxPermissions, |
| } from './sandboxManager.js'; |
| import type { SandboxConfig } from '../config/config.js'; |
| import { killProcessGroup } from '../utils/process-utils.js'; |
| import { isNodeError } from '../utils/errors.js'; |
| import { |
| ExecutionLifecycleService, |
| type ExecutionHandle, |
| type ExecutionOutputEvent, |
| type ExecutionResult, |
| } from './executionLifecycleService.js'; |
| const { Terminal } = pkg; |
|
|
| const MAX_CHILD_PROCESS_BUFFER_SIZE = 16 * 1024 * 1024; |
|
|
| |
| |
| |
| |
| |
| export const GEMINI_CLI_IDENTIFICATION_ENV_VAR = 'GEMINI_CLI'; |
|
|
| |
| |
| |
| export const GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE = '1'; |
|
|
| |
| |
| |
| export const SCROLLBACK_LIMIT = 300000; |
|
|
| const BASH_SHOPT_OPTIONS = 'promptvars nullglob extglob nocaseglob dotglob'; |
| const BASH_SHOPT_GUARD = `shopt -u ${BASH_SHOPT_OPTIONS};`; |
|
|
| function ensurePromptvarsDisabled(command: string, shell: ShellType): string { |
| if (shell !== 'bash') { |
| return command; |
| } |
|
|
| const trimmed = command.trimStart(); |
| if (trimmed.startsWith(BASH_SHOPT_GUARD)) { |
| return command; |
| } |
|
|
| return `${BASH_SHOPT_GUARD} ${command}`; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function injectUtf8CodepageForPty( |
| command: string, |
| shell: ShellType, |
| isWindows: boolean, |
| usingPty: boolean, |
| ): string { |
| if (!isWindows || !usingPty) { |
| return command; |
| } |
| if (shell === 'powershell') { |
| return `chcp 65001 >$null;${command}`; |
| } |
| if (shell === 'cmd') { |
| return `chcp 65001>nul&${command}`; |
| } |
| return command; |
| } |
|
|
| |
| export type ShellExecutionResult = ExecutionResult; |
|
|
| |
| export type ShellExecutionHandle = ExecutionHandle; |
|
|
| export interface ShellExecutionConfig { |
| additionalPermissions?: SandboxPermissions; |
| terminalWidth?: number; |
| terminalHeight?: number; |
| pager?: string; |
| showColor?: boolean; |
| defaultFg?: string; |
| defaultBg?: string; |
| sanitizationConfig: EnvironmentSanitizationConfig; |
| sandboxManager: SandboxManager; |
| |
| disableDynamicLineTrimming?: boolean; |
| scrollback?: number; |
| maxSerializedLines?: number; |
| sandboxConfig?: SandboxConfig; |
| backgroundCompletionBehavior?: 'inject' | 'notify' | 'silent'; |
| originalCommand?: string; |
| sessionId?: string; |
| env?: Record<string, string | undefined>; |
| } |
|
|
| |
| |
| |
| export type ShellOutputEvent = ExecutionOutputEvent; |
|
|
| export type DestroyablePty = IPty & { destroy?: () => void }; |
|
|
| interface ActivePty { |
| ptyProcess: DestroyablePty; |
| headlessTerminal: pkg.Terminal; |
| maxSerializedLines?: number; |
| command: string; |
| sessionId?: string; |
| } |
|
|
| interface ActiveChildProcess { |
| process: ChildProcess; |
| state: { |
| output: string; |
| truncated: boolean; |
| sniffChunks: Buffer[]; |
| binaryBytesReceived: number; |
| }; |
| command: string; |
| sessionId?: string; |
| } |
|
|
| const findLastContentLine = ( |
| buffer: pkg.IBuffer, |
| startLine: number, |
| ): number => { |
| const lineCount = buffer.length; |
| for (let i = lineCount - 1; i >= startLine; i--) { |
| const line = buffer.getLine(i); |
| if (line && line.translateToString(true).length > 0) { |
| return i; |
| } |
| } |
| return -1; |
| }; |
|
|
| const getFullBufferText = (terminal: pkg.Terminal, startLine = 0): string => { |
| const buffer = terminal.buffer.active; |
| const lines: string[] = []; |
|
|
| const lastContentLine = findLastContentLine(buffer, startLine); |
|
|
| if (lastContentLine === -1 || lastContentLine < startLine) return ''; |
|
|
| for (let i = startLine; i <= lastContentLine; i++) { |
| const line = buffer.getLine(i); |
| if (!line) { |
| lines.push(''); |
| continue; |
| } |
|
|
| let trimRight = true; |
| if (i + 1 <= lastContentLine) { |
| const nextLine = buffer.getLine(i + 1); |
| if (nextLine?.isWrapped) { |
| trimRight = false; |
| } |
| } |
|
|
| const lineContent = line.translateToString(trimRight); |
|
|
| if (line.isWrapped && lines.length > 0) { |
| lines[lines.length - 1] += lineContent; |
| } else { |
| lines.push(lineContent); |
| } |
| } |
|
|
| return lines.join('\n'); |
| }; |
|
|
| const writeBufferToLogStream = ( |
| terminal: pkg.Terminal, |
| stream: fs.WriteStream, |
| startLine = 0, |
| ): number => { |
| const buffer = terminal.buffer.active; |
| const lastContentLine = findLastContentLine(buffer, startLine); |
|
|
| if (lastContentLine === -1 || lastContentLine < startLine) return startLine; |
|
|
| for (let i = startLine; i <= lastContentLine; i++) { |
| const line = buffer.getLine(i); |
| if (!line) { |
| stream.write('\n'); |
| continue; |
| } |
|
|
| let trimRight = true; |
| if (i + 1 <= lastContentLine) { |
| const nextLine = buffer.getLine(i + 1); |
| if (nextLine?.isWrapped) { |
| trimRight = false; |
| } |
| } |
|
|
| const lineContent = line.translateToString(trimRight); |
| const stripped = stripAnsi(lineContent); |
|
|
| if (line.isWrapped) { |
| stream.write(stripped); |
| } else { |
| if (i > startLine) { |
| stream.write('\n'); |
| } |
| stream.write(stripped); |
| } |
| } |
|
|
| |
| if (lastContentLine >= startLine) { |
| const nextLine = terminal.buffer.active.getLine(lastContentLine + 1); |
| if (!nextLine?.isWrapped) { |
| stream.write('\n'); |
| } |
| } |
|
|
| return lastContentLine + 1; |
| }; |
|
|
| |
| |
| |
| |
| |
|
|
| export type BackgroundProcess = { |
| pid: number; |
| command: string; |
| status: 'running' | 'exited'; |
| exitCode?: number | null; |
| signal?: number | null; |
| }; |
|
|
| export type BackgroundProcessRecord = Omit<BackgroundProcess, 'pid'> & { |
| startTime: number; |
| endTime?: number; |
| }; |
|
|
| export class ShellExecutionService { |
| private static activePtys = new Map<number, ActivePty>(); |
| private static activeChildProcesses = new Map<number, ActiveChildProcess>(); |
| private static backgroundLogPids = new Set<number>(); |
| private static backgroundLogStreams = new Map<number, fs.WriteStream>(); |
| private static backgroundProcessHistory = new Map< |
| string, |
| Map<number, BackgroundProcessRecord> |
| >(); |
|
|
| static getLogDir(): string { |
| return path.join(Storage.getGlobalTempDir(), 'background-processes'); |
| } |
|
|
| private static formatShellBackgroundCompletion( |
| pid: number, |
| behavior: string, |
| output: string, |
| error?: Error, |
| ): string { |
| const logPath = ShellExecutionService.getLogFilePath(pid); |
| const status = error ? `with error: ${error.message}` : 'successfully'; |
|
|
| if (behavior === 'inject') { |
| const truncated = truncateString(output, 5000); |
| return `[Background command completed ${status}. Output saved to ${logPath}]\n\n${truncated}`; |
| } |
|
|
| return `[Background command completed ${status}. Output saved to ${logPath}]`; |
| } |
|
|
| static getLogFilePath(pid: number): string { |
| return path.join(this.getLogDir(), `background-${pid}.log`); |
| } |
|
|
| private static syncBackgroundLog(pid: number, content: string): void { |
| if (!this.backgroundLogPids.has(pid)) return; |
|
|
| const stream = this.backgroundLogStreams.get(pid); |
| if (stream && content) { |
| |
| stream.write(stripAnsi(content)); |
| } |
| } |
|
|
| private static async cleanupLogStream(pid: number): Promise<void> { |
| const stream = this.backgroundLogStreams.get(pid); |
| if (stream) { |
| await new Promise<void>((resolve) => { |
| stream.end(() => resolve()); |
| }); |
| this.backgroundLogStreams.delete(pid); |
| } |
|
|
| this.backgroundLogPids.delete(pid); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| static async execute( |
| commandToExecute: string, |
| cwd: string, |
| onOutputEvent: (event: ShellOutputEvent) => void, |
| abortSignal: AbortSignal, |
| shouldUseNodePty: boolean, |
| shellExecutionConfig: ShellExecutionConfig, |
| ): Promise<ShellExecutionHandle> { |
| if (shouldUseNodePty) { |
| const ptyInfo = await getPty(); |
| if (ptyInfo) { |
| try { |
| return await this.executeWithPty( |
| commandToExecute, |
| cwd, |
| onOutputEvent, |
| abortSignal, |
| shellExecutionConfig, |
| ptyInfo, |
| ); |
| } catch { |
| |
| } |
| } |
| } |
|
|
| return this.childProcessFallback( |
| commandToExecute, |
| cwd, |
| onOutputEvent, |
| abortSignal, |
| shellExecutionConfig, |
| shouldUseNodePty, |
| ); |
| } |
|
|
| private static appendAndTruncate( |
| currentBuffer: string, |
| chunk: string, |
| maxSize: number, |
| ): { newBuffer: string; truncated: boolean } { |
| const chunkLength = chunk.length; |
| const currentLength = currentBuffer.length; |
| const newTotalLength = currentLength + chunkLength; |
|
|
| if (newTotalLength <= maxSize) { |
| return { newBuffer: currentBuffer + chunk, truncated: false }; |
| } |
|
|
| |
| if (chunkLength >= maxSize) { |
| |
| |
| return { |
| newBuffer: chunk.substring(chunkLength - maxSize), |
| truncated: true, |
| }; |
| } |
|
|
| |
| |
| const charsToTrim = newTotalLength - maxSize; |
| const truncatedBuffer = currentBuffer.substring(charsToTrim); |
| return { newBuffer: truncatedBuffer + chunk, truncated: true }; |
| } |
|
|
| private static async prepareExecution( |
| commandToExecute: string, |
| cwd: string, |
| shellExecutionConfig: ShellExecutionConfig, |
| isInteractive: boolean, |
| usingPty: boolean, |
| ): Promise<{ |
| program: string; |
| args: string[]; |
| env: NodeJS.ProcessEnv; |
| cwd: string; |
| cleanup?: () => void; |
| }> { |
| const sandboxManager = |
| shellExecutionConfig.sandboxManager ?? new NoopSandboxManager(); |
|
|
| |
| const isWindows = os.platform() === 'win32'; |
| const isStrictSandbox = |
| isWindows && |
| shellExecutionConfig.sandboxConfig?.enabled && |
| shellExecutionConfig.sandboxConfig?.command === 'windows-native' && |
| !shellExecutionConfig.sandboxConfig?.networkAccess; |
|
|
| let { executable, argsPrefix, shell } = getShellConfiguration(); |
| if (isStrictSandbox) { |
| shell = 'cmd'; |
| argsPrefix = ['/c']; |
| executable = 'cmd.exe'; |
| } |
|
|
| const resolvedExecutable = resolveExecutable(executable) ?? executable; |
|
|
| const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell); |
| const finalCommand = injectUtf8CodepageForPty( |
| guardedCommand, |
| shell, |
| isWindows, |
| usingPty, |
| ); |
| const spawnArgs = [...argsPrefix, finalCommand]; |
|
|
| |
| const sourceEnv = shellExecutionConfig.env ?? process.env; |
| const gitConfigKeys: string[] = []; |
| for (const key in sourceEnv) { |
| if (key.startsWith('GIT_CONFIG_')) { |
| gitConfigKeys.push(key); |
| } |
| } |
|
|
| const sanitizationConfig = { |
| ...shellExecutionConfig.sanitizationConfig, |
| allowedEnvironmentVariables: [ |
| ...(shellExecutionConfig.sanitizationConfig |
| .allowedEnvironmentVariables || []), |
| ...gitConfigKeys, |
| ], |
| }; |
|
|
| const sanitizedEnv = sanitizeEnvironment(sourceEnv, sanitizationConfig); |
|
|
| const baseEnv: Record<string, string | undefined> = { |
| ...sanitizedEnv, |
| [GEMINI_CLI_IDENTIFICATION_ENV_VAR]: |
| GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE, |
| TERM: 'xterm-256color', |
| PAGER: shellExecutionConfig.pager ?? 'cat', |
| GIT_PAGER: shellExecutionConfig.pager ?? 'cat', |
| }; |
|
|
| |
| for (const key of gitConfigKeys) { |
| baseEnv[key] = sourceEnv[key]; |
| } |
|
|
| let gitConfigCount = parseInt(baseEnv['GIT_CONFIG_COUNT'] || '0', 10); |
| const devNullPath = os.platform() === 'win32' ? 'NUL' : '/dev/null'; |
|
|
| baseEnv['GIT_CONFIG_GLOBAL'] = devNullPath; |
| baseEnv['GIT_CONFIG_SYSTEM'] = devNullPath; |
| baseEnv['GIT_CONFIG_NOSYSTEM'] = '1'; |
|
|
| sanitizationConfig.allowedEnvironmentVariables.push( |
| 'GIT_CONFIG_COUNT', |
| 'GIT_CONFIG_GLOBAL', |
| 'GIT_CONFIG_SYSTEM', |
| 'GIT_CONFIG_NOSYSTEM', |
| ); |
|
|
| const defaultGitOverrides: Array<[string, string]> = [ |
| ['credential.helper', ''], |
| ['core.fsmonitor', ''], |
| ['core.hooksPath', ''], |
| ['core.sshCommand', ''], |
| ['core.pager', 'cat'], |
| ['core.editor', ''], |
| ['sequence.editor', ''], |
| ['diff.external', ''], |
| ]; |
|
|
| for (const [overrideKey, overrideVal] of defaultGitOverrides) { |
| const keyVar = `GIT_CONFIG_KEY_${gitConfigCount}`; |
| const valVar = `GIT_CONFIG_VALUE_${gitConfigCount}`; |
| sanitizationConfig.allowedEnvironmentVariables.push(keyVar, valVar); |
| baseEnv[keyVar] = overrideKey; |
| baseEnv[valVar] = overrideVal; |
| gitConfigCount++; |
| } |
|
|
| baseEnv['GIT_CONFIG_COUNT'] = gitConfigCount.toString(); |
|
|
| Object.assign(baseEnv, { |
| GIT_TERMINAL_PROMPT: '0', |
| GIT_ASKPASS: '', |
| SSH_ASKPASS: '', |
| GH_PROMPT_DISABLED: '1', |
| GCM_INTERACTIVE: 'never', |
| DISPLAY: '', |
| DBUS_SESSION_BUS_ADDRESS: '', |
| }); |
|
|
| |
| const sandboxedCommand = await sandboxManager.prepareCommand({ |
| command: resolvedExecutable, |
| args: spawnArgs, |
| env: baseEnv, |
| cwd, |
| policy: { |
| ...shellExecutionConfig, |
| ...(shellExecutionConfig.sandboxConfig || {}), |
| sanitizationConfig, |
| additionalPermissions: shellExecutionConfig.additionalPermissions, |
| }, |
| }); |
|
|
| return { |
| program: sandboxedCommand.program, |
| args: sandboxedCommand.args, |
| env: sandboxedCommand.env, |
| cwd: sandboxedCommand.cwd ?? cwd, |
| cleanup: sandboxedCommand.cleanup, |
| }; |
| } |
|
|
| private static async childProcessFallback( |
| commandToExecute: string, |
| cwd: string, |
| onOutputEvent: (event: ShellOutputEvent) => void, |
| abortSignal: AbortSignal, |
| shellExecutionConfig: ShellExecutionConfig, |
| isInteractive: boolean, |
| ): Promise<ShellExecutionHandle> { |
| let cmdCleanup: (() => void) | undefined; |
| try { |
| const isWindows = os.platform() === 'win32'; |
|
|
| const prepared = await this.prepareExecution( |
| commandToExecute, |
| cwd, |
| shellExecutionConfig, |
| isInteractive, |
| false, |
| ); |
| cmdCleanup = prepared.cleanup; |
|
|
| const { |
| program: finalExecutable, |
| args: finalArgs, |
| env: finalEnv, |
| cwd: finalCwd, |
| } = prepared; |
|
|
| |
| |
| |
| |
| |
| const isBun = 'bun' in process.versions; |
| const child = cpSpawn(finalExecutable, finalArgs, { |
| cwd: finalCwd, |
| stdio: ['ignore', 'pipe', 'pipe'], |
| windowsVerbatimArguments: isWindows ? false : undefined, |
| shell: false, |
| detached: !isWindows && !isBun, |
| env: finalEnv, |
| }); |
|
|
| const state = { |
| output: '', |
| truncated: false, |
| sniffChunks: [] as Buffer[], |
| binaryBytesReceived: 0, |
| }; |
|
|
| if (child.pid !== undefined) { |
| this.activeChildProcesses.set(child.pid, { |
| process: child, |
| state, |
| command: shellExecutionConfig.originalCommand ?? commandToExecute, |
| sessionId: shellExecutionConfig.sessionId, |
| }); |
| } |
|
|
| const lifecycleHandle = child.pid |
| ? ExecutionLifecycleService.attachExecution(child.pid, { |
| executionMethod: 'child_process', |
| getBackgroundOutput: () => state.output, |
| getSubscriptionSnapshot: () => state.output || undefined, |
| writeInput: (input) => { |
| const stdin = child.stdin as Writable | null; |
| if (stdin) { |
| stdin.write(input); |
| } |
| }, |
| kill: () => { |
| if (child.pid) { |
| killProcessGroup({ pid: child.pid }).catch(() => {}); |
| this.activeChildProcesses.delete(child.pid); |
| } |
| }, |
| isActive: () => { |
| if (!child.pid) { |
| return false; |
| } |
| try { |
| return process.kill(child.pid, 0); |
| } catch { |
| return false; |
| } |
| }, |
| formatInjection: (output, error) => |
| ShellExecutionService.formatShellBackgroundCompletion( |
| child.pid!, |
| shellExecutionConfig.backgroundCompletionBehavior || 'silent', |
| output, |
| error ?? undefined, |
| ), |
| completionBehavior: |
| shellExecutionConfig.backgroundCompletionBehavior || 'silent', |
| }) |
| : undefined; |
|
|
| let resolveWithoutPid: |
| | ((result: ShellExecutionResult) => void) |
| | undefined; |
| const result = |
| lifecycleHandle?.result ?? |
| new Promise<ShellExecutionResult>((resolve) => { |
| resolveWithoutPid = resolve; |
| }); |
|
|
| let stdoutDecoder: TextDecoder | null = null; |
| let stderrDecoder: TextDecoder | null = null; |
| 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) { |
| stdoutDecoder = new TextDecoder('utf-8'); |
| stderrDecoder = new TextDecoder('utf-8'); |
| } |
|
|
| if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) { |
| state.sniffChunks.push(data); |
| } else if (!isStreamingRawContent) { |
| state.binaryBytesReceived += data.length; |
| } |
|
|
| if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) { |
| const sniffBuffer = Buffer.concat(state.sniffChunks); |
| sniffedBytes = sniffBuffer.length; |
|
|
| if (isBinary(sniffBuffer)) { |
| isStreamingRawContent = false; |
| state.binaryBytesReceived = sniffBuffer.length; |
| const event: ShellOutputEvent = { type: 'binary_detected' }; |
| onOutputEvent(event); |
| if (child.pid) { |
| ExecutionLifecycleService.emitEvent(child.pid, event); |
| } |
| } |
| } |
|
|
| if (isStreamingRawContent) { |
| const decoder = stream === 'stdout' ? stdoutDecoder : stderrDecoder; |
| const decodedChunk = decoder.decode(data, { stream: true }); |
|
|
| const { newBuffer, truncated } = this.appendAndTruncate( |
| state.output, |
| decodedChunk, |
| MAX_CHILD_PROCESS_BUFFER_SIZE, |
| ); |
| state.output = newBuffer; |
| if (truncated) { |
| state.truncated = true; |
| } |
|
|
| if (decodedChunk) { |
| const event: ShellOutputEvent = { |
| type: 'data', |
| chunk: decodedChunk, |
| }; |
| onOutputEvent(event); |
| if (child.pid) { |
| ExecutionLifecycleService.emitEvent(child.pid, event); |
| if (ShellExecutionService.backgroundLogPids.has(child.pid)) { |
| ShellExecutionService.syncBackgroundLog( |
| child.pid, |
| decodedChunk, |
| ); |
| } |
| } |
| } |
| } else { |
| const totalBytes = state.binaryBytesReceived; |
| const event: ShellOutputEvent = { |
| type: 'binary_progress', |
| bytesReceived: totalBytes, |
| }; |
| onOutputEvent(event); |
| if (child.pid) { |
| ExecutionLifecycleService.emitEvent(child.pid, event); |
| } |
| } |
| }; |
|
|
| const handleExit = ( |
| code: number | null, |
| signal: NodeJS.Signals | null, |
| ) => { |
| cleanup(); |
| cmdCleanup?.(); |
|
|
| let combinedOutput = state.output; |
| if (state.truncated) { |
| const truncationMessage = `\n[GEMINI_CLI_WARNING: Output truncated. The buffer is limited to ${ |
| MAX_CHILD_PROCESS_BUFFER_SIZE / (1024 * 1024) |
| }MB.]`; |
| combinedOutput += truncationMessage; |
| } |
|
|
| const finalStrippedOutput = stripAnsi(combinedOutput).trim(); |
| const exitCode = code; |
| const exitSignal = |
| signal && os.constants.signals |
| ? (os.constants.signals[signal] ?? null) |
| : null; |
|
|
| const resultPayload: ShellExecutionResult = { |
| rawOutput: Buffer.from(''), |
| output: finalStrippedOutput, |
| exitCode, |
| signal: exitSignal, |
| error, |
| aborted: abortSignal.aborted, |
| pid: child.pid, |
| executionMethod: 'child_process', |
| }; |
|
|
| if (child.pid) { |
| const pid = child.pid; |
| const event: ShellOutputEvent = { |
| type: 'exit', |
| exitCode, |
| signal: exitSignal, |
| }; |
|
|
| const sessionId = shellExecutionConfig.sessionId ?? 'default'; |
| const history = |
| ShellExecutionService.backgroundProcessHistory.get(sessionId); |
| const historyItem = history?.get(pid); |
| if (historyItem) { |
| historyItem.status = 'exited'; |
| historyItem.exitCode = exitCode ?? undefined; |
| historyItem.signal = exitSignal ?? undefined; |
| historyItem.endTime = Date.now(); |
| } |
| onOutputEvent(event); |
|
|
| |
| ShellExecutionService.cleanupLogStream(pid).then(() => { |
| ShellExecutionService.activeChildProcesses.delete(pid); |
| }); |
|
|
| ExecutionLifecycleService.completeWithResult(pid, resultPayload); |
| } else { |
| resolveWithoutPid?.(resultPayload); |
| } |
| }; |
|
|
| child.stdout.on('data', (data) => handleOutput(data, 'stdout')); |
| child.stderr.on('data', (data) => handleOutput(data, 'stderr')); |
| child.on('error', (err) => { |
| error = err; |
| handleExit(1, null); |
| }); |
|
|
| const abortHandler = async () => { |
| if (child.pid && !exited) { |
| await killProcessGroup({ |
| pid: child.pid, |
| escalate: true, |
| isExited: () => exited, |
| }); |
| } |
| }; |
|
|
| abortSignal.addEventListener('abort', abortHandler, { once: true }); |
|
|
| child.on('close', (code, signal) => { |
| handleExit(code, signal); |
| }); |
|
|
| function cleanup() { |
| exited = true; |
| abortSignal.removeEventListener('abort', abortHandler); |
| if (stdoutDecoder) { |
| const remaining = stdoutDecoder.decode(); |
| if (remaining) { |
| state.output += remaining; |
| if (isStreamingRawContent) { |
| const event: ShellOutputEvent = { |
| type: 'data', |
| chunk: remaining, |
| }; |
| onOutputEvent(event); |
| if (child.pid) { |
| ExecutionLifecycleService.emitEvent(child.pid, event); |
| } |
| } |
| } |
| } |
| if (stderrDecoder) { |
| const remaining = stderrDecoder.decode(); |
| if (remaining) { |
| state.output += remaining; |
| if (isStreamingRawContent) { |
| const event: ShellOutputEvent = { |
| type: 'data', |
| chunk: remaining, |
| }; |
| onOutputEvent(event); |
| if (child.pid) { |
| ExecutionLifecycleService.emitEvent(child.pid, event); |
| } |
| } |
| } |
| } |
|
|
| return; |
| } |
|
|
| return { pid: child.pid, result }; |
| } catch (e) { |
| |
| const error = e as Error; |
| cmdCleanup?.(); |
| return { |
| pid: undefined, |
| result: Promise.resolve({ |
| error, |
| rawOutput: Buffer.from(''), |
| output: '', |
| exitCode: 1, |
| signal: null, |
| aborted: false, |
| pid: undefined, |
| executionMethod: 'none', |
| }), |
| }; |
| } |
| } |
| |
| |
| |
| |
| private static destroyPtyProcess(ptyProcess: DestroyablePty): void { |
| try { |
| if (typeof ptyProcess?.destroy === 'function') { |
| ptyProcess.destroy(); |
| } else if (typeof ptyProcess?.kill === 'function') { |
| |
| ptyProcess.kill(); |
| } |
| } catch { |
| |
| } |
| } |
|
|
| |
| |
| |
| |
| private static cleanupPtyEntry(pid: number): void { |
| const entry = this.activePtys.get(pid); |
| if (!entry) return; |
|
|
| this.destroyPtyProcess(entry.ptyProcess); |
|
|
| try { |
| entry.headlessTerminal.dispose(); |
| } catch { |
| |
| } |
|
|
| this.activePtys.delete(pid); |
| } |
|
|
| private static async executeWithPty( |
| commandToExecute: string, |
| cwd: string, |
| onOutputEvent: (event: ShellOutputEvent) => void, |
| abortSignal: AbortSignal, |
| shellExecutionConfig: ShellExecutionConfig, |
| ptyInfo: PtyImplementation, |
| ): Promise<ShellExecutionHandle> { |
| if (!ptyInfo) { |
| |
| throw new Error('PTY implementation not found'); |
| } |
| let spawnedPty: DestroyablePty | undefined; |
| let cmdCleanup: (() => void) | undefined; |
| let headlessTerminal: pkg.Terminal | undefined; |
| const disposables: Array<{ dispose: () => void }> = []; |
|
|
| try { |
| const cols = shellExecutionConfig.terminalWidth ?? 80; |
| const rows = shellExecutionConfig.terminalHeight ?? 30; |
|
|
| const prepared = await this.prepareExecution( |
| commandToExecute, |
| cwd, |
| shellExecutionConfig, |
| true, |
| true, |
| ); |
| cmdCleanup = prepared.cleanup; |
|
|
| const { |
| program: finalExecutable, |
| args: finalArgs, |
| env: finalEnv, |
| cwd: finalCwd, |
| } = prepared; |
|
|
| const isWindowsPlatform = os.platform() === 'win32'; |
| |
| const ptyProcess = ptyInfo.module.spawn(finalExecutable, finalArgs, { |
| cwd: finalCwd, |
| name: 'xterm-256color', |
| cols, |
| rows, |
| env: finalEnv, |
| |
| |
| |
| |
| handleFlowControl: !isWindowsPlatform, |
| |
| |
| |
| |
| ...(isWindowsPlatform ? { useConpty: true } : {}), |
| }); |
|
|
| |
| spawnedPty = ptyProcess as DestroyablePty; |
| const pty = spawnedPty; |
| const ptyPid = Number(pty.pid); |
|
|
| headlessTerminal = new Terminal({ |
| allowProposedApi: true, |
| cols, |
| rows, |
| scrollback: shellExecutionConfig.scrollback ?? SCROLLBACK_LIMIT, |
| }); |
| headlessTerminal.scrollToTop(); |
|
|
| const terminal = headlessTerminal; |
|
|
| this.activePtys.set(ptyPid, { |
| ptyProcess: pty, |
| headlessTerminal, |
| maxSerializedLines: shellExecutionConfig.maxSerializedLines, |
| command: shellExecutionConfig.originalCommand ?? commandToExecute, |
| sessionId: shellExecutionConfig.sessionId, |
| }); |
|
|
| const result = ExecutionLifecycleService.attachExecution(ptyPid, { |
| executionMethod: ptyInfo?.name ?? 'node-pty', |
| writeInput: (input) => { |
| if (!ExecutionLifecycleService.isActive(ptyPid)) { |
| return; |
| } |
| pty.write(input); |
| }, |
| kill: () => { |
| killProcessGroup({ |
| pid: ptyPid, |
| pty, |
| }).catch(() => {}); |
| }, |
| isActive: () => { |
| |
| |
| |
| |
| if (ShellExecutionService.activePtys.has(ptyPid)) { |
| return true; |
| } |
| try { |
| return process.kill(ptyPid, 0); |
| } catch { |
| return false; |
| } |
| }, |
| getBackgroundOutput: () => getFullBufferText(terminal), |
| getSubscriptionSnapshot: () => { |
| const endLine = terminal.buffer.active.length; |
| const startLine = Math.max( |
| 0, |
| endLine - (shellExecutionConfig.maxSerializedLines ?? 2000), |
| ); |
| const bufferData = serializeTerminalToObject( |
| terminal, |
| startLine, |
| endLine, |
| ); |
| return bufferData.length > 0 ? bufferData : undefined; |
| }, |
| formatInjection: (output, error) => |
| ShellExecutionService.formatShellBackgroundCompletion( |
| ptyPid, |
| shellExecutionConfig.backgroundCompletionBehavior || 'silent', |
| output, |
| error ?? undefined, |
| ), |
| completionBehavior: |
| shellExecutionConfig.backgroundCompletionBehavior || 'silent', |
| }).result; |
|
|
| let processingChain = Promise.resolve(); |
| let decoder: TextDecoder | null = null; |
| let output: string | AnsiOutput | null = null; |
| const sniffChunks: Buffer[] = []; |
| let binaryBytesReceived = 0; |
| const error: Error | null = null; |
| let exited = false; |
|
|
| let isStreamingRawContent = true; |
| const MAX_SNIFF_SIZE = 4096; |
| let sniffedBytes = 0; |
| let isWriting = false; |
| let hasStartedOutput = false; |
| let renderTimeout: NodeJS.Timeout | null = null; |
|
|
| const renderFn = () => { |
| renderTimeout = null; |
|
|
| if (!isStreamingRawContent) { |
| return; |
| } |
|
|
| if (!shellExecutionConfig.disableDynamicLineTrimming) { |
| if (!hasStartedOutput) { |
| const bufferText = getFullBufferText(terminal); |
| if (bufferText.trim().length === 0) { |
| return; |
| } |
| hasStartedOutput = true; |
| } |
| } |
|
|
| const buffer = terminal.buffer.active; |
| const endLine = buffer.length; |
| const startLine = Math.max( |
| 0, |
| endLine - (shellExecutionConfig.maxSerializedLines ?? 2000), |
| ); |
|
|
| let newOutput: AnsiOutput; |
| if (shellExecutionConfig.showColor) { |
| newOutput = serializeTerminalToObject(terminal, startLine, endLine); |
| } else { |
| newOutput = ( |
| serializeTerminalToObject(terminal, startLine, endLine) || [] |
| ).map((line) => |
| line.map((token) => { |
| token.fg = ''; |
| token.bg = ''; |
| return token; |
| }), |
| ); |
| } |
|
|
| let lastNonEmptyLine = -1; |
| for (let i = newOutput.length - 1; i >= 0; i--) { |
| const line = newOutput[i]; |
| if ( |
| line |
| .map((segment) => segment.text) |
| .join('') |
| .trim().length > 0 |
| ) { |
| lastNonEmptyLine = i; |
| break; |
| } |
| } |
|
|
| const absoluteCursorY = buffer.baseY + buffer.cursorY; |
| const cursorRelativeIndex = absoluteCursorY - startLine; |
|
|
| if (cursorRelativeIndex > lastNonEmptyLine) { |
| lastNonEmptyLine = cursorRelativeIndex; |
| } |
|
|
| const trimmedOutput = newOutput.slice(0, lastNonEmptyLine + 1); |
|
|
| const finalOutput = shellExecutionConfig.disableDynamicLineTrimming |
| ? newOutput |
| : trimmedOutput; |
|
|
| if (output !== finalOutput) { |
| output = finalOutput; |
| const event: ShellOutputEvent = { |
| type: 'data', |
| chunk: finalOutput, |
| }; |
| onOutputEvent(event); |
| ExecutionLifecycleService.emitEvent(ptyPid, event); |
| } |
| }; |
|
|
| const render = (finalRender = false) => { |
| if (finalRender) { |
| if (renderTimeout) { |
| clearTimeout(renderTimeout); |
| } |
| renderFn(); |
| return; |
| } |
|
|
| if (renderTimeout) { |
| return; |
| } |
|
|
| renderTimeout = setTimeout(() => { |
| renderFn(); |
| renderTimeout = null; |
| }, 68); |
| }; |
|
|
| headlessTerminal.onScroll(() => { |
| if (!isWriting) { |
| render(); |
| } |
| }); |
|
|
| const handleOutput = (data: Buffer) => { |
| processingChain = processingChain.then( |
| () => |
| new Promise<void>((resolveChunk) => { |
| if (!decoder) { |
| decoder = new TextDecoder('utf-8'); |
| } |
|
|
| if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) { |
| sniffChunks.push(data); |
| } else if (!isStreamingRawContent) { |
| binaryBytesReceived += data.length; |
| } |
|
|
| if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) { |
| const sniffBuffer = Buffer.concat(sniffChunks); |
| sniffedBytes = sniffBuffer.length; |
|
|
| if (isBinary(sniffBuffer, 512, true)) { |
| isStreamingRawContent = false; |
| binaryBytesReceived = sniffBuffer.length; |
| const event: ShellOutputEvent = { type: 'binary_detected' }; |
| onOutputEvent(event); |
| ExecutionLifecycleService.emitEvent(ptyPid, event); |
| } |
| } |
|
|
| if (isStreamingRawContent) { |
| const decodedChunk = decoder.decode(data, { stream: true }); |
| if (decodedChunk.length === 0) { |
| resolveChunk(); |
| return; |
| } |
|
|
| if (ShellExecutionService.backgroundLogPids.has(ptyPid)) { |
| ShellExecutionService.syncBackgroundLog(ptyPid, decodedChunk); |
| } |
|
|
| isWriting = true; |
| terminal.write(decodedChunk, () => { |
| render(); |
| isWriting = false; |
| resolveChunk(); |
| }); |
| } else { |
| const totalBytes = binaryBytesReceived; |
| const event: ShellOutputEvent = { |
| type: 'binary_progress', |
| bytesReceived: totalBytes, |
| }; |
| onOutputEvent(event); |
| ExecutionLifecycleService.emitEvent(ptyPid, event); |
| resolveChunk(); |
| } |
| }), |
| ); |
| }; |
|
|
| const dataListener = pty.onData((data) => { |
| const bufferData = Buffer.from(data, 'utf-8'); |
| handleOutput(bufferData); |
| }); |
| disposables.push(dataListener); |
|
|
| const exitListener = pty.onExit(({ exitCode, signal }) => { |
| exited = true; |
| abortSignal.removeEventListener('abort', abortHandler); |
|
|
| |
| |
| |
| ShellExecutionService.destroyPtyProcess(pty); |
|
|
| const finalize = () => { |
| render(true); |
| cmdCleanup?.(); |
|
|
| |
| disposables.forEach((d) => { |
| try { |
| d.dispose(); |
| } catch { |
| |
| } |
| }); |
|
|
| const event: ShellOutputEvent = { |
| type: 'exit', |
| exitCode, |
| signal: signal ?? null, |
| }; |
|
|
| const sessionId = shellExecutionConfig.sessionId ?? 'default'; |
| const history = |
| ShellExecutionService.backgroundProcessHistory.get(sessionId); |
| const historyItem = history?.get(ptyPid); |
| if (historyItem) { |
| historyItem.status = 'exited'; |
| historyItem.exitCode = exitCode; |
| historyItem.signal = signal ?? null; |
| historyItem.endTime = Date.now(); |
| } |
| onOutputEvent(event); |
|
|
| const endLine = headlessTerminal |
| ? headlessTerminal.buffer.active.length |
| : 0; |
| const startLine = Math.max( |
| 0, |
| endLine - (shellExecutionConfig.maxSerializedLines ?? 2000), |
| ); |
| const ansiOutputSnapshot = headlessTerminal |
| ? serializeTerminalToObject(headlessTerminal, startLine, endLine) |
| : []; |
| const finalOutput = headlessTerminal |
| ? getFullBufferText(headlessTerminal) |
| : ''; |
|
|
| |
| |
| try { |
| headlessTerminal?.dispose(); |
| } catch { |
| |
| } |
|
|
| |
| ShellExecutionService.cleanupLogStream(ptyPid).then(() => { |
| ShellExecutionService.activePtys.delete(ptyPid); |
| }); |
|
|
| ExecutionLifecycleService.completeWithResult(ptyPid, { |
| rawOutput: Buffer.from(''), |
| output: finalOutput, |
| ansiOutput: ansiOutputSnapshot, |
| exitCode, |
| signal: signal ?? null, |
| error, |
| aborted: abortSignal.aborted, |
| pid: ptyPid, |
| executionMethod: ptyInfo?.name ?? 'node-pty', |
| }); |
| }; |
|
|
| if (abortSignal.aborted) { |
| finalize(); |
| return; |
| } |
|
|
| const processingComplete = processingChain.then(() => 'processed'); |
| const abortFired = new Promise<'aborted'>((res) => { |
| if (abortSignal.aborted) { |
| res('aborted'); |
| return; |
| } |
| abortSignal.addEventListener('abort', () => res('aborted'), { |
| once: true, |
| }); |
| }); |
|
|
| |
| Promise.race([processingComplete, abortFired]).then(() => { |
| finalize(); |
| }); |
| }); |
| disposables.push(exitListener); |
|
|
| const abortHandler = async () => { |
| if (ptyProcess.pid && !exited) { |
| await killProcessGroup({ |
| pid: ptyPid, |
| escalate: true, |
| isExited: () => exited, |
| |
| pty: ptyProcess, |
| }); |
| } |
| }; |
|
|
| abortSignal.addEventListener('abort', abortHandler, { once: true }); |
|
|
| return { pid: ptyPid, result }; |
| } catch (e) { |
| |
| const error = e as Error; |
| cmdCleanup?.(); |
|
|
| if (spawnedPty) { |
| ShellExecutionService.destroyPtyProcess(spawnedPty); |
| } |
|
|
| if (headlessTerminal) { |
| try { |
| headlessTerminal.dispose(); |
| } catch { |
| |
| } |
| } |
|
|
| |
| disposables.forEach((d) => { |
| try { |
| d.dispose(); |
| } catch { |
| |
| } |
| }); |
|
|
| const isPtyCreationFailure = |
| error?.message?.includes('posix_spawnp failed') || |
| error?.message?.includes('ENXIO') || |
| (isNodeError(error) && error.code === 'ENXIO') || |
| error?.message?.includes('Device not configured'); |
|
|
| if (isPtyCreationFailure) { |
| onOutputEvent({ |
| type: 'data', |
| chunk: |
| '[GEMINI_CLI_WARNING] PTY execution failed, falling back to child_process. This may be due to terminal exhaustion or sandbox restrictions.\n', |
| }); |
| throw e; |
| } else { |
| return { |
| pid: undefined, |
| result: Promise.resolve({ |
| error, |
| rawOutput: Buffer.from(''), |
| output: '', |
| exitCode: 1, |
| signal: null, |
| aborted: false, |
| pid: undefined, |
| executionMethod: 'none', |
| }), |
| }; |
| } |
| } |
| } |
| |
| |
| |
| |
| |
| |
| static writeToPty(pid: number, input: string): void { |
| ExecutionLifecycleService.writeInput(pid, input); |
| } |
|
|
| static isPtyActive(pid: number): boolean { |
| return ExecutionLifecycleService.isActive(pid); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| static onExit( |
| pid: number, |
| callback: (exitCode: number, signal?: number) => void, |
| ): () => void { |
| return ExecutionLifecycleService.onExit(pid, callback); |
| } |
|
|
| |
| |
| |
| |
| |
| static async kill(pid: number): Promise<void> { |
| await this.cleanupLogStream(pid); |
| this.activeChildProcesses.delete(pid); |
| ExecutionLifecycleService.kill(pid); |
| this.cleanupPtyEntry(pid); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| static background(pid: number, sessionId?: string, command?: string): void { |
| const activePty = this.activePtys.get(pid); |
| const activeChild = this.activeChildProcesses.get(pid); |
|
|
| const resolvedSessionId = |
| sessionId ?? activePty?.sessionId ?? activeChild?.sessionId; |
| const resolvedCommand = |
| command ?? |
| activePty?.command ?? |
| activeChild?.command ?? |
| 'unknown command'; |
|
|
| if (!resolvedSessionId) { |
| throw new Error('Session ID is required for background operations'); |
| } |
|
|
| const MAX_BACKGROUND_PROCESS_HISTORY_SIZE = 100; |
| const history = |
| this.backgroundProcessHistory.get(resolvedSessionId) ?? |
| new Map< |
| number, |
| { |
| command: string; |
| status: 'running' | 'exited'; |
| exitCode?: number | null; |
| signal?: number | null; |
| startTime: number; |
| endTime?: number; |
| } |
| >(); |
|
|
| if (history.size >= MAX_BACKGROUND_PROCESS_HISTORY_SIZE) { |
| const oldestPid = history.keys().next().value; |
| if (oldestPid !== undefined) { |
| history.delete(oldestPid); |
| } |
| } |
|
|
| history.set(pid, { |
| command: resolvedCommand, |
| status: 'running', |
| startTime: Date.now(), |
| }); |
| this.backgroundProcessHistory.set(resolvedSessionId, history); |
|
|
| |
| const logPath = this.getLogFilePath(pid); |
| const logDir = this.getLogDir(); |
| try { |
| mkdirSync(logDir, { recursive: true, mode: 0o700 }); |
| const stream = fs.createWriteStream(logPath, { flags: 'wx' }); |
| stream.on('error', (err) => { |
| debugLogger.warn('Background log stream error:', err); |
| }); |
| this.backgroundLogStreams.set(pid, stream); |
|
|
| if (activePty) { |
| writeBufferToLogStream(activePty.headlessTerminal, stream, 0); |
| } else if (activeChild) { |
| const output = activeChild.state.output; |
| if (output) { |
| stream.write(stripAnsi(output) + '\n'); |
| } |
| } |
| } catch (e) { |
| debugLogger.warn('Failed to setup background logging:', e); |
| } |
|
|
| this.backgroundLogPids.add(pid); |
|
|
| ExecutionLifecycleService.background(pid); |
| } |
|
|
| static subscribe( |
| pid: number, |
| listener: (event: ShellOutputEvent) => void, |
| ): () => void { |
| return ExecutionLifecycleService.subscribe(pid, listener); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| static resizePty(pid: number, cols: number, rows: number): void { |
| if (!this.isPtyActive(pid)) { |
| return; |
| } |
|
|
| const activePty = this.activePtys.get(pid); |
| if (!activePty) { |
| return; |
| } |
|
|
| |
| if (process.platform !== 'win32') { |
| try { |
| process.kill(pid, 0); |
| } catch (e) { |
| |
| if (isNodeError(e) && e.code === 'ESRCH') { |
| return; |
| } |
| } |
| } |
|
|
| try { |
| activePty.ptyProcess.resize(cols, rows); |
| activePty.headlessTerminal.resize(cols, rows); |
| } catch (e) { |
| |
| |
| |
| const err = e as { code?: string; message?: string }; |
| const isEsrch = err.code === 'ESRCH'; |
| const isEbadf = err.code === 'EBADF' || err.message?.includes('EBADF'); |
| const isWindowsPtyError = err.message?.includes( |
| 'Cannot resize a pty that has already exited', |
| ); |
|
|
| if (isEsrch || isEbadf || isWindowsPtyError) { |
| |
| |
| |
| } else { |
| throw e; |
| } |
| } |
|
|
| |
| if (activePty) { |
| const endLine = activePty.headlessTerminal.buffer.active.length; |
| const startLine = Math.max( |
| 0, |
| endLine - (activePty.maxSerializedLines ?? 2000), |
| ); |
| const bufferData = serializeTerminalToObject( |
| activePty.headlessTerminal, |
| startLine, |
| endLine, |
| ); |
| const event: ShellOutputEvent = { type: 'data', chunk: bufferData }; |
| ExecutionLifecycleService.emitEvent(pid, event); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| static scrollPty(pid: number, lines: number): void { |
| if (!this.isPtyActive(pid)) { |
| return; |
| } |
|
|
| const activePty = this.activePtys.get(pid); |
| if (activePty) { |
| try { |
| activePty.headlessTerminal.scrollLines(lines); |
| if (activePty.headlessTerminal.buffer.active.viewportY < 0) { |
| activePty.headlessTerminal.scrollToTop(); |
| } |
| } catch (e) { |
| |
| |
| if (e instanceof Error && 'code' in e && e.code === 'ESRCH') { |
| |
| } else { |
| throw e; |
| } |
| } |
| } |
| } |
|
|
| static listBackgroundProcesses(sessionId: string): BackgroundProcess[] { |
| if (!sessionId) { |
| throw new Error('Session ID is required'); |
| } |
| const history = this.backgroundProcessHistory.get(sessionId); |
| if (!history) return []; |
|
|
| return Array.from(history.entries()).map(([pid, info]) => ({ |
| pid, |
| command: info.command, |
| status: info.status, |
| exitCode: info.exitCode, |
| signal: info.signal, |
| })); |
| } |
|
|
| |
| |
| |
| |
| static resetForTest(): void { |
| this.activePtys.clear(); |
| this.activeChildProcesses.clear(); |
| this.backgroundLogPids.clear(); |
| this.backgroundLogStreams.clear(); |
| this.backgroundProcessHistory.clear(); |
| } |
| } |
|
|