feat(core): refactor shell execution to use node-pty (#6491)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
Gal Zahavi
2025-08-19 16:03:51 -07:00
committed by GitHub
parent 0cc2a1e7ef
commit f1575f6d8d
17 changed files with 1064 additions and 328 deletions

View File

@@ -539,6 +539,7 @@ export async function loadCliConfig(
folderTrust,
interactive,
trustedFolder,
shouldUseNodePtyShell: settings.shouldUseNodePtyShell,
});
}

View File

@@ -277,6 +277,17 @@ export const SETTINGS_SCHEMA = {
showInDialog: true,
},
shouldUseNodePtyShell: {
type: 'boolean',
label: 'Use node-pty for Shell Execution',
category: 'Shell',
requiresRestart: true,
default: false,
description:
'Use node-pty for shell command execution. Fallback to child_process still applies.',
showInDialog: true,
},
selectedAuthType: {
type: 'string',
label: 'Selected Auth Type',

View File

@@ -46,8 +46,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
});
const SUCCESS_RESULT = {
stdout: 'default shell output',
stderr: '',
output: 'default shell output',
exitCode: 0,
error: null,
aborted: false,
@@ -64,6 +63,7 @@ describe('ShellProcessor', () => {
mockConfig = {
getTargetDir: vi.fn().mockReturnValue('/test/dir'),
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
getShouldUseNodePtyShell: vi.fn().mockReturnValue(false),
};
context = createMockCommandContext({
@@ -120,7 +120,7 @@ describe('ShellProcessor', () => {
disallowedCommands: [],
});
mockShellExecute.mockReturnValue({
result: Promise.resolve({ ...SUCCESS_RESULT, stdout: 'On branch main' }),
result: Promise.resolve({ ...SUCCESS_RESULT, output: 'On branch main' }),
});
const result = await processor.process(prompt, context);
@@ -135,6 +135,7 @@ describe('ShellProcessor', () => {
expect.any(String),
expect.any(Function),
expect.any(Object),
false,
);
expect(result).toBe('The current status is: On branch main');
});
@@ -151,11 +152,11 @@ describe('ShellProcessor', () => {
.mockReturnValueOnce({
result: Promise.resolve({
...SUCCESS_RESULT,
stdout: 'On branch main',
output: 'On branch main',
}),
})
.mockReturnValueOnce({
result: Promise.resolve({ ...SUCCESS_RESULT, stdout: '/usr/home' }),
result: Promise.resolve({ ...SUCCESS_RESULT, output: '/usr/home' }),
});
const result = await processor.process(prompt, context);
@@ -188,7 +189,7 @@ describe('ShellProcessor', () => {
// Override the approval mode for this test
(mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.YOLO);
mockShellExecute.mockReturnValue({
result: Promise.resolve({ ...SUCCESS_RESULT, stdout: 'deleted' }),
result: Promise.resolve({ ...SUCCESS_RESULT, output: 'deleted' }),
});
const result = await processor.process(prompt, context);
@@ -199,6 +200,7 @@ describe('ShellProcessor', () => {
expect.any(String),
expect.any(Function),
expect.any(Object),
false,
);
expect(result).toBe('Do something dangerous: deleted');
});
@@ -324,10 +326,10 @@ describe('ShellProcessor', () => {
mockShellExecute
.mockReturnValueOnce({
result: Promise.resolve({ ...SUCCESS_RESULT, stdout: 'output1' }),
result: Promise.resolve({ ...SUCCESS_RESULT, output: 'output1' }),
})
.mockReturnValueOnce({
result: Promise.resolve({ ...SUCCESS_RESULT, stdout: 'output2' }),
result: Promise.resolve({ ...SUCCESS_RESULT, output: 'output2' }),
});
const result = await processor.process(prompt, context);
@@ -361,7 +363,7 @@ describe('ShellProcessor', () => {
disallowedCommands: [],
});
mockShellExecute.mockReturnValue({
result: Promise.resolve({ ...SUCCESS_RESULT, stdout: 'total 0' }),
result: Promise.resolve({ ...SUCCESS_RESULT, output: 'total 0' }),
});
await processor.process(prompt, context);
@@ -376,6 +378,7 @@ describe('ShellProcessor', () => {
expect.any(String),
expect.any(Function),
expect.any(Object),
false,
);
});
@@ -398,7 +401,7 @@ describe('ShellProcessor', () => {
const command = "awk '{print $1}' file.txt";
const prompt = `Output: !{${command}}`;
mockShellExecute.mockReturnValue({
result: Promise.resolve({ ...SUCCESS_RESULT, stdout: 'result' }),
result: Promise.resolve({ ...SUCCESS_RESULT, output: 'result' }),
});
const result = await processor.process(prompt, context);
@@ -413,6 +416,7 @@ describe('ShellProcessor', () => {
expect.any(String),
expect.any(Function),
expect.any(Object),
false,
);
expect(result).toBe('Output: result');
});
@@ -422,7 +426,7 @@ describe('ShellProcessor', () => {
const command = "echo '{{a},{b}}'";
const prompt = `!{${command}}`;
mockShellExecute.mockReturnValue({
result: Promise.resolve({ ...SUCCESS_RESULT, stdout: '{{a},{b}}' }),
result: Promise.resolve({ ...SUCCESS_RESULT, output: '{{a},{b}}' }),
});
const result = await processor.process(prompt, context);
@@ -431,6 +435,7 @@ describe('ShellProcessor', () => {
expect.any(String),
expect.any(Function),
expect.any(Object),
false,
);
expect(result).toBe('{{a},{b}}');
});
@@ -455,45 +460,13 @@ describe('ShellProcessor', () => {
});
describe('Error Reporting', () => {
it('should append stderr information if the command produces it', async () => {
it('should append exit code and command name on failure', async () => {
const processor = new ShellProcessor('test-command');
const prompt = '!{cmd}';
mockShellExecute.mockReturnValue({
result: Promise.resolve({
...SUCCESS_RESULT,
stdout: 'some output',
stderr: 'some error',
}),
});
const result = await processor.process(prompt, context);
expect(result).toBe('some output\n--- STDERR ---\nsome error');
});
it('should handle stderr-only output correctly', async () => {
const processor = new ShellProcessor('test-command');
const prompt = '!{cmd}';
mockShellExecute.mockReturnValue({
result: Promise.resolve({
...SUCCESS_RESULT,
stdout: '',
stderr: 'error only',
}),
});
const result = await processor.process(prompt, context);
expect(result).toBe('--- STDERR ---\nerror only');
});
it('should append exit code and command name if the command fails', async () => {
const processor = new ShellProcessor('test-command');
const prompt = 'Run a failing command: !{exit 1}';
mockShellExecute.mockReturnValue({
result: Promise.resolve({
...SUCCESS_RESULT,
stdout: 'some error output',
output: 'some error output',
stderr: '',
exitCode: 1,
}),
@@ -502,7 +475,7 @@ describe('ShellProcessor', () => {
const result = await processor.process(prompt, context);
expect(result).toBe(
"Run a failing command: some error output\n[Shell command 'exit 1' exited with code 1]",
"some error output\n[Shell command 'cmd' exited with code 1]",
);
});
@@ -512,7 +485,7 @@ describe('ShellProcessor', () => {
mockShellExecute.mockReturnValue({
result: Promise.resolve({
...SUCCESS_RESULT,
stdout: 'output',
output: 'output',
stderr: '',
exitCode: null,
signal: 'SIGTERM',
@@ -526,25 +499,6 @@ describe('ShellProcessor', () => {
);
});
it('should append stderr and exit code information correctly', async () => {
const processor = new ShellProcessor('test-command');
const prompt = '!{cmd}';
mockShellExecute.mockReturnValue({
result: Promise.resolve({
...SUCCESS_RESULT,
stdout: 'out',
stderr: 'err',
exitCode: 127,
}),
});
const result = await processor.process(prompt, context);
expect(result).toBe(
"out\n--- STDERR ---\nerr\n[Shell command 'cmd' exited with code 127]",
);
});
it('should throw a detailed error if the shell fails to spawn', async () => {
const processor = new ShellProcessor('test-command');
const prompt = '!{bad-command}';
@@ -572,7 +526,7 @@ describe('ShellProcessor', () => {
mockShellExecute.mockReturnValue({
result: Promise.resolve({
...SUCCESS_RESULT,
stdout: 'partial output',
output: 'partial output',
stderr: '',
exitCode: null,
error: spawnError,
@@ -609,7 +563,7 @@ describe('ShellProcessor', () => {
const processor = new ShellProcessor('test-command');
const prompt = 'Outside: {{args}}. Inside: !{echo "hello"}';
mockShellExecute.mockReturnValue({
result: Promise.resolve({ ...SUCCESS_RESULT, stdout: 'hello' }),
result: Promise.resolve({ ...SUCCESS_RESULT, output: 'hello' }),
});
const result = await processor.process(prompt, context);
@@ -621,7 +575,7 @@ describe('ShellProcessor', () => {
const processor = new ShellProcessor('test-command');
const prompt = 'Command: !{grep {{args}} file.txt}';
mockShellExecute.mockReturnValue({
result: Promise.resolve({ ...SUCCESS_RESULT, stdout: 'match found' }),
result: Promise.resolve({ ...SUCCESS_RESULT, output: 'match found' }),
});
const result = await processor.process(prompt, context);
@@ -634,6 +588,7 @@ describe('ShellProcessor', () => {
expect.any(String),
expect.any(Function),
expect.any(Object),
false,
);
expect(result).toBe('Command: match found');
@@ -643,7 +598,7 @@ describe('ShellProcessor', () => {
const processor = new ShellProcessor('test-command');
const prompt = 'User "({{args}})" requested search: !{search {{args}}}';
mockShellExecute.mockReturnValue({
result: Promise.resolve({ ...SUCCESS_RESULT, stdout: 'results' }),
result: Promise.resolve({ ...SUCCESS_RESULT, output: 'results' }),
});
const result = await processor.process(prompt, context);
@@ -655,6 +610,7 @@ describe('ShellProcessor', () => {
expect.any(String),
expect.any(Function),
expect.any(Object),
false,
);
expect(result).toBe(`User "(${rawArgs})" requested search: results`);
@@ -718,6 +674,7 @@ describe('ShellProcessor', () => {
expect.any(String),
expect.any(Function),
expect.any(Object),
false,
);
});
@@ -745,6 +702,7 @@ describe('ShellProcessor', () => {
expect.any(String),
expect.any(Function),
expect.any(Object),
false,
);
});
});

View File

@@ -137,11 +137,12 @@ export class ShellProcessor implements IPromptProcessor {
// Execute the resolved command (which already has ESCAPED input).
if (injection.resolvedCommand) {
const { result } = ShellExecutionService.execute(
const { result } = await ShellExecutionService.execute(
injection.resolvedCommand,
config.getTargetDir(),
() => {},
new AbortController().signal,
config.getShouldUseNodePtyShell(),
);
const executionResult = await result;
@@ -154,15 +155,7 @@ export class ShellProcessor implements IPromptProcessor {
}
// Append the output, making stderr explicit for the model.
if (executionResult.stdout) {
processedPrompt += executionResult.stdout;
}
if (executionResult.stderr) {
if (executionResult.stdout) {
processedPrompt += '\n';
}
processedPrompt += `--- STDERR ---\n${executionResult.stderr}`;
}
processedPrompt += executionResult.output;
// Append a status message if the command did not succeed.
if (executionResult.aborted) {

View File

@@ -65,7 +65,10 @@ describe('useShellCommandProcessor', () => {
setPendingHistoryItemMock = vi.fn();
onExecMock = vi.fn();
onDebugMessageMock = vi.fn();
mockConfig = { getTargetDir: () => '/test/dir' } as Config;
mockConfig = {
getTargetDir: () => '/test/dir',
getShouldUseNodePtyShell: () => false,
} as Config;
mockGeminiClient = { addHistory: vi.fn() } as unknown as GeminiClient;
vi.mocked(os.platform).mockReturnValue('linux');
@@ -104,13 +107,12 @@ describe('useShellCommandProcessor', () => {
): ShellExecutionResult => ({
rawOutput: Buffer.from(overrides.output || ''),
output: 'Success',
stdout: 'Success',
stderr: '',
exitCode: 0,
signal: null,
error: null,
aborted: false,
pid: 12345,
executionMethod: 'child_process',
...overrides,
});
@@ -141,6 +143,7 @@ describe('useShellCommandProcessor', () => {
'/test/dir',
expect.any(Function),
expect.any(Object),
false,
);
expect(onExecMock).toHaveBeenCalledWith(expect.any(Promise));
});
@@ -223,7 +226,6 @@ describe('useShellCommandProcessor', () => {
act(() => {
mockShellOutputCallback({
type: 'data',
stream: 'stdout',
chunk: 'hello',
});
});
@@ -238,7 +240,6 @@ describe('useShellCommandProcessor', () => {
act(() => {
mockShellOutputCallback({
type: 'data',
stream: 'stdout',
chunk: ' world',
});
});
@@ -319,6 +320,7 @@ describe('useShellCommandProcessor', () => {
'/test/dir',
expect.any(Function),
expect.any(Object),
false,
);
});

View File

@@ -101,10 +101,11 @@ export const useShellCommandProcessor = (
commandToExecute = `{ ${command} }; __code=$?; pwd > "${pwdFilePath}"; exit $__code`;
}
const execPromise = new Promise<void>((resolve) => {
const executeCommand = async (
resolve: (value: void | PromiseLike<void>) => void,
) => {
let lastUpdateTime = Date.now();
let cumulativeStdout = '';
let cumulativeStderr = '';
let isBinaryStream = false;
let binaryBytesReceived = 0;
@@ -134,7 +135,7 @@ export const useShellCommandProcessor = (
onDebugMessage(`Executing in ${targetDir}: ${commandToExecute}`);
try {
const { pid, result } = ShellExecutionService.execute(
const { pid, result } = await ShellExecutionService.execute(
commandToExecute,
targetDir,
(event) => {
@@ -142,11 +143,7 @@ export const useShellCommandProcessor = (
case 'data':
// Do not process text data if we've already switched to binary mode.
if (isBinaryStream) break;
if (event.stream === 'stdout') {
cumulativeStdout += event.chunk;
} else {
cumulativeStderr += event.chunk;
}
cumulativeStdout += event.chunk;
break;
case 'binary_detected':
isBinaryStream = true;
@@ -172,9 +169,7 @@ export const useShellCommandProcessor = (
'[Binary output detected. Halting stream...]';
}
} else {
currentDisplayOutput =
cumulativeStdout +
(cumulativeStderr ? `\n${cumulativeStderr}` : '');
currentDisplayOutput = cumulativeStdout;
}
// Throttle pending UI updates to avoid excessive re-renders.
@@ -192,6 +187,7 @@ export const useShellCommandProcessor = (
}
},
abortSignal,
config.getShouldUseNodePtyShell(),
);
executionPid = pid;
@@ -295,6 +291,10 @@ export const useShellCommandProcessor = (
resolve(); // Resolve the promise to unblock `onExec`
}
};
const execPromise = new Promise<void>((resolve) => {
executeCommand(resolve);
});
onExec(execPromise);