mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-22 11:04:42 -07:00
Persistent shell support
This commit is contained in:
@@ -542,6 +542,7 @@ export interface ConfigParameters {
|
||||
useAlternateBuffer?: boolean;
|
||||
useRipgrep?: boolean;
|
||||
enableInteractiveShell?: boolean;
|
||||
enablePersistentShell?: boolean;
|
||||
skipNextSpeakerCheck?: boolean;
|
||||
shellExecutionConfig?: ShellExecutionConfig;
|
||||
extensionManagement?: boolean;
|
||||
@@ -726,6 +727,7 @@ export class Config implements McpContext {
|
||||
private readonly directWebFetch: boolean;
|
||||
private readonly useRipgrep: boolean;
|
||||
private readonly enableInteractiveShell: boolean;
|
||||
private readonly enablePersistentShell: boolean;
|
||||
private readonly skipNextSpeakerCheck: boolean;
|
||||
private readonly useBackgroundColor: boolean;
|
||||
private readonly useAlternateBuffer: boolean;
|
||||
@@ -936,6 +938,7 @@ export class Config implements McpContext {
|
||||
this.useBackgroundColor = params.useBackgroundColor ?? true;
|
||||
this.useAlternateBuffer = params.useAlternateBuffer ?? false;
|
||||
this.enableInteractiveShell = params.enableInteractiveShell ?? false;
|
||||
this.enablePersistentShell = params.enablePersistentShell ?? true;
|
||||
this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true;
|
||||
this.shellExecutionConfig = {
|
||||
terminalWidth: params.shellExecutionConfig?.terminalWidth ?? 80,
|
||||
@@ -2613,6 +2616,10 @@ export class Config implements McpContext {
|
||||
return this.enableInteractiveShell;
|
||||
}
|
||||
|
||||
getEnablePersistentShell(): boolean {
|
||||
return this.enablePersistentShell;
|
||||
}
|
||||
|
||||
getSkipNextSpeakerCheck(): boolean {
|
||||
return this.skipNextSpeakerCheck;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { PersistentShellSession } from './persistentShellSession.js';
|
||||
import { getPty } from '../utils/getPty.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
vi.mock('../utils/getPty.js');
|
||||
vi.mock('node:child_process');
|
||||
vi.mock('../utils/shell-utils.js', async () => {
|
||||
const actual = await vi.importActual('../utils/shell-utils.js');
|
||||
return {
|
||||
...actual,
|
||||
getShellConfiguration: vi.fn().mockReturnValue({
|
||||
executable: 'bash',
|
||||
argsPrefix: ['-c'],
|
||||
shell: 'bash',
|
||||
}),
|
||||
resolveExecutable: vi.fn().mockResolvedValue('/bin/bash'),
|
||||
};
|
||||
});
|
||||
|
||||
describe('PersistentShellSession', () => {
|
||||
let mockPtyProcess: {
|
||||
pid: number;
|
||||
write: Mock;
|
||||
onData: Mock;
|
||||
onExit: Mock;
|
||||
kill: Mock;
|
||||
emit: (event: string, ...args: unknown[]) => boolean;
|
||||
on: (event: string, cb: (...args: unknown[]) => void) => unknown;
|
||||
removeListener: (
|
||||
event: string,
|
||||
cb: (...args: unknown[]) => void,
|
||||
) => unknown;
|
||||
};
|
||||
let onOutputEventMock: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
onOutputEventMock = vi.fn();
|
||||
|
||||
// @ts-expect-error - EventEmitter is used as a base but we add more properties
|
||||
mockPtyProcess = new EventEmitter();
|
||||
mockPtyProcess.pid = 123;
|
||||
mockPtyProcess.write = vi.fn();
|
||||
mockPtyProcess.onData = vi.fn((cb) => {
|
||||
mockPtyProcess.on('data', cb);
|
||||
return { dispose: () => mockPtyProcess.removeListener('data', cb) };
|
||||
});
|
||||
mockPtyProcess.onExit = vi.fn((cb) => {
|
||||
mockPtyProcess.on('exit', cb);
|
||||
return { dispose: () => mockPtyProcess.removeListener('exit', cb) };
|
||||
});
|
||||
mockPtyProcess.kill = vi.fn();
|
||||
|
||||
(getPty as Mock).mockResolvedValue({
|
||||
module: {
|
||||
spawn: vi.fn().mockReturnValue(mockPtyProcess),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize and run a command', async () => {
|
||||
const session = new PersistentShellSession({
|
||||
sanitizationConfig: {
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: [],
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
},
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
// Start execution
|
||||
const executePromise = session.execute(
|
||||
'ls',
|
||||
process.cwd(),
|
||||
onOutputEventMock,
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
// 1. Wait for bootstrap write
|
||||
await vi.waitFor(() => {
|
||||
if (mockPtyProcess.write.mock.calls.length === 0)
|
||||
throw new Error('No write yet');
|
||||
});
|
||||
const bootstrapCall = mockPtyProcess.write.mock.calls[0][0];
|
||||
expect(bootstrapCall).toContain('echo INIT_');
|
||||
const initMarker = bootstrapCall.match(/INIT_[a-z0-9]+/)[0];
|
||||
|
||||
// 2. Resolve bootstrap
|
||||
mockPtyProcess.emit('data', `${initMarker}\n`);
|
||||
|
||||
// 3. Wait for command write
|
||||
await vi.waitFor(() => {
|
||||
if (mockPtyProcess.write.mock.calls.length < 2)
|
||||
throw new Error('No command write yet');
|
||||
});
|
||||
const commandCall = mockPtyProcess.write.mock.calls[1][0];
|
||||
expect(commandCall).toContain('ls');
|
||||
expect(commandCall).toContain('echo "___GEMINI""_EXIT_CODE_');
|
||||
|
||||
const startMarkerMatch = commandCall.match(
|
||||
/"___GEMINI""(_START_MARKER_[a-z0-9]+___)"/,
|
||||
);
|
||||
const startMarker = startMarkerMatch
|
||||
? `___GEMINI${startMarkerMatch[1]}`
|
||||
: '___GEMINI_START_MARKER___';
|
||||
|
||||
// 4. Send command output and exit marker
|
||||
mockPtyProcess.emit(
|
||||
'data',
|
||||
`${startMarker}\nfile1.txt\n___GEMINI_EXIT_CODE_0___\n`,
|
||||
);
|
||||
|
||||
const result = await executePromise;
|
||||
expect(result.output).toBe('file1.txt');
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('should persist state between commands', async () => {
|
||||
const session = new PersistentShellSession({
|
||||
sanitizationConfig: {
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: [],
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
},
|
||||
});
|
||||
|
||||
const p1 = session.execute(
|
||||
'export FOO=bar',
|
||||
process.cwd(),
|
||||
vi.fn(),
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
// Bootstrap
|
||||
await vi.waitFor(() => expect(mockPtyProcess.write).toHaveBeenCalled());
|
||||
const initMarker =
|
||||
mockPtyProcess.write.mock.calls[0][0].match(/INIT_[a-z0-9]+/)[0];
|
||||
mockPtyProcess.emit('data', `${initMarker}\n`);
|
||||
|
||||
// Cmd 1
|
||||
await vi.waitFor(() =>
|
||||
expect(mockPtyProcess.write).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
let commandCall = mockPtyProcess.write.mock.calls[1][0];
|
||||
let startMarkerMatch = commandCall.match(
|
||||
/"___GEMINI""(_START_MARKER_[a-z0-9]+___)"/,
|
||||
);
|
||||
let startMarker = startMarkerMatch
|
||||
? `___GEMINI${startMarkerMatch[1]}`
|
||||
: '___GEMINI_START_MARKER___';
|
||||
mockPtyProcess.emit('data', `${startMarker}\n___GEMINI_EXIT_CODE_0___\n`);
|
||||
await p1;
|
||||
|
||||
// Cmd 2
|
||||
const p2 = session.execute(
|
||||
'echo $FOO',
|
||||
process.cwd(),
|
||||
onOutputEventMock,
|
||||
new AbortController().signal,
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(mockPtyProcess.write).toHaveBeenCalledTimes(3),
|
||||
);
|
||||
commandCall = mockPtyProcess.write.mock.calls[2][0];
|
||||
startMarkerMatch = commandCall.match(
|
||||
/"___GEMINI""(_START_MARKER_[a-z0-9]+___)"/,
|
||||
);
|
||||
startMarker = startMarkerMatch
|
||||
? `___GEMINI${startMarkerMatch[1]}`
|
||||
: '___GEMINI_START_MARKER___';
|
||||
mockPtyProcess.emit(
|
||||
'data',
|
||||
`${startMarker}\nbar\n___GEMINI_EXIT_CODE_0___\n`,
|
||||
);
|
||||
|
||||
const result = await p2;
|
||||
expect(result.output).toBe('bar');
|
||||
});
|
||||
|
||||
it('should handle abort and successfully run the next command', async () => {
|
||||
const session = new PersistentShellSession({
|
||||
sanitizationConfig: {
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: [],
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
},
|
||||
});
|
||||
|
||||
const abortController1 = new AbortController();
|
||||
const p1 = session.execute(
|
||||
'sleep 10',
|
||||
process.cwd(),
|
||||
vi.fn(),
|
||||
abortController1.signal,
|
||||
);
|
||||
|
||||
// Bootstrap first PTY
|
||||
await vi.waitFor(() => expect(mockPtyProcess.write).toHaveBeenCalled());
|
||||
const initMarker1 =
|
||||
mockPtyProcess.write.mock.calls[0][0].match(/INIT_[a-z0-9]+/)[0];
|
||||
mockPtyProcess.emit('data', `${initMarker1}\n`);
|
||||
|
||||
// Command 1 write
|
||||
await vi.waitFor(() =>
|
||||
expect(mockPtyProcess.write).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
|
||||
// Now abort!
|
||||
abortController1.abort();
|
||||
|
||||
// The abortHandler will wait 1000ms, then call kill() and resolve.
|
||||
// We need to wait for this to happen.
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
// Check if kill was called
|
||||
if (mockPtyProcess.kill.mock.calls.length === 0)
|
||||
throw new Error('Not killed yet');
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
|
||||
// Resolve the promise from execution
|
||||
const res1 = await p1;
|
||||
expect(res1.aborted).toBe(true);
|
||||
|
||||
// Now execute command 2
|
||||
const abortController2 = new AbortController();
|
||||
const onOutputMock2 = vi.fn();
|
||||
const p2 = session.execute(
|
||||
'ls -l',
|
||||
process.cwd(),
|
||||
onOutputMock2,
|
||||
abortController2.signal,
|
||||
);
|
||||
|
||||
// Bootstrap second PTY (triggered by ensuring initialization after kill)
|
||||
await vi.waitFor(() =>
|
||||
expect(mockPtyProcess.write).toHaveBeenCalledTimes(4),
|
||||
);
|
||||
const initMarker2 =
|
||||
mockPtyProcess.write.mock.calls[3][0].match(/INIT_[a-z0-9]+/)[0];
|
||||
mockPtyProcess.emit('data', `${initMarker2}\n`);
|
||||
|
||||
// Command 2 write
|
||||
await vi.waitFor(() =>
|
||||
expect(mockPtyProcess.write).toHaveBeenCalledTimes(5),
|
||||
);
|
||||
|
||||
const commandCall2 = mockPtyProcess.write.mock.calls[4][0];
|
||||
const startMarkerMatch2 = commandCall2.match(
|
||||
/"___GEMINI""(_START_MARKER_[a-z0-9]+___)"/,
|
||||
);
|
||||
const startMarker2 = startMarkerMatch2
|
||||
? `___GEMINI${startMarkerMatch2[1]}`
|
||||
: '___GEMINI_START_MARKER___';
|
||||
|
||||
mockPtyProcess.emit(
|
||||
'data',
|
||||
`${startMarker2}\noutput of ls\n___GEMINI_EXIT_CODE_0___\n`,
|
||||
);
|
||||
|
||||
const res2 = await p2;
|
||||
expect(res2.output).toBe('output of ls');
|
||||
expect(res2.exitCode).toBe(0);
|
||||
expect(onOutputMock2).toHaveBeenCalledWith('output of ls\n');
|
||||
});
|
||||
|
||||
it('should reject queued commands if the shell is killed during abort', async () => {
|
||||
const session = new PersistentShellSession({
|
||||
sanitizationConfig: {
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: [],
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
},
|
||||
});
|
||||
|
||||
const abortController1 = new AbortController();
|
||||
const p1 = session.execute(
|
||||
'sleep 10',
|
||||
process.cwd(),
|
||||
vi.fn(),
|
||||
abortController1.signal,
|
||||
);
|
||||
|
||||
// Bootstrap
|
||||
await vi.waitFor(() => expect(mockPtyProcess.write).toHaveBeenCalled());
|
||||
const initMarker1 =
|
||||
mockPtyProcess.write.mock.calls[0][0].match(/INIT_[a-z0-9]+/)[0];
|
||||
mockPtyProcess.emit('data', `${initMarker1}\n`);
|
||||
|
||||
// Command 1 write
|
||||
await vi.waitFor(() =>
|
||||
expect(mockPtyProcess.write).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
|
||||
// Now abort!
|
||||
abortController1.abort();
|
||||
|
||||
// While it is aborting (waiting for the 1000ms timeout), queue another command
|
||||
const p2 = session.execute(
|
||||
'ls',
|
||||
process.cwd(),
|
||||
vi.fn(),
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
// Fast-forward timeout
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
|
||||
// p1 should be aborted
|
||||
const res1 = await p1;
|
||||
expect(res1.aborted).toBe(true);
|
||||
|
||||
// p2 should be REJECTED because kill() clears the queue
|
||||
await expect(p2).rejects.toThrow(
|
||||
'Persistent shell process was terminated.',
|
||||
);
|
||||
|
||||
// Clean up p1 promise to avoid unhandled rejection if it were to reject (though it resolves in this test)
|
||||
await p1;
|
||||
});
|
||||
|
||||
it('should reset sentOutputLength between commands even after abort', async () => {
|
||||
const session = new PersistentShellSession({
|
||||
sanitizationConfig: {
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: [],
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
},
|
||||
});
|
||||
|
||||
const onOutputMock1 = vi.fn();
|
||||
const abortController1 = new AbortController();
|
||||
const p1 = session.execute(
|
||||
'ls',
|
||||
process.cwd(),
|
||||
onOutputMock1,
|
||||
abortController1.signal,
|
||||
);
|
||||
|
||||
// Bootstrap
|
||||
await vi.waitFor(() => expect(mockPtyProcess.write).toHaveBeenCalled());
|
||||
const initMarker1 =
|
||||
mockPtyProcess.write.mock.calls[0][0].match(/INIT_[a-z0-9]+/)[0];
|
||||
mockPtyProcess.emit('data', `${initMarker1}\n`);
|
||||
|
||||
// Cmd 1
|
||||
await vi.waitFor(() =>
|
||||
expect(mockPtyProcess.write).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
const commandCall1 = mockPtyProcess.write.mock.calls[1][0];
|
||||
const startMarker1 = commandCall1
|
||||
.match(/"___GEMINI""(_START_MARKER_[a-z0-9]+___)"/)[0]
|
||||
.replace(/"/g, '')
|
||||
.replace(/___GEMINI/, '___GEMINI');
|
||||
|
||||
// Send some output
|
||||
mockPtyProcess.emit(
|
||||
'data',
|
||||
`${startMarker1}\nLong output that is more than 10 characters\n`,
|
||||
);
|
||||
expect(onOutputMock1).toHaveBeenCalled();
|
||||
|
||||
// Now ABORT
|
||||
abortController1.abort();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100)); // Wait for kill()
|
||||
await p1;
|
||||
|
||||
// Now run command 2
|
||||
const onOutputMock2 = vi.fn();
|
||||
const p2 = session.execute(
|
||||
'ls',
|
||||
process.cwd(),
|
||||
onOutputMock2,
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
// Bootstrap (new PTY because kill() was called)
|
||||
await vi.waitFor(() =>
|
||||
expect(mockPtyProcess.write).toHaveBeenCalledTimes(4),
|
||||
); // SIGINT + Bootstrap 2
|
||||
const initMarker2 =
|
||||
mockPtyProcess.write.mock.calls[3][0].match(/INIT_[a-z0-9]+/)[0];
|
||||
mockPtyProcess.emit('data', `${initMarker2}\n`);
|
||||
|
||||
// Cmd 2
|
||||
await vi.waitFor(() =>
|
||||
expect(mockPtyProcess.write).toHaveBeenCalledTimes(5),
|
||||
);
|
||||
const commandCall2 = mockPtyProcess.write.mock.calls[4][0];
|
||||
const startMarker2 = commandCall2
|
||||
.match(/"___GEMINI""(_START_MARKER_[a-z0-9]+___)"/)[0]
|
||||
.replace(/"/g, '')
|
||||
.replace(/___GEMINI/, '___GEMINI');
|
||||
|
||||
// Send SHORT output
|
||||
mockPtyProcess.emit(
|
||||
'data',
|
||||
`${startMarker2}\nShort\n___GEMINI_EXIT_CODE_0___\n`,
|
||||
);
|
||||
const res2 = await p2;
|
||||
expect(res2.output).toBe('Short');
|
||||
|
||||
// IF sentOutputLength was NOT reset, onOutputMock2 would NOT have been called!
|
||||
expect(onOutputMock2).toHaveBeenCalledWith('Short\n');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,482 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { getPty } from '../utils/getPty.js';
|
||||
import { spawn as cpSpawn, type ChildProcess } from 'node:child_process';
|
||||
import { TextDecoder } from 'node:util';
|
||||
import os from 'node:os';
|
||||
import type { IPty } from '@lydell/node-pty';
|
||||
import {
|
||||
getShellConfiguration,
|
||||
resolveExecutable,
|
||||
type ShellType,
|
||||
} from '../utils/shell-utils.js';
|
||||
import {
|
||||
sanitizeEnvironment,
|
||||
type EnvironmentSanitizationConfig,
|
||||
} from './environmentSanitization.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
export interface PersistentShellConfig {
|
||||
sanitizationConfig: EnvironmentSanitizationConfig;
|
||||
}
|
||||
|
||||
export interface PersistentShellResult {
|
||||
output: string;
|
||||
exitCode: number | null;
|
||||
signal: number | null;
|
||||
aborted: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages a persistent shell session (PTY or child_process) that remains active
|
||||
* across multiple command executions, preserving environment variables,
|
||||
* aliases, and the current working directory.
|
||||
*/
|
||||
export class PersistentShellSession {
|
||||
private pty: IPty | null = null;
|
||||
private child: ChildProcess | null = null;
|
||||
private shellType: ShellType = 'bash';
|
||||
|
||||
private queue: Array<{
|
||||
command: string;
|
||||
cwd: string;
|
||||
onOutput: (data: string) => void;
|
||||
signal: AbortSignal;
|
||||
resolve: (res: PersistentShellResult) => void;
|
||||
reject: (err: Error) => void;
|
||||
}> = [];
|
||||
|
||||
private isProcessing = false;
|
||||
private currentOutput = '';
|
||||
private currentExitCode: number | null = null;
|
||||
private currentResolver: ((res: PersistentShellResult) => void) | null = null;
|
||||
private currentRejecter: ((err: Error) => void) | null = null;
|
||||
private currentOutputCallback: ((data: string) => void) | null = null;
|
||||
private sentOutputLength = 0;
|
||||
|
||||
private endMarkerPrefix = '___GEMINI_EXIT_CODE_';
|
||||
private endMarkerSuffix = '___';
|
||||
private startMarker = '';
|
||||
private hasSeenStartMarker = false;
|
||||
|
||||
constructor(private config: PersistentShellConfig) {}
|
||||
|
||||
get pid(): number | undefined {
|
||||
return this.pty?.pid || this.child?.pid;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
}
|
||||
|
||||
write(data: string): void {
|
||||
if (this.pty) {
|
||||
this.pty.write(data);
|
||||
} else if (this.child?.stdin) {
|
||||
this.child.stdin.write(data);
|
||||
}
|
||||
}
|
||||
|
||||
resize(cols: number, rows: number): void {
|
||||
if (this.pty) {
|
||||
this.pty.resize(cols, rows);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureInitialized(): Promise<void> {
|
||||
if (this.pty || this.child) {
|
||||
debugLogger.log('Reusing existing persistent shell session.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { executable, shell } = getShellConfiguration();
|
||||
this.shellType = shell;
|
||||
|
||||
// For persistent shells, we want interactive login shells on Unix
|
||||
const userShell = process.env['SHELL'] || executable;
|
||||
const isUnix = os.platform() !== 'win32';
|
||||
const args = isUnix ? ['-i', '-l'] : []; // login shell for aliases
|
||||
|
||||
const resolvedExecutable = await resolveExecutable(userShell);
|
||||
if (!resolvedExecutable) {
|
||||
throw new Error(`Shell executable "${userShell}" not found.`);
|
||||
}
|
||||
|
||||
// If the user's shell is zsh, don't treat it strictly as bash
|
||||
if (resolvedExecutable.endsWith('zsh')) {
|
||||
this.shellType = 'zsh';
|
||||
}
|
||||
|
||||
debugLogger.log(
|
||||
`Initializing PersistentShellSession with executable: ${resolvedExecutable} args: ${args.join(' ')}`,
|
||||
);
|
||||
|
||||
const env = {
|
||||
...sanitizeEnvironment(process.env, this.config.sanitizationConfig),
|
||||
GEMINI_CLI: '1',
|
||||
TERM: 'xterm-256color',
|
||||
PAGER: 'cat',
|
||||
GIT_PAGER: 'cat',
|
||||
};
|
||||
|
||||
const ptyInfo = await getPty();
|
||||
if (ptyInfo) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
this.pty = ptyInfo.module.spawn(resolvedExecutable, args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: process.cwd(),
|
||||
env,
|
||||
}) as IPty;
|
||||
|
||||
this.pty.onData((data) => this.handleRawOutput(data));
|
||||
const currentPty = this.pty;
|
||||
this.pty.onExit((e) => {
|
||||
debugLogger.log(
|
||||
`Persistent shell PTY exited with code ${e.exitCode} and signal ${e.signal}`,
|
||||
);
|
||||
if (this.pty === currentPty) {
|
||||
this.pty = null;
|
||||
this.handleProcessEnd();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Fallback to child_process
|
||||
this.child = cpSpawn(resolvedExecutable, args, {
|
||||
cwd: process.cwd(),
|
||||
env,
|
||||
shell: false,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
this.child.stdout?.on('data', (data: Buffer) =>
|
||||
this.handleRawOutput(decoder.decode(data)),
|
||||
);
|
||||
this.child.stderr?.on('data', (data: Buffer) =>
|
||||
this.handleRawOutput(decoder.decode(data)),
|
||||
);
|
||||
const currentChild = this.child;
|
||||
this.child.on('exit', (code, signal) => {
|
||||
debugLogger.log(
|
||||
`Persistent shell child process exited with code ${code} and signal ${signal}`,
|
||||
);
|
||||
if (this.child === currentChild) {
|
||||
this.child = null;
|
||||
this.handleProcessEnd();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initial silent bootstrap
|
||||
await this.bootstrapShell();
|
||||
}
|
||||
|
||||
private async bootstrapShell(): Promise<void> {
|
||||
debugLogger.log('Bootstrapping persistent shell...');
|
||||
// Send a sequence to clear initialization noise and ensure we are ready
|
||||
const marker = `INIT_${Math.random().toString(36).substring(2)}`;
|
||||
|
||||
let bootstrapCmd = '';
|
||||
if (this.shellType === 'bash') {
|
||||
// Explicitly source .bashrc as some systems don't do it automatically for login shells,
|
||||
// or aliases might be defined there instead of .bash_profile.
|
||||
// Also enable alias expansion which is often disabled in non-interactive modes.
|
||||
bootstrapCmd = `unset npm_config_prefix; [ -f ~/.bashrc ] && . ~/.bashrc; shopt -s expand_aliases; echo ${marker}\n`;
|
||||
} else if (this.shellType === 'powershell') {
|
||||
bootstrapCmd = `Write-Output ${marker}\n`;
|
||||
} else if (this.shellType === 'zsh') {
|
||||
// zsh usually has aliases expanded in interactive mode
|
||||
bootstrapCmd = `unset npm_config_prefix; [ -f ~/.zshrc ] && . ~/.zshrc; echo ${marker}\n`;
|
||||
} else {
|
||||
bootstrapCmd = `echo ${marker}\n`;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let buffer = '';
|
||||
const timeout = setTimeout(() => {
|
||||
debugLogger.error(
|
||||
'Persistent shell bootstrap timed out. Buffer content:',
|
||||
buffer,
|
||||
);
|
||||
resolve();
|
||||
}, 5000);
|
||||
|
||||
const onData = (data: string) => {
|
||||
buffer += data;
|
||||
if (buffer.includes(marker)) {
|
||||
debugLogger.log('Persistent shell bootstrap complete.');
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
if (this.pty) {
|
||||
const disposable = this.pty.onData(onData);
|
||||
this.pty.write(bootstrapCmd);
|
||||
const originalResolve = resolve;
|
||||
resolve = () => {
|
||||
disposable.dispose();
|
||||
originalResolve();
|
||||
};
|
||||
} else if (this.child) {
|
||||
const listener = (data: Buffer) =>
|
||||
onData(new TextDecoder().decode(data));
|
||||
this.child.stdout?.on('data', listener);
|
||||
this.child.stdin?.write(bootstrapCmd);
|
||||
const originalResolve = resolve;
|
||||
resolve = () => {
|
||||
this.child?.stdout?.removeListener('data', listener);
|
||||
originalResolve();
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handleRawOutput(data: string): void {
|
||||
if (!this.isProcessing) {
|
||||
debugLogger.log(
|
||||
`Persistent shell received output while NOT processing: ${JSON.stringify(data)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentOutput += data;
|
||||
|
||||
if (!this.hasSeenStartMarker) {
|
||||
const startIndex = this.currentOutput.indexOf(this.startMarker);
|
||||
if (startIndex !== -1) {
|
||||
let startMarkerEnd = startIndex + this.startMarker.length;
|
||||
if (this.currentOutput.startsWith('\r\n', startMarkerEnd)) {
|
||||
startMarkerEnd += 2;
|
||||
} else if (
|
||||
this.currentOutput.startsWith('\n', startMarkerEnd) ||
|
||||
this.currentOutput.startsWith('\r', startMarkerEnd)
|
||||
) {
|
||||
startMarkerEnd += 1;
|
||||
}
|
||||
|
||||
this.currentOutput = this.currentOutput.substring(startMarkerEnd);
|
||||
this.hasSeenStartMarker = true;
|
||||
} else {
|
||||
// Fallback if we see the end marker before the start marker
|
||||
const endIndex = this.currentOutput.indexOf(this.endMarkerPrefix);
|
||||
if (endIndex !== -1) {
|
||||
this.hasSeenStartMarker = true;
|
||||
} else {
|
||||
// Still waiting for start marker
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for end marker
|
||||
const endIndex = this.currentOutput.indexOf(this.endMarkerPrefix);
|
||||
if (endIndex !== -1) {
|
||||
const remaining = this.currentOutput.substring(
|
||||
endIndex + this.endMarkerPrefix.length,
|
||||
);
|
||||
const suffixIndex = remaining.indexOf(this.endMarkerSuffix);
|
||||
if (suffixIndex !== -1) {
|
||||
const exitCodeStr = remaining.substring(0, suffixIndex);
|
||||
this.currentExitCode = parseInt(exitCodeStr, 10);
|
||||
|
||||
// Strip marker from output
|
||||
const finalOutput = this.currentOutput.substring(0, endIndex);
|
||||
|
||||
// Stream the remaining valid part before the marker
|
||||
if (
|
||||
this.currentOutputCallback &&
|
||||
finalOutput.length > this.sentOutputLength
|
||||
) {
|
||||
const chunk = finalOutput.substring(this.sentOutputLength);
|
||||
if (chunk) {
|
||||
this.currentOutputCallback(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
this.currentOutput = ''; // Reset for next command
|
||||
this.sentOutputLength = 0;
|
||||
this.hasSeenStartMarker = false;
|
||||
|
||||
if (this.currentResolver) {
|
||||
this.currentResolver({
|
||||
output: finalOutput.trim(),
|
||||
exitCode: this.currentExitCode,
|
||||
signal: null,
|
||||
aborted: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
this.isProcessing = false;
|
||||
void this.processQueue();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.currentOutputCallback) {
|
||||
// Find the safe length to stream without including parts of the end marker
|
||||
let safeLength = this.currentOutput.length;
|
||||
for (let i = this.endMarkerPrefix.length; i >= 1; i--) {
|
||||
const prefixCheck = this.endMarkerPrefix.substring(0, i);
|
||||
if (this.currentOutput.endsWith(prefixCheck)) {
|
||||
safeLength = this.currentOutput.length - i;
|
||||
break; // Found the longest matching suffix that is a prefix of the marker
|
||||
}
|
||||
}
|
||||
|
||||
if (safeLength > this.sentOutputLength) {
|
||||
const chunk = this.currentOutput.substring(
|
||||
this.sentOutputLength,
|
||||
safeLength,
|
||||
);
|
||||
this.currentOutputCallback(chunk);
|
||||
this.sentOutputLength = safeLength;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleProcessEnd(): void {
|
||||
debugLogger.log(
|
||||
`Persistent shell process ended. isProcessing=${this.isProcessing}, queueLength=${this.queue.length}`,
|
||||
);
|
||||
if (this.isProcessing && this.currentRejecter) {
|
||||
debugLogger.log(
|
||||
`Persistent shell process exited unexpectedly while processing a command. Pending output: ${JSON.stringify(this.currentOutput)}`,
|
||||
);
|
||||
this.currentRejecter(
|
||||
new Error('Persistent shell process exited unexpectedly.'),
|
||||
);
|
||||
}
|
||||
this.isProcessing = false;
|
||||
this.hasSeenStartMarker = false;
|
||||
this.currentOutput = '';
|
||||
this.sentOutputLength = 0;
|
||||
|
||||
const pendingQueue = this.queue;
|
||||
this.queue = [];
|
||||
for (const item of pendingQueue) {
|
||||
item.reject(new Error('Persistent shell process was terminated.'));
|
||||
}
|
||||
}
|
||||
|
||||
async execute(
|
||||
command: string,
|
||||
cwd: string,
|
||||
onOutput: (data: string) => void,
|
||||
signal: AbortSignal,
|
||||
): Promise<PersistentShellResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.queue.push({ command, cwd, onOutput, signal, resolve, reject });
|
||||
if (!this.isProcessing) {
|
||||
void this.processQueue();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async processQueue(): Promise<void> {
|
||||
if (this.queue.length === 0 || this.isProcessing) return;
|
||||
|
||||
this.isProcessing = true;
|
||||
const item = this.queue.shift();
|
||||
if (!item) {
|
||||
this.isProcessing = false;
|
||||
return;
|
||||
}
|
||||
const { command, cwd, onOutput, signal, resolve, reject } = item;
|
||||
|
||||
try {
|
||||
await this.ensureInitialized();
|
||||
|
||||
this.currentResolver = resolve;
|
||||
this.currentRejecter = reject;
|
||||
this.currentOutputCallback = onOutput;
|
||||
this.currentOutput = '';
|
||||
this.sentOutputLength = 0;
|
||||
this.currentExitCode = null;
|
||||
this.startMarker = `___GEMINI_START_MARKER_${Math.random().toString(36).substring(2)}___`;
|
||||
this.hasSeenStartMarker = false;
|
||||
|
||||
// Construct wrapped command
|
||||
let wrappedCmd = '';
|
||||
const prefix1 = this.endMarkerPrefix.substring(0, 9);
|
||||
const prefix2 = this.endMarkerPrefix.substring(9);
|
||||
|
||||
const start1 = this.startMarker.substring(0, 9);
|
||||
const start2 = this.startMarker.substring(9);
|
||||
|
||||
if (this.shellType === 'powershell') {
|
||||
wrappedCmd = `Set-Location "${cwd}"; Write-Output ("${start1}" + "${start2}"); try { ${command} } finally { Write-Output ("${prefix1}" + "${prefix2}$LASTEXITCODE${this.endMarkerSuffix}") }\r\n`;
|
||||
} else if (this.shellType === 'cmd') {
|
||||
wrappedCmd = `call echo ${start1}^${start2} & pushd "${cwd}" && ${command} & set __code=%errorlevel% & popd & call echo ${prefix1}^${prefix2}%__code%${this.endMarkerSuffix}\r\n`;
|
||||
} else {
|
||||
// bash/zsh
|
||||
// Use stty sane and tput rmcup to restore terminal state if a previous command (like vim) left it in a bad state
|
||||
wrappedCmd = `stty sane 2>/dev/null; tput rmcup 2>/dev/null; tput sgr0 2>/dev/null; echo "${start1}""${start2}"; cd "${cwd}" && { ${command.trim().replace(/;$/, '')}; }; echo "${prefix1}""${prefix2}$?${this.endMarkerSuffix}"\n`;
|
||||
}
|
||||
|
||||
const abortHandler = () => {
|
||||
if (this.pty) {
|
||||
this.pty.write('\x03'); // Send SIGINT
|
||||
} else if (this.child) {
|
||||
this.child.kill('SIGINT');
|
||||
}
|
||||
// We don't resolve yet, wait for the prompt to return or a timeout
|
||||
setTimeout(() => {
|
||||
if (this.isProcessing) {
|
||||
this.isProcessing = false;
|
||||
this.kill();
|
||||
resolve({
|
||||
output: this.currentOutput,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
aborted: true,
|
||||
error: null,
|
||||
});
|
||||
this.hasSeenStartMarker = false;
|
||||
void this.processQueue();
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
signal.addEventListener('abort', abortHandler, { once: true });
|
||||
|
||||
debugLogger.log(
|
||||
`Executing persistent command in ${this.shellType}: ${wrappedCmd.trim()}`,
|
||||
);
|
||||
|
||||
if (this.pty) {
|
||||
this.pty.write(wrappedCmd);
|
||||
} else if (this.child) {
|
||||
this.child.stdin?.write(wrappedCmd);
|
||||
}
|
||||
} catch (err) {
|
||||
this.isProcessing = false;
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
void this.processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
kill(): void {
|
||||
if (this.pty) {
|
||||
try {
|
||||
(this.pty as IPty & { destroy?: () => void }).destroy?.();
|
||||
this.pty.kill();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.pty = null;
|
||||
}
|
||||
if (this.child) {
|
||||
this.child.kill();
|
||||
this.child = null;
|
||||
}
|
||||
this.handleProcessEnd();
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type EnvironmentSanitizationConfig,
|
||||
} from './environmentSanitization.js';
|
||||
import { killProcessGroup } from '../utils/process-utils.js';
|
||||
import { PersistentShellSession } from './persistentShellSession.js';
|
||||
const { Terminal } = pkg;
|
||||
|
||||
const MAX_CHILD_PROCESS_BUFFER_SIZE = 16 * 1024 * 1024; // 16MB
|
||||
@@ -106,6 +107,7 @@ export interface ShellExecutionConfig {
|
||||
disableDynamicLineTrimming?: boolean;
|
||||
scrollback?: number;
|
||||
maxSerializedLines?: number;
|
||||
persistent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,6 +198,7 @@ const getFullBufferText = (terminal: pkg.Terminal): string => {
|
||||
*/
|
||||
|
||||
export class ShellExecutionService {
|
||||
private static persistentSession: PersistentShellSession | null = null;
|
||||
private static activePtys = new Map<number, ActivePty>();
|
||||
private static activeChildProcesses = new Map<number, ActiveChildProcess>();
|
||||
private static exitedPtyInfo = new Map<
|
||||
@@ -210,6 +213,13 @@ export class ShellExecutionService {
|
||||
number,
|
||||
Set<(event: ShellOutputEvent) => void>
|
||||
>();
|
||||
static clearPersistentSession(): void {
|
||||
if (this.persistentSession) {
|
||||
this.persistentSession.kill();
|
||||
this.persistentSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a shell command using `node-pty`, capturing all output and lifecycle events.
|
||||
*
|
||||
@@ -228,6 +238,98 @@ export class ShellExecutionService {
|
||||
shouldUseNodePty: boolean,
|
||||
shellExecutionConfig: ShellExecutionConfig,
|
||||
): Promise<ShellExecutionHandle> {
|
||||
if (shellExecutionConfig.persistent) {
|
||||
if (!this.persistentSession) {
|
||||
this.persistentSession = new PersistentShellSession({
|
||||
sanitizationConfig: shellExecutionConfig.sanitizationConfig,
|
||||
});
|
||||
}
|
||||
|
||||
await this.persistentSession.init();
|
||||
const pid = this.persistentSession.pid;
|
||||
|
||||
const cols = shellExecutionConfig.terminalWidth ?? 80;
|
||||
const rows = shellExecutionConfig.terminalHeight ?? 30;
|
||||
|
||||
this.persistentSession.resize(cols, rows);
|
||||
|
||||
const headlessTerminal = new Terminal({
|
||||
allowProposedApi: true,
|
||||
cols,
|
||||
rows,
|
||||
scrollback: shellExecutionConfig.scrollback ?? SCROLLBACK_LIMIT,
|
||||
});
|
||||
headlessTerminal.scrollToTop();
|
||||
|
||||
let writePromise = Promise.resolve();
|
||||
let renderTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
const renderFn = () => {
|
||||
renderTimeout = null;
|
||||
const endLine = headlessTerminal.buffer.active.length;
|
||||
const startLine = Math.max(
|
||||
0,
|
||||
endLine - (shellExecutionConfig.maxSerializedLines ?? 2000),
|
||||
);
|
||||
const serializedData = serializeTerminalToObject(
|
||||
headlessTerminal,
|
||||
startLine,
|
||||
endLine,
|
||||
);
|
||||
const event: ShellOutputEvent = { type: 'data', chunk: serializedData };
|
||||
onOutputEvent(event);
|
||||
if (pid !== undefined) {
|
||||
ShellExecutionService.emitEvent(pid, event);
|
||||
}
|
||||
};
|
||||
|
||||
const render = (final?: boolean) => {
|
||||
if (final) {
|
||||
if (renderTimeout) clearTimeout(renderTimeout);
|
||||
renderFn();
|
||||
return;
|
||||
}
|
||||
if (!renderTimeout) {
|
||||
renderTimeout = setTimeout(renderFn, 100);
|
||||
}
|
||||
};
|
||||
|
||||
const result = this.persistentSession
|
||||
.execute(
|
||||
commandToExecute,
|
||||
cwd,
|
||||
(chunk) => {
|
||||
const bufferData = Buffer.from(chunk);
|
||||
writePromise = writePromise.then(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
headlessTerminal.write(bufferData, () => {
|
||||
render();
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
);
|
||||
},
|
||||
abortSignal,
|
||||
)
|
||||
.then(async (res) => {
|
||||
await writePromise;
|
||||
render(true);
|
||||
return {
|
||||
...res,
|
||||
rawOutput: Buffer.from(res.output),
|
||||
output: getFullBufferText(headlessTerminal),
|
||||
executionMethod: 'node-pty' as const,
|
||||
pid,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
pid,
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
if (shouldUseNodePty) {
|
||||
const ptyInfo = await getPty();
|
||||
if (ptyInfo) {
|
||||
@@ -481,6 +583,7 @@ export class ShellExecutionService {
|
||||
function cleanup() {
|
||||
exited = true;
|
||||
abortSignal.removeEventListener('abort', abortHandler);
|
||||
|
||||
if (stdoutDecoder) {
|
||||
const remaining = stdoutDecoder.decode();
|
||||
if (remaining) {
|
||||
@@ -801,41 +904,47 @@ export class ShellExecutionService {
|
||||
|
||||
const finalize = () => {
|
||||
render(true);
|
||||
finish();
|
||||
|
||||
// Store exit info for late subscribers (e.g. backgrounding race condition)
|
||||
this.exitedPtyInfo.set(ptyProcess.pid, { exitCode, signal });
|
||||
setTimeout(
|
||||
() => {
|
||||
this.exitedPtyInfo.delete(ptyProcess.pid);
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
).unref();
|
||||
function finish() {
|
||||
// Store exit info for late subscribers (e.g. backgrounding race condition)
|
||||
ShellExecutionService.exitedPtyInfo.set(ptyProcess.pid, {
|
||||
exitCode,
|
||||
signal,
|
||||
});
|
||||
setTimeout(
|
||||
() => {
|
||||
ShellExecutionService.exitedPtyInfo.delete(ptyProcess.pid);
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
).unref();
|
||||
|
||||
this.activePtys.delete(ptyProcess.pid);
|
||||
this.activeResolvers.delete(ptyProcess.pid);
|
||||
ShellExecutionService.activePtys.delete(ptyProcess.pid);
|
||||
ShellExecutionService.activeResolvers.delete(ptyProcess.pid);
|
||||
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'exit',
|
||||
exitCode,
|
||||
signal: signal ?? null,
|
||||
};
|
||||
onOutputEvent(event);
|
||||
ShellExecutionService.emitEvent(ptyProcess.pid, event);
|
||||
this.activeListeners.delete(ptyProcess.pid);
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'exit',
|
||||
exitCode,
|
||||
signal: signal ?? null,
|
||||
};
|
||||
onOutputEvent(event);
|
||||
ShellExecutionService.emitEvent(ptyProcess.pid, event);
|
||||
ShellExecutionService.activeListeners.delete(ptyProcess.pid);
|
||||
|
||||
const finalBuffer = Buffer.concat(outputChunks);
|
||||
const finalBuffer = Buffer.concat(outputChunks);
|
||||
|
||||
resolve({
|
||||
rawOutput: finalBuffer,
|
||||
output: getFullBufferText(headlessTerminal),
|
||||
exitCode,
|
||||
signal: signal ?? null,
|
||||
error,
|
||||
aborted: abortSignal.aborted,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
pid: ptyProcess.pid,
|
||||
executionMethod: ptyInfo?.name ?? 'node-pty',
|
||||
});
|
||||
resolve({
|
||||
rawOutput: finalBuffer,
|
||||
output: getFullBufferText(headlessTerminal),
|
||||
exitCode,
|
||||
signal: signal ?? null,
|
||||
error,
|
||||
aborted: abortSignal.aborted,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
pid: ptyProcess.pid,
|
||||
executionMethod: ptyInfo?.name ?? 'node-pty',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
@@ -914,6 +1023,11 @@ export class ShellExecutionService {
|
||||
* @param input The string to write to the terminal.
|
||||
*/
|
||||
static writeToPty(pid: number, input: string): void {
|
||||
if (this.persistentSession?.pid === pid) {
|
||||
this.persistentSession.write(input);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeChildProcesses.has(pid)) {
|
||||
const activeChild = this.activeChildProcesses.get(pid);
|
||||
if (activeChild) {
|
||||
@@ -1114,6 +1228,11 @@ export class ShellExecutionService {
|
||||
* @param rows The new number of rows.
|
||||
*/
|
||||
static resizePty(pid: number, cols: number, rows: number): void {
|
||||
if (this.persistentSession?.pid === pid) {
|
||||
this.persistentSession.resize(cols, rows);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isPtyActive(pid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -128,6 +128,7 @@ describe('ShellTool', () => {
|
||||
getGeminiClient: vi.fn().mockReturnValue({}),
|
||||
getShellToolInactivityTimeout: vi.fn().mockReturnValue(1000),
|
||||
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
|
||||
getEnablePersistentShell: vi.fn().mockReturnValue(false),
|
||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||
sanitizationConfig: {},
|
||||
} as unknown as Config;
|
||||
@@ -274,7 +275,7 @@ describe('ShellTool', () => {
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{ pager: 'cat', sanitizationConfig: {} },
|
||||
{ pager: 'cat', sanitizationConfig: {}, persistent: false },
|
||||
);
|
||||
expect(result.llmContent).toContain('Background PIDs: 54322');
|
||||
// The file should be deleted by the tool
|
||||
@@ -299,7 +300,7 @@ describe('ShellTool', () => {
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{ pager: 'cat', sanitizationConfig: {} },
|
||||
{ pager: 'cat', sanitizationConfig: {}, persistent: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -320,7 +321,7 @@ describe('ShellTool', () => {
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{ pager: 'cat', sanitizationConfig: {} },
|
||||
{ pager: 'cat', sanitizationConfig: {}, persistent: false },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -176,16 +176,21 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
|
||||
const onAbort = () => combinedController.abort();
|
||||
|
||||
const isPersistent =
|
||||
this.config.getEnablePersistentShell() && !this.params.is_background;
|
||||
|
||||
try {
|
||||
// pgrep is not available on Windows, so we can't get background PIDs
|
||||
const commandToExecute = isWindows
|
||||
? strippedCommand
|
||||
: (() => {
|
||||
// wrap command to append subprocess pids (via pgrep) to temporary file
|
||||
let command = strippedCommand.trim();
|
||||
if (!command.endsWith('&')) command += ';';
|
||||
return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`;
|
||||
})();
|
||||
// pgrep is not available on Windows, so we can't get background PIDs.
|
||||
// Also skip wrapping for persistent shells to avoid 'exit' killing the session.
|
||||
const commandToExecute =
|
||||
isWindows || isPersistent
|
||||
? strippedCommand
|
||||
: (() => {
|
||||
// wrap command to append subprocess pids (via pgrep) to temporary file
|
||||
let command = strippedCommand.trim();
|
||||
if (!command.endsWith('&')) command += ';';
|
||||
return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`;
|
||||
})();
|
||||
|
||||
const cwd = this.params.dir_path
|
||||
? path.resolve(this.config.getTargetDir(), this.params.dir_path)
|
||||
@@ -277,6 +282,9 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
sanitizationConfig:
|
||||
shellExecutionConfig?.sanitizationConfig ??
|
||||
this.config.sanitizationConfig,
|
||||
persistent:
|
||||
this.config.getEnablePersistentShell() &&
|
||||
!this.params.is_background,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -296,7 +304,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
const result = await resultPromise;
|
||||
|
||||
const backgroundPIDs: number[] = [];
|
||||
if (os.platform() !== 'win32') {
|
||||
if (os.platform() !== 'win32' && !isPersistent) {
|
||||
let tempFileExists = false;
|
||||
try {
|
||||
await fsPromises.access(tempFilePath);
|
||||
|
||||
@@ -406,6 +406,7 @@ describe('getShellConfiguration', () => {
|
||||
|
||||
it('should return bash configuration on Linux', () => {
|
||||
mockPlatform.mockReturnValue('linux');
|
||||
process.env['SHELL'] = '/bin/bash';
|
||||
const config = getShellConfiguration();
|
||||
expect(config.executable).toBe('bash');
|
||||
expect(config.argsPrefix).toEqual(['-c']);
|
||||
@@ -414,6 +415,7 @@ describe('getShellConfiguration', () => {
|
||||
|
||||
it('should return bash configuration on macOS (darwin)', () => {
|
||||
mockPlatform.mockReturnValue('darwin');
|
||||
delete process.env['SHELL'];
|
||||
const config = getShellConfiguration();
|
||||
expect(config.executable).toBe('bash');
|
||||
expect(config.argsPrefix).toEqual(['-c']);
|
||||
|
||||
@@ -23,7 +23,7 @@ export const SHELL_TOOL_NAMES = ['run_shell_command', 'ShellTool'];
|
||||
/**
|
||||
* An identifier for the shell type.
|
||||
*/
|
||||
export type ShellType = 'cmd' | 'powershell' | 'bash';
|
||||
export type ShellType = 'cmd' | 'powershell' | 'bash' | 'zsh';
|
||||
|
||||
/**
|
||||
* Defines the configuration required to execute a command string within a specific shell.
|
||||
|
||||
Reference in New Issue
Block a user