Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/src/nonInteractiveCliCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export const handleSlashCommand = async (
name: commandToExecute.name,
args,
},
signal: abortController.signal,
};

const result = await commandToExecute.action(commandContext, args);
Expand Down
138 changes: 137 additions & 1 deletion packages/cli/src/services/prompt-processors/shellProcessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
*/

import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import { ConfirmationRequiredError, ShellProcessor } from './shellProcessor.js';
import {
ConfirmationRequiredError,
SHELL_INJECTION_TIMEOUT_MS,
ShellProcessor,
} from './shellProcessor.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import type { CommandContext } from '../../ui/commands/types.js';
import type { Config } from '@google/gemini-cli-core';
Expand Down Expand Up @@ -577,6 +581,138 @@ describe('ShellProcessor', () => {
});
});

describe('Command cancellation', () => {
/**
* A command that never exits on its own, whose result only settles once the
* signal it was executed with is aborted.
*/
function mockHangingCommand(): () => AbortSignal | undefined {
let executedWith: AbortSignal | undefined;
mockShellExecute.mockImplementation(
(
_command: string,
_cwd: string,
_onOutput: unknown,
signal: AbortSignal,
) => {
executedWith = signal;
return {
result: new Promise((resolve) => {
signal.addEventListener(
'abort',
() =>
resolve({
...SUCCESS_RESULT,
output: 'partial output',
exitCode: null,
aborted: true,
}),
{ once: true },
);
}),
};
},
);
return () => executedWith;
}

it('should forward the caller signal to shell execution', async () => {
const processor = new ShellProcessor('test-command');
const prompt: PromptPipelineContent = createPromptPipelineContent(
'!{long-running-command}',
);
const controller = new AbortController();
const executedWith = mockHangingCommand();

context.signal = controller.signal;
const processed = processor.process(prompt, context);

// The command is still running and the caller has not cancelled yet.
await vi.waitFor(() => expect(executedWith()).toBeDefined());
expect(executedWith()?.aborted).toBe(false);

controller.abort();

// Cancelling the caller must reach the subprocess.
expect(executedWith()?.aborted).toBe(true);
expect(await processed).toEqual([
{
text: "partial output\n[Shell command 'long-running-command' aborted]",
},
]);
});

it('should abort a hung command once the injection timeout elapses', async () => {
vi.useFakeTimers();
try {
const processor = new ShellProcessor('test-command');
const prompt: PromptPipelineContent =
createPromptPipelineContent('!{hanging-command}');
const executedWith = mockHangingCommand();
const processed = processor.process(prompt, context);

await vi.advanceTimersByTimeAsync(0);
expect(executedWith()).toBeDefined();
expect(executedWith()?.aborted).toBe(false);

await vi.advanceTimersByTimeAsync(SHELL_INJECTION_TIMEOUT_MS);

expect(await processed).toEqual([
{
text: "partial output\n[Shell command 'hanging-command' aborted]",
},
]);
} finally {
vi.useRealTimers();
}
});

it('should clean up the timeout and caller listener after execution', async () => {
vi.useFakeTimers();
try {
const processor = new ShellProcessor('test-command');
const prompt: PromptPipelineContent =
createPromptPipelineContent('!{list-command}');
const controller = new AbortController();
const addListenerSpy = vi.spyOn(controller.signal, 'addEventListener');
const removeListenerSpy = vi.spyOn(
controller.signal,
'removeEventListener',
);

context.signal = controller.signal;
const result = await processor.process(prompt, context);

expect(result).toEqual([{ text: 'default shell output' }]);
expect(addListenerSpy).toHaveBeenCalledWith(
'abort',
expect.any(Function),
{ once: true },
);
expect(removeListenerSpy).toHaveBeenCalledWith(
'abort',
expect.any(Function),
);
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
}
});
Comment thread
FanouZeng-TT marked this conversation as resolved.

it('should execute with a live signal when the caller provides none', async () => {
const processor = new ShellProcessor('test-command');
const prompt: PromptPipelineContent =
createPromptPipelineContent('!{list-command}');

const result = await processor.process(prompt, context);

expect(result).toEqual([{ text: 'default shell output' }]);
const signal = mockShellExecute.mock.calls[0][3] as AbortSignal;
expect(signal).toBeInstanceOf(AbortSignal);
expect(signal.aborted).toBe(false);
});
});

describe('Context-Aware Argument Interpolation ({{args}})', () => {
const rawArgs = 'user input';

Expand Down
66 changes: 56 additions & 10 deletions packages/cli/src/services/prompt-processors/shellProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,47 @@ export class ConfirmationRequiredError extends Error {
}
}

/**
* Ceiling for a single `!{...}` injection. A command that never exits would
* otherwise hold the prompt pipeline open with no way out, since the pipeline
* has no per-command budget of its own.
*/
export const SHELL_INJECTION_TIMEOUT_MS = 60_000;

interface CommandAbortSignal {
signal: AbortSignal;
cleanup: () => void;
}

/**
* Builds the signal a single injection is executed under: the caller's
* cancellation (when there is one) combined with the per-command timeout.
* The returned cleanup must run when execution settles so short-lived commands
* do not leave a timer or listener attached to a long-lived caller signal.
*/
function getCommandAbortSignal(caller?: AbortSignal): CommandAbortSignal {
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
SHELL_INJECTION_TIMEOUT_MS,
);
const onCallerAbort = () => controller.abort();

if (caller?.aborted) {
controller.abort();
} else {
caller?.addEventListener('abort', onCallerAbort, { once: true });
}

return {
signal: controller.signal,
cleanup: () => {
clearTimeout(timeoutId);
caller?.removeEventListener('abort', onCallerAbort);
},
};
}

/**
* Represents a single detected shell injection site in the prompt,
* after resolution of arguments. Extends the base Injection interface.
Expand Down Expand Up @@ -168,16 +209,21 @@ export class ShellProcessor implements IPromptProcessor {
defaultFg: activeTheme.colors.Foreground,
defaultBg: activeTheme.colors.Background,
};
const { result } = await ShellExecutionService.execute(
injection.resolvedCommand,
config.getTargetDir(),
() => {},
new AbortController().signal,
config.getEnableInteractiveShell(),
shellExecutionConfig,
);

const executionResult = await result;
const commandAbort = getCommandAbortSignal(context.signal);
let executionResult;
try {
const { result } = await ShellExecutionService.execute(
injection.resolvedCommand,
config.getTargetDir(),
() => {},
commandAbort.signal,
config.getEnableInteractiveShell(),
shellExecutionConfig,
);
executionResult = await result;
} finally {
commandAbort.cleanup();
}

// Handle Spawn Errors
if (executionResult.error && !executionResult.aborted) {
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/ui/commands/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ export interface CommandContext {
/** A transient list of shell commands the user has approved for this session. */
sessionShellAllowlist: Set<string>;
};
/**
* Cancellation for the work this command triggers (for example the caller's
* abort controller in non-interactive mode). Processors and actions that
* start long-running work should honour it so a cancelled invocation does not
* keep running.
*/
signal?: AbortSignal;
// Flag to indicate if an overwrite has been confirmed
overwriteConfirmed?: boolean;
}
Expand Down
Loading