Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -468,8 +468,9 @@ export function matchTerminalPromptOption(options: readonly string[], suggestedO
export function detectsInputRequiredPattern(cursorLine: string): boolean {
return [
// PowerShell-style multi-option line (supports [?] Help and optional default suffix) ending
// in whitespace
/\s*(?:\[[^\]]\]\s+[^\[\s][^\[]*\s*)+(?:\(default is\s+"[^"]+"\):)?\s+$/,
// in whitespace. The label part uses [^\[\s]+(?:\s+[^\[\s]+)* to support multi-word
// labels (e.g. "Yes to All") while avoiding overlap with \s* that caused ReDoS.
/\s*(?:\[[^\]]\]\s+[^\[\s]+(?:\s+[^\[\s]+)*\s*)+(?:\(default is\s+"[^"]+"\):)?\s+$/,
// Bracketed/parenthesized yes/no pairs at end of line: (y/n), [Y/n], (yes/no), [no/yes]
/(?:\(|\[)\s*(?:y(?:es)?\s*\/\s*n(?:o)?|n(?:o)?\s*\/\s*y(?:es)?)\s*(?:\]|\))\s+$/i,
// Same as above but allows a preceding '?' or ':' and optional wrappers e.g.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { ITerminalLogService, ITerminalProfile } from '../../../../../../platfor
import { IRemoteAgentService } from '../../../../../services/remote/common/remoteAgentService.js';
import { TerminalToolConfirmationStorageKeys } from '../../../../chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.js';
import { IChatService, ChatRequestQueueKind, ElicitationState, type IChatExternalToolInvocationUpdate, type IChatTerminalToolInvocationData } from '../../../../chat/common/chatService/chatService.js';
import { constObservable, type IObservable } from '../../../../../../base/common/observable.js';
import { autorun, constObservable, type IObservable } from '../../../../../../base/common/observable.js';
import { ChatModel, type IChatRequestModeInfo } from '../../../../chat/common/model/chatModel.js';
import type { UserSelectedTools } from '../../../../chat/common/participants/chatAgents.js';
import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolConfirmationMessages, IStreamedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolInvocationStreamContext, IToolResult, ToolDataSource, ToolInvocationPresentation, ToolProgress } from '../../../../chat/common/tools/languageModelToolsService.js';
Expand Down Expand Up @@ -2111,6 +2111,33 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
// stops asking questions and lets the user finish interacting with the terminal.
let userIsReplyingDirectly = false;

const disposeNotification = () => this._backgroundNotifications.deleteAndDispose(termId);

// If the user manually stopped the agent, suppress background
// steering requests and tear down the notification listeners.
const handleSessionCancelled = (): boolean => {
if (sessionRef.object.lastRequest?.response?.isCanceled) {
disposeNotification();
return true;
}
return false;
};
Comment thread
meganrogge marked this conversation as resolved.

// Proactively detect session cancellation so that all background
// listeners are torn down immediately, rather than waiting for the
// next terminal event to fire and discover the cancelled state.
store.add(autorun(reader => {
const request = sessionRef.object.lastRequestObs.read(reader);
if (!request?.response) {
return;
}
reader.store.add(request.response.onDidChange(ev => {
if (ev.reason === 'completedRequest' && request.response!.isCanceled) {
disposeNotification();
}
}));
}));

if (outputMonitor) {
let lastInputNeededOutput = '';
let lastInputNeededNotificationTime = 0;
Expand All @@ -2132,6 +2159,10 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
return;
}

if (handleSessionCancelled()) {
return;
}

const execution = RunInTerminalTool._activeExecutions.get(termId);
if (!execution) {
return;
Expand Down Expand Up @@ -2176,15 +2207,17 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {

store.add(sessionRef);

const disposeNotification = () => this._backgroundNotifications.deleteAndDispose(termId);

store.add(commandDetection.onCommandFinished(command => {
const execution = RunInTerminalTool._activeExecutions.get(termId);
if (!execution) {
disposeNotification();
return;
}

if (handleSessionCancelled()) {
return;
}

// Dispose after first notification to avoid chatty repeated messages
// if the user runs additional commands via send_to_terminal.
disposeNotification();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,15 @@ suite('OutputMonitor', () => {
false
);
});
test('PowerShell regex does not cause catastrophic backtracking (ReDoS)', () => {
// Pathological input: many spaces not followed by a bracket.
// With the old overlapping regex this would hang; it must return promptly.
const start = performance.now();
const pathological = '[Y] Yes' + ' '.repeat(200) + 'x';
detectsInputRequiredPattern(pathological);
const elapsed = performance.now() - start;
assert.ok(elapsed < 500, `Regex took ${elapsed}ms on pathological input, expected < 500ms`);
});
test('Line ends with colon', () => {
assert.strictEqual(detectsInputRequiredPattern('Enter your name: '), true);
assert.strictEqual(detectsInputRequiredPattern('Password: '), true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ok, strictEqual } from 'assert';
import { Separator } from '../../../../../../base/common/actions.js';
import { CancellationToken } from '../../../../../../base/common/cancellation.js';
import { Emitter, Event } from '../../../../../../base/common/event.js';
import { constObservable } from '../../../../../../base/common/observable.js';
import { Schemas } from '../../../../../../base/common/network.js';
import { isLinux, isWindows, OperatingSystem } from '../../../../../../base/common/platform.js';
import { count } from '../../../../../../base/common/strings.js';
Expand Down Expand Up @@ -150,6 +151,7 @@ suite('RunInTerminalTool', () => {
acquireExistingSession: () => ({
object: {
lastRequest: undefined,
lastRequestObs: constObservable(undefined),
onDidChange: Event.None,
},
dispose: () => { },
Expand Down
Loading