mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-24 08:40:58 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39d1a1bf2f |
@@ -114,6 +114,7 @@ they appear in the UI.
|
||||
| UI Label | Setting | Description | Default |
|
||||
| -------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` |
|
||||
| Enable Persistent Shell | `tools.shell.enablePersistentShell` | Maintain a persistent shell session across commands to preserve environment variables, directory changes, and aliases. | `true` |
|
||||
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
|
||||
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
|
||||
| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation. | `40000` |
|
||||
|
||||
@@ -768,6 +768,12 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.shell.enablePersistentShell`** (boolean):
|
||||
- **Description:** Maintain a persistent shell session across commands to
|
||||
preserve environment variables, directory changes, and aliases.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.shell.pager`** (string):
|
||||
- **Description:** The pager command to use for shell output. Defaults to
|
||||
`cat`.
|
||||
|
||||
@@ -783,6 +783,7 @@ export async function loadCliConfig(
|
||||
useAlternateBuffer: settings.ui?.useAlternateBuffer,
|
||||
useRipgrep: settings.tools?.useRipgrep,
|
||||
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
|
||||
enablePersistentShell: settings.tools?.shell?.enablePersistentShell,
|
||||
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
|
||||
enableShellOutputEfficiency:
|
||||
settings.tools?.shell?.enableShellOutputEfficiency ?? true,
|
||||
|
||||
@@ -1282,6 +1282,17 @@ const SETTINGS_SCHEMA = {
|
||||
`,
|
||||
showInDialog: true,
|
||||
},
|
||||
enablePersistentShell: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Persistent Shell',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: oneLine`
|
||||
Maintain a persistent shell session across commands to preserve environment variables, directory changes, and aliases.
|
||||
`,
|
||||
showInDialog: true,
|
||||
},
|
||||
pager: {
|
||||
type: 'string',
|
||||
label: 'Pager',
|
||||
|
||||
@@ -77,6 +77,7 @@ describe('ShellProcessor', () => {
|
||||
getTargetDir: vi.fn().mockReturnValue('/test/dir'),
|
||||
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
|
||||
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
|
||||
getEnablePersistentShell: vi.fn().mockReturnValue(false),
|
||||
getShellExecutionConfig: vi.fn().mockReturnValue({}),
|
||||
getPolicyEngine: vi.fn().mockReturnValue({
|
||||
check: mockPolicyEngineCheck,
|
||||
|
||||
@@ -167,6 +167,7 @@ export class ShellProcessor implements IPromptProcessor {
|
||||
...config.getShellExecutionConfig(),
|
||||
defaultFg: activeTheme.colors.Foreground,
|
||||
defaultBg: activeTheme.colors.Background,
|
||||
persistent: config.getEnablePersistentShell(),
|
||||
};
|
||||
const { result } = await ShellExecutionService.execute(
|
||||
injection.resolvedCommand,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
SessionEndReason,
|
||||
SessionStartSource,
|
||||
flushTelemetry,
|
||||
ShellExecutionService,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { SlashCommand } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
@@ -70,6 +71,7 @@ export const clearCommand: SlashCommand = {
|
||||
}
|
||||
|
||||
uiTelemetryService.setLastPromptTokenCount(0);
|
||||
ShellExecutionService.clearPersistentSession();
|
||||
context.ui.clear();
|
||||
|
||||
if (result?.systemMessage) {
|
||||
|
||||
-88
@@ -1,88 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="428" viewBox="0 0 920 428">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="428" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#f9e2af" textLength="900" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="19" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#f9e2af" textLength="135" lengthAdjust="spacingAndGlyphs" font-weight="bold">Action Required</text>
|
||||
<text x="891" y="19" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="36" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="36" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="53" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="53" fill="#89b4fa" textLength="9" lengthAdjust="spacingAndGlyphs">?</text>
|
||||
<text x="45" y="53" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">run_shell_command</text>
|
||||
<text x="207" y="53" fill="#6c7086" textLength="666" lengthAdjust="spacingAndGlyphs">cat << 'EOF' > foo.txtRoses are red,Violets are blue,The CLI is fast,And h</text>
|
||||
<text x="873" y="53" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">… </text>
|
||||
<text x="891" y="53" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="70" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="70" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="87" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="87" fill="#00cdcd" textLength="27" lengthAdjust="spacingAndGlyphs">cat</text>
|
||||
<text x="45" y="87" fill="#e5e5e5" textLength="36" lengthAdjust="spacingAndGlyphs"> << </text>
|
||||
<text x="81" y="87" fill="#cdcd00" textLength="45" lengthAdjust="spacingAndGlyphs">'EOF'</text>
|
||||
<text x="126" y="87" fill="#e5e5e5" textLength="90" lengthAdjust="spacingAndGlyphs"> > foo.txt</text>
|
||||
<text x="891" y="87" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="104" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="104" fill="#e5e5e5" textLength="126" lengthAdjust="spacingAndGlyphs">Roses are red,</text>
|
||||
<text x="891" y="104" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="121" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="121" fill="#e5e5e5" textLength="153" lengthAdjust="spacingAndGlyphs">Violets are blue,</text>
|
||||
<text x="891" y="121" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="138" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="138" fill="#e5e5e5" textLength="144" lengthAdjust="spacingAndGlyphs">The CLI is fast,</text>
|
||||
<text x="891" y="138" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="155" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="155" fill="#e5e5e5" textLength="144" lengthAdjust="spacingAndGlyphs">And helpful too.</text>
|
||||
<text x="891" y="155" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="172" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="172" fill="#e5e5e5" textLength="144" lengthAdjust="spacingAndGlyphs">End of the poem.</text>
|
||||
<text x="891" y="172" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="189" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="189" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs">EOF</text>
|
||||
<text x="891" y="189" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="206" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="206" fill="#00cdcd" textLength="36" lengthAdjust="spacingAndGlyphs">echo</text>
|
||||
<text x="63" y="206" fill="#cdcd00" textLength="342" lengthAdjust="spacingAndGlyphs">"Poem successfully written to foo.txt"</text>
|
||||
<text x="891" y="206" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="223" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="223" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="240" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs" font-weight="bold">Note: </text>
|
||||
<text x="72" y="240" fill="#ffffff" textLength="819" lengthAdjust="spacingAndGlyphs">Command contains redirection which can be undesirable. </text>
|
||||
<text x="891" y="240" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="257" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="257" fill="#333333" textLength="54" lengthAdjust="spacingAndGlyphs" font-weight="bold">Tip: </text>
|
||||
<text x="72" y="257" fill="#333333" textLength="576" lengthAdjust="spacingAndGlyphs">Toggle auto-edit (Shift+Tab) to allow redirection in the future.</text>
|
||||
<text x="891" y="257" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="274" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="274" fill="#ffffff" textLength="882" lengthAdjust="spacingAndGlyphs"> Allow execution of: 'cat, heredoc (<<), redirection (>), echo'? </text>
|
||||
<text x="891" y="274" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="291" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="291" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="308" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<rect x="18" y="306" width="9" height="17" fill="#001a00" />
|
||||
<text x="18" y="308" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">●</text>
|
||||
<rect x="27" y="306" width="9" height="17" fill="#001a00" />
|
||||
<rect x="36" y="306" width="18" height="17" fill="#001a00" />
|
||||
<text x="36" y="308" fill="#00cd00" textLength="18" lengthAdjust="spacingAndGlyphs">1.</text>
|
||||
<rect x="54" y="306" width="9" height="17" fill="#001a00" />
|
||||
<rect x="63" y="306" width="90" height="17" fill="#001a00" />
|
||||
<text x="63" y="308" fill="#00cd00" textLength="90" lengthAdjust="spacingAndGlyphs">Allow once</text>
|
||||
<rect x="153" y="306" width="171" height="17" fill="#001a00" />
|
||||
<text x="891" y="308" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="325" fill="#ffffff" textLength="882" lengthAdjust="spacingAndGlyphs"> 2. Allow for this session </text>
|
||||
<text x="891" y="325" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="342" fill="#ffffff" textLength="882" lengthAdjust="spacingAndGlyphs"> 3. Allow for all future sessions </text>
|
||||
<text x="891" y="342" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="359" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="9" y="359" fill="#ffffff" textLength="882" lengthAdjust="spacingAndGlyphs"> 4. No, suggest changes (esc) </text>
|
||||
<text x="891" y="359" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="376" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="376" fill="#f9e2af" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="393" fill="#f9e2af" textLength="900" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -90,33 +90,6 @@ exports[`ToolConfirmationQueue > renders ExitPlanMode tool confirmation with Suc
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ToolConfirmationQueue > renders a multiline shell command with syntax highlighting and redirection warning (SVG snapshot) 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Action Required │
|
||||
│ │
|
||||
│ ? run_shell_command cat << 'EOF' > foo.txtRoses are red,Violets are blue,The CLI is fast,And h… │
|
||||
│ │
|
||||
│ cat << 'EOF' > foo.txt │
|
||||
│ Roses are red, │
|
||||
│ Violets are blue, │
|
||||
│ The CLI is fast, │
|
||||
│ And helpful too. │
|
||||
│ End of the poem. │
|
||||
│ EOF │
|
||||
│ echo "Poem successfully written to foo.txt" │
|
||||
│ │
|
||||
│ Note: Command contains redirection which can be undesirable. │
|
||||
│ Tip: Toggle auto-edit (Shift+Tab) to allow redirection in the future. │
|
||||
│ Allow execution of: 'cat, heredoc (<<), redirection (>), echo'? │
|
||||
│ │
|
||||
│ ● 1. Allow once │
|
||||
│ 2. Allow for this session │
|
||||
│ 3. Allow for all future sessions │
|
||||
│ 4. No, suggest changes (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`ToolConfirmationQueue > renders expansion hint when content is long and constrained 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Action Required │
|
||||
|
||||
@@ -240,37 +240,6 @@ describe('ToolConfirmationMessage', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render multiline shell scripts with correct newlines and syntax highlighting (SVG snapshot)', async () => {
|
||||
const confirmationDetails: SerializableConfirmationDetails = {
|
||||
type: 'exec',
|
||||
title: 'Confirm Multiline Script',
|
||||
command: 'echo "hello"\nfor i in 1 2 3; do\n echo $i\ndone',
|
||||
rootCommand: 'echo',
|
||||
rootCommands: ['echo'],
|
||||
};
|
||||
|
||||
const result = renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
getPreferredEditor={vi.fn()}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
await result.waitUntilReady();
|
||||
|
||||
const output = result.lastFrame();
|
||||
expect(output).toContain('echo "hello"');
|
||||
expect(output).toContain('for i in 1 2 3; do');
|
||||
expect(output).toContain('echo $i');
|
||||
expect(output).toContain('done');
|
||||
|
||||
await expect(result).toMatchSvgSnapshot();
|
||||
result.unmount();
|
||||
});
|
||||
|
||||
describe('with folder trust', () => {
|
||||
const editConfirmationDetails: SerializableConfirmationDetails = {
|
||||
type: 'edit',
|
||||
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
import { AskUserDialog } from '../AskUserDialog.js';
|
||||
import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js';
|
||||
import { WarningMessage } from './WarningMessage.js';
|
||||
import { colorizeCode } from '../../utils/CodeColorizer.js';
|
||||
import {
|
||||
getDeceptiveUrlDetails,
|
||||
toUnicodeUrl,
|
||||
@@ -549,19 +548,9 @@ export const ToolConfirmationMessage: React.FC<
|
||||
>
|
||||
<Box flexDirection="column">
|
||||
{commandsToDisplay.map((cmd, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
flexDirection="column"
|
||||
paddingBottom={idx < commandsToDisplay.length - 1 ? 1 : 0}
|
||||
>
|
||||
{colorizeCode({
|
||||
code: cmd,
|
||||
language: 'bash',
|
||||
maxWidth: Math.max(terminalWidth, 1),
|
||||
settings,
|
||||
hideLineNumbers: true,
|
||||
})}
|
||||
</Box>
|
||||
<Text key={idx} color={theme.text.link}>
|
||||
{sanitizeForDisplay(cmd)}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
@@ -645,7 +634,6 @@ export const ToolConfirmationMessage: React.FC<
|
||||
mcpToolDetailsText,
|
||||
expandDetailsHintKey,
|
||||
getPreferredEditor,
|
||||
settings,
|
||||
]);
|
||||
|
||||
const bodyOverflowDirection: 'top' | 'bottom' =
|
||||
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="173" viewBox="0 0 920 173">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="173" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#00cdcd" textLength="36" lengthAdjust="spacingAndGlyphs">echo</text>
|
||||
<text x="45" y="2" fill="#cdcd00" textLength="63" lengthAdjust="spacingAndGlyphs">"hello"</text>
|
||||
<text x="0" y="19" fill="#0000ee" textLength="27" lengthAdjust="spacingAndGlyphs">for</text>
|
||||
<text x="27" y="19" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> i </text>
|
||||
<text x="54" y="19" fill="#0000ee" textLength="18" lengthAdjust="spacingAndGlyphs">in</text>
|
||||
<text x="72" y="19" fill="#e5e5e5" textLength="72" lengthAdjust="spacingAndGlyphs"> 1 2 3; </text>
|
||||
<text x="144" y="19" fill="#0000ee" textLength="18" lengthAdjust="spacingAndGlyphs">do</text>
|
||||
<text x="18" y="36" fill="#00cdcd" textLength="36" lengthAdjust="spacingAndGlyphs">echo</text>
|
||||
<text x="63" y="36" fill="#cd00cd" textLength="18" lengthAdjust="spacingAndGlyphs">$i</text>
|
||||
<text x="0" y="53" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">done</text>
|
||||
<text x="0" y="70" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Allow execution of: 'echo'? </text>
|
||||
<rect x="0" y="102" width="9" height="17" fill="#001a00" />
|
||||
<text x="0" y="104" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">●</text>
|
||||
<rect x="9" y="102" width="9" height="17" fill="#001a00" />
|
||||
<rect x="18" y="102" width="18" height="17" fill="#001a00" />
|
||||
<text x="18" y="104" fill="#00cd00" textLength="18" lengthAdjust="spacingAndGlyphs">1.</text>
|
||||
<rect x="36" y="102" width="9" height="17" fill="#001a00" />
|
||||
<rect x="45" y="102" width="90" height="17" fill="#001a00" />
|
||||
<text x="45" y="104" fill="#00cd00" textLength="90" lengthAdjust="spacingAndGlyphs">Allow once</text>
|
||||
<rect x="135" y="102" width="135" height="17" fill="#001a00" />
|
||||
<text x="0" y="121" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> 2. Allow for this session </text>
|
||||
<text x="0" y="138" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> 3. No, suggest changes (esc) </text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
-15
@@ -2,9 +2,7 @@
|
||||
|
||||
exports[`ToolConfirmationMessage > should display multiple commands for exec type when provided 1`] = `
|
||||
"echo "hello"
|
||||
|
||||
ls -la
|
||||
|
||||
whoami
|
||||
Allow execution of 3 commands?
|
||||
|
||||
@@ -37,19 +35,6 @@ Do you want to proceed?
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ToolConfirmationMessage > should render multiline shell scripts with correct newlines and syntax highlighting (SVG snapshot) 1`] = `
|
||||
"echo "hello"
|
||||
for i in 1 2 3; do
|
||||
echo $i
|
||||
done
|
||||
Allow execution of: 'echo'?
|
||||
|
||||
● 1. Allow once
|
||||
2. Allow for this session
|
||||
3. No, suggest changes (esc)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ToolConfirmationMessage > should strip BiDi characters from MCP tool and server names 1`] = `
|
||||
"MCP Server: testserver
|
||||
Tool: testtool
|
||||
|
||||
@@ -106,6 +106,7 @@ describe('useShellCommandProcessor', () => {
|
||||
mockConfig = {
|
||||
getTargetDir: () => '/test/dir',
|
||||
getEnableInteractiveShell: () => false,
|
||||
getEnablePersistentShell: () => false,
|
||||
getShellExecutionConfig: () => ({
|
||||
terminalHeight: 20,
|
||||
terminalWidth: 80,
|
||||
|
||||
@@ -283,6 +283,7 @@ export const useShellCommandProcessor = (
|
||||
let commandToExecute = rawQuery;
|
||||
let pwdFilePath: string | undefined;
|
||||
|
||||
const isPersistent = config.getEnablePersistentShell();
|
||||
// On non-windows, wrap the command to capture the final working directory.
|
||||
if (!isWindows) {
|
||||
let command = rawQuery.trim();
|
||||
@@ -292,7 +293,11 @@ export const useShellCommandProcessor = (
|
||||
if (!command.endsWith(';') && !command.endsWith('&')) {
|
||||
command += ';';
|
||||
}
|
||||
commandToExecute = `{ ${command} }; __code=$?; pwd > "${pwdFilePath}"; exit $__code`;
|
||||
if (isPersistent) {
|
||||
commandToExecute = `{ ${command} }; __code=$?; pwd > "${pwdFilePath}"; (exit $__code)`;
|
||||
} else {
|
||||
commandToExecute = `{ ${command} }; __code=$?; pwd > "${pwdFilePath}"; exit $__code`;
|
||||
}
|
||||
}
|
||||
|
||||
const executeCommand = async () => {
|
||||
@@ -324,7 +329,9 @@ export const useShellCommandProcessor = (
|
||||
};
|
||||
abortSignal.addEventListener('abort', abortHandler, { once: true });
|
||||
|
||||
onDebugMessage(`Executing in ${targetDir}: ${commandToExecute}`);
|
||||
onDebugMessage(
|
||||
`Executing in ${targetDir} (persistent=${isPersistent}): ${commandToExecute}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const activeTheme = themeManager.getActiveTheme();
|
||||
@@ -334,6 +341,7 @@ export const useShellCommandProcessor = (
|
||||
terminalHeight,
|
||||
defaultFg: activeTheme.colors.Foreground,
|
||||
defaultBg: activeTheme.colors.Background,
|
||||
persistent: isPersistent,
|
||||
};
|
||||
|
||||
const { pid, result: resultPromise } =
|
||||
|
||||
@@ -50,36 +50,4 @@ describe('colorizeCode', () => {
|
||||
expect(lastFrame()).toMatch(/line 1\s*\n\s*\n\s*line 3/);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not let colors from ansi escape codes leak into colorized code', async () => {
|
||||
const code = 'line 1\n\x1b[41mline 2 with red background\x1b[0m\nline 3';
|
||||
const settings = new LoadedSettings(
|
||||
{ path: '', settings: {}, originalSettings: {} },
|
||||
{ path: '', settings: {}, originalSettings: {} },
|
||||
{
|
||||
path: '',
|
||||
settings: { ui: { useAlternateBuffer: true, showLineNumbers: false } },
|
||||
originalSettings: {
|
||||
ui: { useAlternateBuffer: true, showLineNumbers: false },
|
||||
},
|
||||
},
|
||||
{ path: '', settings: {}, originalSettings: {} },
|
||||
true,
|
||||
[],
|
||||
);
|
||||
|
||||
const result = colorizeCode({
|
||||
code,
|
||||
language: 'javascript',
|
||||
maxWidth: 80,
|
||||
settings,
|
||||
hideLineNumbers: true,
|
||||
});
|
||||
|
||||
const renderResult = renderWithProviders(<>{result}</>);
|
||||
await renderResult.waitUntilReady();
|
||||
|
||||
await expect(renderResult).toMatchSvgSnapshot();
|
||||
renderResult.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,6 @@ import type {
|
||||
ElementContent,
|
||||
RootContent,
|
||||
} from 'hast';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import { themeManager } from '../themes/theme-manager.js';
|
||||
import type { Theme } from '../themes/theme.js';
|
||||
import {
|
||||
@@ -99,17 +98,16 @@ function highlightAndRenderLine(
|
||||
theme: Theme,
|
||||
): React.ReactNode {
|
||||
try {
|
||||
const strippedLine = stripAnsi(line);
|
||||
const getHighlightedLine = () =>
|
||||
!language || !lowlight.registered(language)
|
||||
? lowlight.highlightAuto(strippedLine)
|
||||
: lowlight.highlight(language, strippedLine);
|
||||
? lowlight.highlightAuto(line)
|
||||
: lowlight.highlight(language, line);
|
||||
|
||||
const renderedNode = renderHastNode(getHighlightedLine(), theme, undefined);
|
||||
|
||||
return renderedNode !== null ? renderedNode : strippedLine;
|
||||
return renderedNode !== null ? renderedNode : line;
|
||||
} catch (_error) {
|
||||
return stripAnsi(line);
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,7 +238,7 @@ export function colorizeCode({
|
||||
<Text color={activeTheme.defaultColor}>{`${index + 1}`}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Text color={activeTheme.colors.Gray}>{stripAnsi(line)}</Text>
|
||||
<Text color={activeTheme.colors.Gray}>{line}</Text>
|
||||
</Box>
|
||||
));
|
||||
|
||||
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">line </text>
|
||||
<text x="45" y="2" fill="#a6e3a1" textLength="9" lengthAdjust="spacingAndGlyphs">1</text>
|
||||
<text x="0" y="19" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">line </text>
|
||||
<text x="45" y="19" fill="#a6e3a1" textLength="9" lengthAdjust="spacingAndGlyphs">2</text>
|
||||
<text x="63" y="19" fill="#89b4fa" textLength="36" lengthAdjust="spacingAndGlyphs">with</text>
|
||||
<text x="99" y="19" fill="#ffffff" textLength="801" lengthAdjust="spacingAndGlyphs"> red background </text>
|
||||
<text x="0" y="36" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">line </text>
|
||||
<text x="45" y="36" fill="#a6e3a1" textLength="9" lengthAdjust="spacingAndGlyphs">3</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,7 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`colorizeCode > does not let colors from ansi escape codes leak into colorized code 1`] = `
|
||||
"line 1
|
||||
line 2 with red background
|
||||
line 3"
|
||||
`;
|
||||
@@ -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.
|
||||
|
||||
@@ -1305,6 +1305,13 @@
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"enablePersistentShell": {
|
||||
"title": "Enable Persistent Shell",
|
||||
"description": "Maintain a persistent shell session across commands to preserve environment variables, directory changes, and aliases.",
|
||||
"markdownDescription": "Maintain a persistent shell session across commands to preserve environment variables, directory changes, and aliases.\n\n- Category: `Tools`\n- Requires restart: `yes`\n- Default: `true`",
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"pager": {
|
||||
"title": "Pager",
|
||||
"description": "The pager command to use for shell output. Defaults to `cat`.",
|
||||
|
||||
Reference in New Issue
Block a user