mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-24 12:04:56 -07:00
859c7c3a70
fix(cli): Write shell command output to a file and limit memory buffered in UI Fixes. Checkpoint. fix(core, cli): await outputStream.end() to prevent race conditions This commit fixes a critical race condition where was called synchronously without being awaited. This led to potential file truncation or EBUSY errors on Windows when attempting to manipulate the file immediately after the call. Additionally, this change removes fixed wait times (`setTimeout`) that were previously used in test files as a band-aid. fix(core): stream processed xterm output to file to remove spurious escape codes test(core): update shell regression tests to use file_data events
1610 lines
48 KiB
TypeScript
1610 lines
48 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
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 { getCachedEncodingForBuffer } from '../utils/systemEncoding.js';
|
|
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 {
|
|
ExecutionLifecycleService,
|
|
type ExecutionHandle,
|
|
type ExecutionOutputEvent,
|
|
type ExecutionResult,
|
|
} from './executionLifecycleService.js';
|
|
const { Terminal } = pkg;
|
|
|
|
const MAX_CHILD_PROCESS_BUFFER_SIZE = 16 * 1024 * 1024; // 16MB
|
|
|
|
/**
|
|
* An environment variable that is set for shell executions. This can be used
|
|
* by downstream executables and scripts to identify that they were executed
|
|
* from within Gemini CLI.
|
|
*/
|
|
export const GEMINI_CLI_IDENTIFICATION_ENV_VAR = 'GEMINI_CLI';
|
|
|
|
/**
|
|
* The value of {@link GEMINI_CLI_IDENTIFICATION_ENV_VAR}
|
|
*/
|
|
export const GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE = '1';
|
|
|
|
// We want to allow shell outputs that are close to the context window in size.
|
|
// 300,000 lines is roughly equivalent to a large context window, ensuring
|
|
// we capture significant output from long-running commands.
|
|
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}`;
|
|
}
|
|
|
|
/** A structured result from a shell command execution. */
|
|
export type ShellExecutionResult = ExecutionResult;
|
|
|
|
/** A handle for an ongoing shell execution. */
|
|
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;
|
|
// Used for testing
|
|
disableDynamicLineTrimming?: boolean;
|
|
scrollback?: number;
|
|
maxSerializedLines?: number;
|
|
sandboxConfig?: SandboxConfig;
|
|
backgroundCompletionBehavior?: 'inject' | 'notify' | 'silent';
|
|
originalCommand?: string;
|
|
sessionId?: string;
|
|
}
|
|
|
|
/**
|
|
* Describes a structured event emitted during shell command execution.
|
|
*/
|
|
export type ShellOutputEvent = ExecutionOutputEvent;
|
|
|
|
interface ActivePty {
|
|
ptyProcess: IPty;
|
|
headlessTerminal: pkg.Terminal;
|
|
maxSerializedLines?: number;
|
|
command: string;
|
|
sessionId?: string;
|
|
lastSerializedOutput?: AnsiOutput;
|
|
lastCommittedLine: number;
|
|
}
|
|
|
|
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 emitPendingLines = (
|
|
activePty: ActivePty,
|
|
pid: number,
|
|
onOutputEvent: (event: ShellOutputEvent) => void,
|
|
forceAll = false,
|
|
) => {
|
|
const buffer = activePty.headlessTerminal.buffer.active;
|
|
const limit = forceAll ? buffer.length : buffer.baseY;
|
|
|
|
let chunks = '';
|
|
for (let i = activePty.lastCommittedLine + 1; i < limit; i++) {
|
|
const line = buffer.getLine(i);
|
|
if (!line) continue;
|
|
|
|
let trimRight = true;
|
|
let isNextLineWrapped = false;
|
|
if (i + 1 < buffer.length) {
|
|
const nextLine = buffer.getLine(i + 1);
|
|
if (nextLine?.isWrapped) {
|
|
isNextLineWrapped = true;
|
|
trimRight = false;
|
|
}
|
|
}
|
|
|
|
const lineContent = line.translateToString(trimRight);
|
|
chunks += lineContent;
|
|
if (!isNextLineWrapped) {
|
|
chunks += '\n';
|
|
}
|
|
}
|
|
|
|
if (chunks.length > 0) {
|
|
const event: ShellOutputEvent = {
|
|
type: 'file_data',
|
|
chunk: chunks,
|
|
};
|
|
onOutputEvent(event);
|
|
ExecutionLifecycleService.emitEvent(pid, event);
|
|
activePty.lastCommittedLine = limit - 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);
|
|
}
|
|
}
|
|
|
|
// Ensure it ends with a newline if we wrote anything and the next line is not wrapped
|
|
if (lastContentLine >= startLine) {
|
|
const nextLine = terminal.buffer.active.getLine(lastContentLine + 1);
|
|
if (!nextLine?.isWrapped) {
|
|
stream.write('\n');
|
|
}
|
|
}
|
|
|
|
return lastContentLine + 1;
|
|
};
|
|
|
|
/**
|
|
* A centralized service for executing shell commands with robust process
|
|
* management, cross-platform compatibility, and streaming output capabilities.
|
|
*
|
|
*/
|
|
|
|
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, // sessionId
|
|
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) {
|
|
// Strip ANSI escape codes before logging
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Executes a shell command using `node-pty`, 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 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 {
|
|
// Fallback to child_process
|
|
}
|
|
}
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
// Truncation is needed.
|
|
if (chunkLength >= maxSize) {
|
|
// The new chunk is larger than or equal to the max buffer size.
|
|
// The new buffer will be the tail of the new chunk.
|
|
return {
|
|
newBuffer: chunk.substring(chunkLength - maxSize),
|
|
truncated: true,
|
|
};
|
|
}
|
|
|
|
// The combined buffer exceeds the max size, but the new chunk is smaller than it.
|
|
// We need to truncate the current buffer from the beginning to make space.
|
|
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,
|
|
): Promise<{
|
|
program: string;
|
|
args: string[];
|
|
env: NodeJS.ProcessEnv;
|
|
cwd: string;
|
|
cleanup?: () => void;
|
|
}> {
|
|
const sandboxManager =
|
|
shellExecutionConfig.sandboxManager ?? new NoopSandboxManager();
|
|
|
|
// 1. Determine Shell Configuration
|
|
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 =
|
|
(await resolveExecutable(executable)) ?? executable;
|
|
|
|
const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell);
|
|
const spawnArgs = [...argsPrefix, guardedCommand];
|
|
|
|
// 2. Prepare Environment
|
|
const gitConfigKeys: string[] = [];
|
|
if (!isInteractive) {
|
|
for (const key in process.env) {
|
|
if (key.startsWith('GIT_CONFIG_')) {
|
|
gitConfigKeys.push(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
const sanitizationConfig = {
|
|
...shellExecutionConfig.sanitizationConfig,
|
|
allowedEnvironmentVariables: [
|
|
...(shellExecutionConfig.sanitizationConfig
|
|
.allowedEnvironmentVariables || []),
|
|
...gitConfigKeys,
|
|
],
|
|
};
|
|
|
|
const sanitizedEnv = sanitizeEnvironment(process.env, 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',
|
|
};
|
|
|
|
if (!isInteractive) {
|
|
// Ensure all GIT_CONFIG_* variables are preserved even if they were redacted
|
|
for (const key of gitConfigKeys) {
|
|
baseEnv[key] = process.env[key];
|
|
}
|
|
|
|
const gitConfigCount = parseInt(baseEnv['GIT_CONFIG_COUNT'] || '0', 10);
|
|
const newKey = `GIT_CONFIG_KEY_${gitConfigCount}`;
|
|
const newValue = `GIT_CONFIG_VALUE_${gitConfigCount}`;
|
|
|
|
// Ensure these new keys are allowed through sanitization
|
|
sanitizationConfig.allowedEnvironmentVariables.push(
|
|
'GIT_CONFIG_COUNT',
|
|
newKey,
|
|
newValue,
|
|
);
|
|
|
|
Object.assign(baseEnv, {
|
|
GIT_TERMINAL_PROMPT: '0',
|
|
GIT_ASKPASS: '',
|
|
SSH_ASKPASS: '',
|
|
GH_PROMPT_DISABLED: '1',
|
|
GCM_INTERACTIVE: 'never',
|
|
DISPLAY: '',
|
|
DBUS_SESSION_BUS_ADDRESS: '',
|
|
GIT_CONFIG_COUNT: (gitConfigCount + 1).toString(),
|
|
[newKey]: 'credential.helper',
|
|
[newValue]: '',
|
|
});
|
|
}
|
|
|
|
// 3. Prepare Sandboxed Command
|
|
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> {
|
|
try {
|
|
const isWindows = os.platform() === 'win32';
|
|
|
|
const {
|
|
program: finalExecutable,
|
|
args: finalArgs,
|
|
env: finalEnv,
|
|
cwd: finalCwd,
|
|
cleanup: cmdCleanup,
|
|
} = await this.prepareExecution(
|
|
commandToExecute,
|
|
cwd,
|
|
shellExecutionConfig,
|
|
isInteractive,
|
|
);
|
|
|
|
const child = cpSpawn(finalExecutable, finalArgs, {
|
|
cwd: finalCwd,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
windowsVerbatimArguments: isWindows ? false : undefined,
|
|
shell: false,
|
|
detached: !isWindows,
|
|
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) {
|
|
const encoding = getCachedEncodingForBuffer(data);
|
|
try {
|
|
stdoutDecoder = new TextDecoder(encoding);
|
|
stderrDecoder = new TextDecoder(encoding);
|
|
} catch {
|
|
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.slice(0, 20));
|
|
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[signal] : 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);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
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('exit', (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 rawEvent: ShellOutputEvent = {
|
|
type: 'raw_data',
|
|
chunk: remaining,
|
|
};
|
|
onOutputEvent(rawEvent);
|
|
if (child.pid) {
|
|
ExecutionLifecycleService.emitEvent(child.pid, rawEvent);
|
|
}
|
|
|
|
const event: ShellOutputEvent = {
|
|
type: 'data',
|
|
chunk: remaining,
|
|
};
|
|
onOutputEvent(event);
|
|
if (child.pid) {
|
|
ExecutionLifecycleService.emitEvent(child.pid, event);
|
|
}
|
|
|
|
const fileEvent: ShellOutputEvent = {
|
|
type: 'file_data',
|
|
chunk: stripAnsi(remaining),
|
|
};
|
|
onOutputEvent(fileEvent);
|
|
if (child.pid) {
|
|
ExecutionLifecycleService.emitEvent(child.pid, fileEvent);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (stderrDecoder) {
|
|
const remaining = stderrDecoder.decode();
|
|
if (remaining) {
|
|
state.output += remaining;
|
|
if (isStreamingRawContent) {
|
|
const rawEvent: ShellOutputEvent = {
|
|
type: 'raw_data',
|
|
chunk: remaining,
|
|
};
|
|
onOutputEvent(rawEvent);
|
|
if (child.pid) {
|
|
ExecutionLifecycleService.emitEvent(child.pid, rawEvent);
|
|
}
|
|
|
|
const event: ShellOutputEvent = {
|
|
type: 'data',
|
|
chunk: remaining,
|
|
};
|
|
onOutputEvent(event);
|
|
if (child.pid) {
|
|
ExecutionLifecycleService.emitEvent(child.pid, event);
|
|
}
|
|
|
|
const fileEvent: ShellOutputEvent = {
|
|
type: 'file_data',
|
|
chunk: stripAnsi(remaining),
|
|
};
|
|
onOutputEvent(fileEvent);
|
|
if (child.pid) {
|
|
ExecutionLifecycleService.emitEvent(child.pid, fileEvent);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
return { pid: child.pid, result };
|
|
} catch (e) {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
const error = e as Error;
|
|
return {
|
|
pid: undefined,
|
|
result: Promise.resolve({
|
|
error,
|
|
rawOutput: Buffer.from(''),
|
|
output: '',
|
|
exitCode: 1,
|
|
signal: null,
|
|
aborted: false,
|
|
pid: undefined,
|
|
executionMethod: 'none',
|
|
}),
|
|
};
|
|
}
|
|
}
|
|
|
|
private static async executeWithPty(
|
|
commandToExecute: string,
|
|
cwd: string,
|
|
onOutputEvent: (event: ShellOutputEvent) => void,
|
|
abortSignal: AbortSignal,
|
|
shellExecutionConfig: ShellExecutionConfig,
|
|
ptyInfo: PtyImplementation,
|
|
): Promise<ShellExecutionHandle> {
|
|
if (!ptyInfo) {
|
|
// This should not happen, but as a safeguard...
|
|
throw new Error('PTY implementation not found');
|
|
}
|
|
let spawnedPty: IPty | undefined;
|
|
|
|
try {
|
|
const cols = shellExecutionConfig.terminalWidth ?? 80;
|
|
const rows = shellExecutionConfig.terminalHeight ?? 30;
|
|
|
|
const {
|
|
program: finalExecutable,
|
|
args: finalArgs,
|
|
env: finalEnv,
|
|
cwd: finalCwd,
|
|
cleanup: cmdCleanup,
|
|
} = await this.prepareExecution(
|
|
commandToExecute,
|
|
cwd,
|
|
shellExecutionConfig,
|
|
true,
|
|
);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
const ptyProcess = ptyInfo.module.spawn(finalExecutable, finalArgs, {
|
|
cwd: finalCwd,
|
|
name: 'xterm-256color',
|
|
cols,
|
|
rows,
|
|
env: finalEnv,
|
|
handleFlowControl: true,
|
|
});
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
spawnedPty = ptyProcess as IPty;
|
|
const ptyPid = Number(ptyProcess.pid);
|
|
|
|
const headlessTerminal = new Terminal({
|
|
allowProposedApi: true,
|
|
cols,
|
|
rows,
|
|
scrollback: shellExecutionConfig.scrollback ?? SCROLLBACK_LIMIT,
|
|
});
|
|
headlessTerminal.scrollToTop();
|
|
|
|
this.activePtys.set(ptyPid, {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
ptyProcess,
|
|
headlessTerminal,
|
|
maxSerializedLines: shellExecutionConfig.maxSerializedLines,
|
|
command: shellExecutionConfig.originalCommand ?? commandToExecute,
|
|
sessionId: shellExecutionConfig.sessionId,
|
|
lastCommittedLine: -1,
|
|
});
|
|
|
|
const result = ExecutionLifecycleService.attachExecution(ptyPid, {
|
|
executionMethod: ptyInfo?.name ?? 'node-pty',
|
|
writeInput: (input) => {
|
|
if (!ExecutionLifecycleService.isActive(ptyPid)) {
|
|
return;
|
|
}
|
|
ptyProcess.write(input);
|
|
},
|
|
kill: () => {
|
|
killProcessGroup({
|
|
pid: ptyPid,
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
pty: ptyProcess,
|
|
}).catch(() => {});
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
(ptyProcess as IPty & { destroy?: () => void }).destroy?.();
|
|
} catch {
|
|
// Ignore errors during cleanup
|
|
}
|
|
this.activePtys.delete(ptyPid);
|
|
},
|
|
isActive: () => {
|
|
try {
|
|
return process.kill(ptyPid, 0);
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
getBackgroundOutput: () => getFullBufferText(headlessTerminal),
|
|
getSubscriptionSnapshot: () => {
|
|
const endLine = headlessTerminal.buffer.active.length;
|
|
const startLine = Math.max(
|
|
0,
|
|
endLine - (shellExecutionConfig.maxSerializedLines ?? 2000),
|
|
);
|
|
const bufferData = serializeTerminalToObject(
|
|
headlessTerminal,
|
|
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(headlessTerminal);
|
|
if (bufferText.trim().length === 0) {
|
|
return;
|
|
}
|
|
hasStartedOutput = true;
|
|
}
|
|
}
|
|
|
|
const buffer = headlessTerminal.buffer.active;
|
|
const endLine = buffer.length;
|
|
const startLine = Math.max(
|
|
0,
|
|
endLine - (shellExecutionConfig.maxSerializedLines ?? 2000),
|
|
);
|
|
|
|
let newOutput: AnsiOutput;
|
|
if (shellExecutionConfig.showColor) {
|
|
newOutput = serializeTerminalToObject(
|
|
headlessTerminal,
|
|
startLine,
|
|
endLine,
|
|
);
|
|
} else {
|
|
newOutput = (
|
|
serializeTerminalToObject(headlessTerminal, 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);
|
|
};
|
|
|
|
let lastYdisp = 0;
|
|
let hasReachedMax = false;
|
|
const scrollbackLimit = shellExecutionConfig.scrollback ?? SCROLLBACK_LIMIT;
|
|
|
|
headlessTerminal.onScroll((ydisp) => {
|
|
if (!isWriting) {
|
|
render();
|
|
}
|
|
|
|
if (
|
|
ydisp === scrollbackLimit &&
|
|
lastYdisp === scrollbackLimit &&
|
|
hasReachedMax
|
|
) {
|
|
const activePty = this.activePtys.get(ptyPid);
|
|
if (activePty) {
|
|
activePty.lastCommittedLine--;
|
|
}
|
|
}
|
|
if (
|
|
ydisp === scrollbackLimit &&
|
|
headlessTerminal.buffer.active.length === scrollbackLimit + rows
|
|
) {
|
|
hasReachedMax = true;
|
|
}
|
|
lastYdisp = ydisp;
|
|
|
|
const activePtyForEmit = this.activePtys.get(ptyPid);
|
|
if (activePtyForEmit) {
|
|
emitPendingLines(activePtyForEmit, ptyPid, onOutputEvent);
|
|
}
|
|
});
|
|
|
|
const handleOutput = (data: Buffer) => {
|
|
processingChain = processingChain.then(
|
|
() =>
|
|
new Promise<void>((resolveChunk) => {
|
|
if (!decoder) {
|
|
const encoding = getCachedEncodingForBuffer(data);
|
|
try {
|
|
decoder = new TextDecoder(encoding);
|
|
} catch {
|
|
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.slice(0, 20));
|
|
sniffedBytes = sniffBuffer.length;
|
|
|
|
if (isBinary(sniffBuffer)) {
|
|
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;
|
|
headlessTerminal.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();
|
|
}
|
|
}),
|
|
);
|
|
};
|
|
|
|
ptyProcess.onData((data: string) => {
|
|
const bufferData = Buffer.from(data, 'utf-8');
|
|
handleOutput(bufferData);
|
|
});
|
|
|
|
ptyProcess.onExit(
|
|
({ exitCode, signal }: { exitCode: number; signal?: number }) => {
|
|
exited = true;
|
|
abortSignal.removeEventListener('abort', abortHandler);
|
|
// Attempt to destroy the PTY to ensure FD is closed
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
(ptyProcess as IPty & { destroy?: () => void }).destroy?.();
|
|
} catch {
|
|
// Ignore errors during cleanup
|
|
}
|
|
|
|
const finalize = () => {
|
|
render(true);
|
|
cmdCleanup?.();
|
|
|
|
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);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
ShellExecutionService.cleanupLogStream(ptyPid).then(() => {
|
|
ShellExecutionService.activePtys.delete(ptyPid);
|
|
});
|
|
|
|
const endLine = headlessTerminal.buffer.active.length;
|
|
const startLine = Math.max(
|
|
0,
|
|
endLine - (shellExecutionConfig.maxSerializedLines ?? 2000),
|
|
);
|
|
const ansiOutputSnapshot = serializeTerminalToObject(
|
|
headlessTerminal,
|
|
startLine,
|
|
endLine,
|
|
);
|
|
|
|
ExecutionLifecycleService.completeWithResult(ptyPid, {
|
|
rawOutput: Buffer.from(''),
|
|
output: getFullBufferText(headlessTerminal),
|
|
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,
|
|
});
|
|
});
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
Promise.race([processingComplete, abortFired]).then(() => {
|
|
finalize();
|
|
});
|
|
},
|
|
);
|
|
|
|
const abortHandler = async () => {
|
|
if (ptyProcess.pid && !exited) {
|
|
await killProcessGroup({
|
|
pid: ptyPid,
|
|
escalate: true,
|
|
isExited: () => exited,
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
pty: ptyProcess,
|
|
});
|
|
}
|
|
};
|
|
|
|
abortSignal.addEventListener('abort', abortHandler, { once: true });
|
|
|
|
return { pid: ptyPid, result };
|
|
} catch (e) {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
const error = e as Error;
|
|
|
|
if (spawnedPty) {
|
|
try {
|
|
(spawnedPty as IPty & { destroy?: () => void }).destroy?.();
|
|
} catch {
|
|
// Ignore errors during cleanup
|
|
}
|
|
}
|
|
|
|
if (error.message.includes('posix_spawnp failed')) {
|
|
onOutputEvent({
|
|
type: 'data',
|
|
chunk:
|
|
'[GEMINI_CLI_WARNING] PTY execution failed, falling back to child_process. This may be due to 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',
|
|
}),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Writes a string to the pseudo-terminal (PTY) of a running process.
|
|
*
|
|
* @param pid The process ID of the target PTY.
|
|
* @param input The string to write to the terminal.
|
|
*/
|
|
static writeToPty(pid: number, input: string): void {
|
|
ExecutionLifecycleService.writeInput(pid, input);
|
|
}
|
|
|
|
static isPtyActive(pid: number): boolean {
|
|
return ExecutionLifecycleService.isActive(pid);
|
|
}
|
|
|
|
/**
|
|
* Registers a callback to be invoked when the process with the given PID exits.
|
|
* This attaches directly to the PTY's exit event.
|
|
*
|
|
* @param pid The process ID to watch.
|
|
* @param callback The function to call on exit.
|
|
* @returns An unsubscribe function.
|
|
*/
|
|
static onExit(
|
|
pid: number,
|
|
callback: (exitCode: number, signal?: number) => void,
|
|
): () => void {
|
|
return ExecutionLifecycleService.onExit(pid, callback);
|
|
}
|
|
|
|
/**
|
|
* Kills a process by its PID.
|
|
*
|
|
* @param pid The process ID to kill.
|
|
*/
|
|
static async kill(pid: number): Promise<void> {
|
|
await this.cleanupLogStream(pid);
|
|
this.activePtys.delete(pid);
|
|
this.activeChildProcesses.delete(pid);
|
|
ExecutionLifecycleService.kill(pid);
|
|
}
|
|
|
|
/**
|
|
* Moves a running shell command to the background.
|
|
* This resolves the execution promise but keeps the PTY active.
|
|
*
|
|
* @param pid The process ID of the target PTY.
|
|
*/
|
|
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);
|
|
|
|
// Set up background logging
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Resizes the pseudo-terminal (PTY) of a running process.
|
|
*
|
|
* @param pid The process ID of the target PTY.
|
|
* @param cols The new number of columns.
|
|
* @param rows The new number of rows.
|
|
*/
|
|
static resizePty(pid: number, cols: number, rows: number): void {
|
|
if (!this.isPtyActive(pid)) {
|
|
return;
|
|
}
|
|
|
|
const activePty = this.activePtys.get(pid);
|
|
if (activePty) {
|
|
try {
|
|
activePty.ptyProcess.resize(cols, rows);
|
|
activePty.headlessTerminal.resize(cols, rows);
|
|
} catch (e) {
|
|
// Ignore errors if the pty has already exited, which can happen
|
|
// due to a race condition between the exit event and this call.
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
const err = e as { code?: string; message?: string };
|
|
const isEsrch = err.code === 'ESRCH';
|
|
const isWindowsPtyError = err.message?.includes(
|
|
'Cannot resize a pty that has already exited',
|
|
);
|
|
|
|
if (isEsrch || isWindowsPtyError) {
|
|
// On Unix, we get an ESRCH error.
|
|
// On Windows, we get a message-based error.
|
|
// In both cases, it's safe to ignore.
|
|
} else {
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Force emit the new state after resize
|
|
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,
|
|
);
|
|
activePty.lastSerializedOutput = bufferData;
|
|
const event: ShellOutputEvent = { type: 'data', chunk: bufferData };
|
|
ExecutionLifecycleService.emitEvent(pid, event);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scrolls the pseudo-terminal (PTY) of a running process.
|
|
*
|
|
* @param pid The process ID of the target PTY.
|
|
* @param lines The number of lines to scroll.
|
|
*/
|
|
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) {
|
|
// Ignore errors if the pty has already exited, which can happen
|
|
// due to a race condition between the exit event and this call.
|
|
if (e instanceof Error && 'code' in e && e.code === 'ESRCH') {
|
|
// ignore
|
|
} 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,
|
|
}));
|
|
}
|
|
}
|