From 5e3625e9a7b79baeaace36626f768a450d0eb823 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 6 May 2026 15:58:41 -0400 Subject: [PATCH 01/11] Chat terminal: detect sensitive prompts and surface focus-terminal UI (#314796) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a command run via the chat terminal tool produces output that looks like a password / passphrase / secret prompt (e.g. 'Password:', '[sudo] password for user:', 'Passphrase for /home/me/.ssh/id_rsa:', 'Enter PIN:', 'Verification code:'), we never want the model to answer — we route the secret entry to the user directly. The agent system prompt instructs the model not to call ask_questions for secrets, but until now there was no explicit detection or UI affordance, and screen-reader users got no announcement that input was required. Adds: - detectsSensitiveInputPrompt regex covering the common patterns above. - A new onDidDetectSensitiveInputNeeded signal on IOutputMonitor, fired from the same idle-detection path as onDidDetectInputNeeded but routed separately so the regular 'looks like a prompt — answer it' UX is bypassed for secrets. - runInTerminalTool listens for the sensitive signal and shows a chat elicitation that announces the prompt for ARIA, offers a primary 'Focus terminal' action, and a 'Cancel' secondary action. The secret itself is never round-tripped through the model. Tests cover the new regex (positive + negative cases) and verify that a sensitive prompt fires onDidDetectSensitiveInputNeeded but NOT onDidDetectInputNeeded. --- .../browser/tools/monitoring/outputMonitor.ts | 46 ++++++-- .../browser/tools/runInTerminalTool.ts | 101 +++++++++++++++++- .../test/browser/outputMonitor.test.ts | 37 ++++++- .../runInTerminalTool.test.ts | 3 + 4 files changed, 179 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts index af3c934a1eb27..3de8606057f21 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts @@ -20,6 +20,14 @@ export interface IOutputMonitor extends Disposable { readonly onDidFinishCommand: Event; readonly onDidDetectInputNeeded: Event; + /** + * Fires when the terminal is detected to be waiting for sensitive input + * (e.g. a password, passphrase, token, secret or verification code). This + * is fired *instead of* {@link onDidDetectInputNeeded} so callers can show + * UI that focuses the terminal rather than routing the prompt through the + * agent. + */ + readonly onDidDetectSensitiveInputNeeded: Event; } export interface IOutputMonitorTelemetryCounters { @@ -98,6 +106,9 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { private readonly _onDidDetectInputNeeded = this._register(new Emitter()); readonly onDidDetectInputNeeded: Event = this._onDidDetectInputNeeded.event; + private readonly _onDidDetectSensitiveInputNeeded = this._register(new Emitter()); + readonly onDidDetectSensitiveInputNeeded: Event = this._onDidDetectSensitiveInputNeeded.event; + private _asyncMode = false; private _command = ''; private _invocationContext: IToolInvocationContext | undefined; @@ -374,8 +385,13 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { // (passwords, [Y/n], etc.) and signal the agent to handle via send_to_terminal. if (this._asyncMode) { if (detectsInputRequiredPattern(outputLastLine)) { - this._logService.trace('OutputMonitor: Async mode - input-required pattern detected, signaling agent'); - this._onDidDetectInputNeeded.fire(); + if (detectsSensitiveInputPrompt(outputLastLine)) { + this._logService.trace('OutputMonitor: Async mode - sensitive input prompt detected, signaling sensitive UI'); + this._onDidDetectSensitiveInputNeeded.fire(); + } else { + this._logService.trace('OutputMonitor: Async mode - input-required pattern detected, signaling agent'); + this._onDidDetectInputNeeded.fire(); + } } this._cleanupIdleInputListener(); return { shouldContinuePolling: false, output }; @@ -384,10 +400,17 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { // Use regex-based detection for input-required patterns (passwords, [Y/n], etc.) // In foreground mode, fire the event so the race in runInTerminalTool can pick it // up and return control to the agent (which uses send_to_terminal to provide input). - // No elicitation UI is shown — the agent handles it autonomously. + // For sensitive prompts (passwords, secrets, OTPs, …) we instead fire a separate + // event so the tool can show a confirmation dialog that focuses the terminal — + // the secret must never be routed through the model. if (detectsInputRequiredPattern(outputLastLine)) { - this._logService.trace('OutputMonitor: Input-required pattern detected, signaling agent'); - this._onDidDetectInputNeeded.fire(); + if (detectsSensitiveInputPrompt(outputLastLine)) { + this._logService.trace('OutputMonitor: Sensitive input prompt detected, signaling sensitive UI'); + this._onDidDetectSensitiveInputNeeded.fire(); + } else { + this._logService.trace('OutputMonitor: Input-required pattern detected, signaling agent'); + this._onDidDetectInputNeeded.fire(); + } this._cleanupIdleInputListener(); return { shouldContinuePolling: false, output }; } @@ -541,10 +564,21 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { } private _isSensitivePrompt(prompt: string): boolean { - return /(password|passphrase|token|api\s*key|secret)/i.test(prompt); + return detectsSensitiveInputPrompt(prompt); } } +/** + * Returns true when the terminal's last visible line looks like a prompt for + * a sensitive secret (password, passphrase, token, API key, OTP, etc.). Used + * to short-circuit the normal "input needed → return to agent" flow so that + * the secret is never routed through the model — instead the user is asked + * via UI to focus the terminal and type the secret directly. + */ +export function detectsSensitiveInputPrompt(cursorLine: string): boolean { + return /(password|passphrase|token|api\s*key|secret|verification code|otp\b|one[\s-]?time (?:code|password)|2fa|mfa|pin\s*(?:code|number)?[: ]?\s*$|authentication code)/i.test(cursorLine); +} + export function matchTerminalPromptOption(options: readonly string[], suggestedOption: string): { option: string | undefined; index: number } { const normalize = (value: string) => value.replace(/['"`]/g, '').trim().replace(/[.,:;]+$/, ''); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 78fd504195ff8..b8ebac62dffb4 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -10,7 +10,7 @@ import { Codicon } from '../../../../../../base/common/codicons.js'; import { CancellationError } from '../../../../../../base/common/errors.js'; import { Event } from '../../../../../../base/common/event.js'; import { escapeMarkdownSyntaxTokens, MarkdownString, type IMarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { Disposable, DisposableMap, DisposableStore, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; import { getMediaMime } from '../../../../../../base/common/mime.js'; import { basename, posix, win32 } from '../../../../../../base/common/path.js'; @@ -1084,6 +1084,78 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { : new MarkdownString(localize('runInTerminal.unsandboxed.autoRetry', "Run `{0}` command outside the sandbox?", shellType)); } + /** + * Surface a confirmation dialog when the terminal is detected to be waiting + * for sensitive input (password, passphrase, OTP, …). Sensitive prompts must + * never be routed through the model — the user types the secret directly + * into the terminal. The "Focus terminal" action reveals and focuses the + * terminal; the "Cancel" action cancels the running command. + * + * Returns a disposable that hides any pending elicitation. The handler + * itself dedupes concurrent elicitations so repeated polling cycles don't + * spam the chat session. + */ + private _registerSensitiveInputElicitation( + chatSessionResource: URI | undefined, + terminalInstance: ITerminalInstance, + outputMonitor: { onDidDetectSensitiveInputNeeded: Event }, + cancelExecution: () => void, + ): IDisposable { + const store = new DisposableStore(); + let pending: { hide: () => void } | undefined; + + store.add(outputMonitor.onDidDetectSensitiveInputNeeded(() => { + if (pending) { + return; + } + const chatModel = chatSessionResource && this._chatService.getSession(chatSessionResource); + if (!(chatModel instanceof ChatModel)) { + // No chat surface to attach to — fall back to focusing the + // terminal directly so the user is at least not left blocked. + this._terminalService.setActiveInstance(terminalInstance); + this._terminalService.revealTerminal(terminalInstance, true).catch(() => { }); + terminalInstance.focus(); + return; + } + const request = chatModel.getRequests().at(-1); + if (!request) { + return; + } + + const part = new ChatElicitationRequestPart( + new MarkdownString(localize('runInTerminal.sensitiveInput.title', "Terminal is waiting for sensitive input")), + new MarkdownString(localize('runInTerminal.sensitiveInput.message', "The terminal command appears to be prompting for a password or other sensitive value. Focus the terminal to type it directly — secrets must not be sent through chat.")), + '', + localize('runInTerminal.sensitiveInput.focus', "Focus Terminal"), + localize('runInTerminal.sensitiveInput.cancel', "Cancel Command"), + async () => { + pending = undefined; + part.hide(); + this._terminalService.setActiveInstance(terminalInstance); + await this._terminalService.revealTerminal(terminalInstance, true); + terminalInstance.focus(); + return ElicitationState.Accepted; + }, + async () => { + pending = undefined; + part.hide(); + cancelExecution(); + return ElicitationState.Rejected; + }, + undefined, + undefined, + () => { pending = undefined; }, + undefined, + ); + + pending = part; + chatModel.acceptResponseProgress(request, part); + store.add({ dispose: () => part.hide() }); + })); + + return store; + } + private _acceptAutomaticUnsandboxRetryToolInvocationUpdate(sessionResource: URI | undefined, toolCallId: string, toolSpecificData: IChatTerminalToolInvocationData, isComplete: boolean, toolResultMessage?: string | IMarkdownString): void { const chatModel = sessionResource && this._chatService.getSession(sessionResource); if (!(chatModel instanceof ChatModel)) { @@ -1434,6 +1506,19 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { // Also race on output monitor input-needed so that interactive prompts // return output to the agent early instead of waiting for timeout. const raceCleanup = new DisposableStore(); + // Sensitive prompts (passwords, OTPs, …) must never reach the model. + // Show a confirmation dialog that focuses the terminal so the user + // types the secret directly. The race is *not* resolved by sensitive + // prompts — the running command keeps waiting for user input until + // it either completes or the user cancels it from the dialog. + if (outputMonitor) { + raceCleanup.add(this._registerSensitiveInputElicitation( + chatSessionResource, + toolTerminal.instance, + outputMonitor, + () => executeCancellation.cancel(), + )); + } const raceCandidates: Promise<{ type: 'completed'; result: ITerminalExecuteStrategyResult } | { type: 'background' } | { type: 'timeout' } | { type: 'inputNeeded' }>[] = [ executionPromise.then(result => ({ type: 'completed' as const, result })), continueInBackgroundPromise.then(() => ({ type: 'background' as const })), @@ -2259,6 +2344,20 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { store.add(outputMonitor); outputMonitor.continueMonitoringAsync(bgCts.token); + // Sensitive prompts (passwords, OTPs, …) detected while the command runs + // in the background must not generate a steering message — the secret + // must never reach the model. Show a confirmation dialog that focuses + // the terminal so the user can type the secret directly. + store.add(this._registerSensitiveInputElicitation( + chatSessionResource, + terminalInstance, + outputMonitor, + () => { + const execution = RunInTerminalTool._activeExecutions.get(termId); + execution?.dispose(); + }, + )); + // When the output monitor detects the terminal is waiting for input, // send a steering message so the agent handles it via send_to_terminal. store.add(outputMonitor.onDidDetectInputNeeded(() => { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts index 3781814f337a6..af9dc182dcb97 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import { detectsGenericPressAnyKeyPattern, detectsHighConfidenceInputPattern, detectsInputRequiredPattern, detectsNonInteractiveHelpPattern, detectsVSCodeTaskFinishMessage, getLastLine, matchTerminalPromptOption, OutputMonitor } from '../../browser/tools/monitoring/outputMonitor.js'; +import { detectsGenericPressAnyKeyPattern, detectsHighConfidenceInputPattern, detectsInputRequiredPattern, detectsNonInteractiveHelpPattern, detectsSensitiveInputPrompt, detectsVSCodeTaskFinishMessage, getLastLine, matchTerminalPromptOption, OutputMonitor } from '../../browser/tools/monitoring/outputMonitor.js'; import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IExecution, IPollingResult, OutputMonitorState } from '../../browser/tools/monitoring/types.js'; @@ -233,6 +233,41 @@ suite('OutputMonitor', () => { }); }); + test('sensitive prompt fires onDidDetectSensitiveInputNeeded and not onDidDetectInputNeeded', async () => { + return runWithFakedTimers({}, async () => { + execution.getOutput = () => 'Password: '; + monitor = store.add(instantiationService.createInstance(OutputMonitor, execution, undefined, createTestContext('1'), cts.token, 'test command')); + + let inputNeededFired = false; + let sensitiveFired = false; + store.add(monitor.onDidDetectInputNeeded(() => { inputNeededFired = true; })); + store.add(monitor.onDidDetectSensitiveInputNeeded(() => { sensitiveFired = true; })); + + await Event.toPromise(monitor.onDidFinishCommand); + + assert.strictEqual(sensitiveFired, true, 'onDidDetectSensitiveInputNeeded should fire for sensitive prompts'); + assert.strictEqual(inputNeededFired, false, 'onDidDetectInputNeeded must not fire for sensitive prompts so the secret is not routed to the agent'); + }); + }); + + test('detectsSensitiveInputPrompt matches common secret prompts', () => { + assert.strictEqual(detectsSensitiveInputPrompt('Password: '), true); + assert.strictEqual(detectsSensitiveInputPrompt('[sudo] password for jdoe: '), true); + assert.strictEqual(detectsSensitiveInputPrompt('Passphrase for key /Users/foo/.ssh/id_rsa: '), true); + assert.strictEqual(detectsSensitiveInputPrompt('Enter your API key: '), true); + assert.strictEqual(detectsSensitiveInputPrompt('Token: '), true); + assert.strictEqual(detectsSensitiveInputPrompt('Verification code: '), true); + assert.strictEqual(detectsSensitiveInputPrompt('Enter OTP: '), true); + assert.strictEqual(detectsSensitiveInputPrompt('One-time code: '), true); + assert.strictEqual(detectsSensitiveInputPrompt('Enter your 2FA code: '), true); + assert.strictEqual(detectsSensitiveInputPrompt('Enter MFA code: '), true); + + assert.strictEqual(detectsSensitiveInputPrompt('Continue? (y/n) '), false); + assert.strictEqual(detectsSensitiveInputPrompt('Press any key to continue...'), false); + assert.strictEqual(detectsSensitiveInputPrompt('Enter your name: '), false); + assert.strictEqual(detectsSensitiveInputPrompt('package name: (test_npm_init) '), false); + }); + test('extended timeout with isActive fires onDidDetectInputNeeded', async () => { return runWithFakedTimers({}, async () => { // Simulate a process that stays active with output that doesn't diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index 4e496253093d1..fe09f07725ad3 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -1980,6 +1980,7 @@ suite('RunInTerminalTool', () => { const outputMonitor = { onDidDetectInputNeeded: inputNeededEmitter.event, + onDidDetectSensitiveInputNeeded: Event.None, continueMonitoringAsync: () => { }, dispose: () => { }, } as unknown as { onDidDetectInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }; @@ -2021,6 +2022,7 @@ suite('RunInTerminalTool', () => { const outputMonitor = { onDidDetectInputNeeded: inputNeededEmitter.event, + onDidDetectSensitiveInputNeeded: Event.None, continueMonitoringAsync: () => { }, dispose: () => { }, } as unknown as { onDidDetectInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }; @@ -2066,6 +2068,7 @@ suite('RunInTerminalTool', () => { const outputMonitor = { onDidDetectInputNeeded: inputNeededEmitter.event, + onDidDetectSensitiveInputNeeded: Event.None, continueMonitoringAsync: () => { }, dispose: () => { }, } as unknown as { onDidDetectInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }; From de21f557d21cdb5067c477dc3602876356a1130d Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 6 May 2026 16:15:09 -0400 Subject: [PATCH 02/11] Chat terminal: resolve race on sensitive input so tool call doesn't hang Previously when the OutputMonitor detected a password/passphrase/secret prompt in foreground mode, it fired onDidDetectSensitiveInputNeeded and showed an elicitation dialog. The dialog's lifetime was tied to the race cleanup, which auto-hid it when the race resolved, AND the race itself never resolved on the sensitive event. The result: the agent's tool call sat indefinitely waiting for the user to focus the terminal and type their secret, with no way to surrender its turn. Changes: - Add a 'sensitiveInputNeeded' race candidate in the foreground path so the tool returns immediately when a sensitive prompt is detected. - Track didSensitiveInputNeeded separately and emit a dedicated steering note that tells the model the user has been shown a focus-terminal dialog and to stop / end its turn (no send_to_terminal, no ask_questions, no guessing the secret). - Stop registering a part.hide() disposable in the elicitation store so the dialog persists after the tool call returns and the user can still interact with it. --- .../browser/tools/runInTerminalTool.ts | 41 ++++++++++++++++--- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index b8ebac62dffb4..b3fbb918412ff 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -1150,7 +1150,10 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { pending = part; chatModel.acceptResponseProgress(request, part); - store.add({ dispose: () => part.hide() }); + // Intentionally do NOT register a disposable that hides the part on store + // dispose: the elicitation must persist past the tool call returning so the + // user can still focus the terminal (and type their secret) after the + // agent has surrendered its turn. The part hides itself on accept/reject. })); return store; @@ -1343,6 +1346,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { let altBufferResult: IToolResult | undefined; let didTimeout = false; let didInputNeeded = false; + let didSensitiveInputNeeded = false; // Covers both terminals that start as background (persistentSession) and // foreground terminals that later move to background (timeout/continue-in-bg). let isBackgroundExecution = executionOptions.persistentSession; @@ -1508,9 +1512,12 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const raceCleanup = new DisposableStore(); // Sensitive prompts (passwords, OTPs, …) must never reach the model. // Show a confirmation dialog that focuses the terminal so the user - // types the secret directly. The race is *not* resolved by sensitive - // prompts — the running command keeps waiting for user input until - // it either completes or the user cancels it from the dialog. + // types the secret directly, and resolve the race so the agent's + // tool call returns immediately — otherwise it would hang waiting + // for executionPromise (which can't complete until the user types + // the secret in the focused terminal). The elicitation lives on the + // chat model and persists past the race resolving so the user can + // still interact with it. if (outputMonitor) { raceCleanup.add(this._registerSensitiveInputElicitation( chatSessionResource, @@ -1519,7 +1526,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { () => executeCancellation.cancel(), )); } - const raceCandidates: Promise<{ type: 'completed'; result: ITerminalExecuteStrategyResult } | { type: 'background' } | { type: 'timeout' } | { type: 'inputNeeded' }>[] = [ + const raceCandidates: Promise<{ type: 'completed'; result: ITerminalExecuteStrategyResult } | { type: 'background' } | { type: 'timeout' } | { type: 'inputNeeded' } | { type: 'sensitiveInputNeeded' }>[] = [ executionPromise.then(result => ({ type: 'completed' as const, result })), continueInBackgroundPromise.then(() => ({ type: 'background' as const })), new Promise<{ type: 'inputNeeded' }>(resolve => { @@ -1528,6 +1535,13 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { raceCleanup.add(outputMonitor.onDidDetectInputNeeded(() => resolve({ type: 'inputNeeded' as const }))); } }); + }), + new Promise<{ type: 'sensitiveInputNeeded' }>(resolve => { + startMarkerPromise.then(() => { + if (outputMonitor && !raceCleanup.isDisposed) { + raceCleanup.add(outputMonitor.onDidDetectSensitiveInputNeeded(() => resolve({ type: 'sensitiveInputNeeded' as const }))); + } + }); }) ]; if (timeoutRacePromise) { @@ -1550,6 +1564,19 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const idleOutput = execution.getOutput(); outputLineCount = idleOutput ? count(idleOutput.trim(), '\n') + 1 : 0; terminalResult = idleOutput ?? ''; + } else if (raceResult.type === 'sensitiveInputNeeded') { + // Output monitor detected a password / passphrase / secret prompt. + // The user has been shown an elicitation that focuses the terminal + // so they can type the secret directly. We return control to the + // agent immediately so its tool call doesn't hang waiting for + // the secret to be typed. The terminal stays foreground. + this._logService.debug(`RunInTerminalTool: Output monitor detected sensitive input needed in foreground terminal, returning output to agent`); + error = 'sensitiveInputNeeded'; + didInputNeeded = true; + didSensitiveInputNeeded = true; + const idleOutput = execution.getOutput(); + outputLineCount = idleOutput ? count(idleOutput.trim(), '\n') + 1 : 0; + terminalResult = idleOutput ?? ''; } else if (raceResult.type === 'background') { // Moved to background - execution continues running, just return current output this._logService.debug(`RunInTerminalTool: Continue in background triggered, returning output collected so far`); @@ -1816,7 +1843,9 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { resultText.push(`Note: This terminal execution was moved to the background using the ID ${termId}\n`); } } - if (didInputNeeded) { + if (didSensitiveInputNeeded) { + resultText.push(`Note: The command in terminal ID ${termId} appears to be waiting for a password, passphrase, or other secret. The user has already been shown a confirmation dialog to focus the terminal and type the secret directly. Do NOT call ${TerminalToolId.SendToTerminal}, do NOT call vscode_askQuestions, and do NOT guess the value — stop and let the user enter the secret. After acknowledging this, end your turn.\n\n`); + } else if (didInputNeeded) { resultText.push(`Note: The command is running in terminal ID ${termId} and may be waiting for input.\n${this._buildInputNeededSteeringText(chatSessionResource, termId, /*mentionTimeout*/ false)}\n\n`); } else if (didTimeout && timeoutValue !== undefined && timeoutValue > 0) { const notificationHint = shouldSendNotifications From 4b2624aef6fd2719f901cf8245ad3d5a0d73549f Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 6 May 2026 16:24:02 -0400 Subject: [PATCH 03/11] Chat terminal: race terminal interaction vs. agent return on sensitive prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, when the OutputMonitor detected a sensitive prompt the tool would immediately resolve the race as 'sensitiveInputNeeded' and hand control back to the agent. That worked but gave the user no opportunity to actually engage with the terminal — the tool always returned to the model even when the user was about to focus the terminal and type their secret. Instead, treat the sensitive prompt as a hint, not a verdict: - On detection, start a 45s grace timer and listen for terminal data. Every onData event (the user typing, shell echo) resets the timer. - If executionPromise resolves during the grace window — i.e. the user successfully entered the secret and the command finished — the outer Promise.race takes the 'completed' branch and we never resolve the sensitive promise. - Only if the grace expires with no engagement do we resolve as 'sensitiveInputNeeded' so the agent gets control back rather than hanging forever. The Cancel button in the elicitation still cancels execution immediately, which causes executionPromise to reject and the outer race takes the 'completed' (cancelled) branch. --- .../browser/tools/runInTerminalTool.ts | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index b3fbb918412ff..e6b8fe783f633 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -1538,9 +1538,54 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { }), new Promise<{ type: 'sensitiveInputNeeded' }>(resolve => { startMarkerPromise.then(() => { - if (outputMonitor && !raceCleanup.isDisposed) { - raceCleanup.add(outputMonitor.onDidDetectSensitiveInputNeeded(() => resolve({ type: 'sensitiveInputNeeded' as const }))); + if (!outputMonitor || raceCleanup.isDisposed) { + return; } + // When a sensitive prompt is detected we don't immediately + // surrender the tool call to the agent — we first give the + // user a chance to actually engage with the terminal (focus + // it and type their secret). If executionPromise resolves + // during that window the outer Promise.race takes the + // 'completed' branch and we never fire here. If the user + // doesn't engage we fall through to 'sensitiveInputNeeded' + // so the agent gets control back rather than hanging + // forever. + // + // "Engagement" is measured as terminal data activity (user + // keystrokes / shell echo): every onData event resets the + // grace timer. This means once the user starts typing we + // keep waiting, but if they ignore the dialog we eventually + // hand control back. + const SENSITIVE_GRACE_MS = 45_000; + let pendingTimer: { dispose(): void } | undefined; + const armTimer = () => { + pendingTimer?.dispose(); + const cancel = new CancellationTokenSource(); + pendingTimer = { + dispose: () => { + cancel.cancel(); + cancel.dispose(); + }, + }; + timeout(SENSITIVE_GRACE_MS, cancel.token).then(() => { + if (!cancel.token.isCancellationRequested) { + resolve({ type: 'sensitiveInputNeeded' as const }); + } + }).catch(() => { /* cancelled */ }); + }; + raceCleanup.add(outputMonitor.onDidDetectSensitiveInputNeeded(() => { + if (pendingTimer) { + return; + } + armTimer(); + // Reset the grace timer on terminal activity so the + // user can take their time typing the secret. + raceCleanup.add(toolTerminal.instance.onData(() => armTimer())); + raceCleanup.add(toDisposable(() => { + pendingTimer?.dispose(); + pendingTimer = undefined; + })); + })); }); }) ]; From 8de5ab1be79be53a014e2e1dc843f2530879513f Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 6 May 2026 16:25:49 -0400 Subject: [PATCH 04/11] Chat terminal: keep waiting on sensitive prompts instead of timing out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the 45s grace + sensitiveInputNeeded race branch added in the prior commit. The dialog already gives the user two unambiguous options: - Focus Terminal: focuses the terminal so the user types the secret; when the command finishes, executionPromise wins the race and we report 'completed' as normal. - Cancel Command: cancels execution, which causes executionPromise to resolve and the race takes the 'completed' (cancelled) branch. Either path resolves the race naturally — no need for a separate timer or fallback. This matches the user's expectation: while a sensitive prompt is up, the tool should wait for the user to answer, not start its own clock and surrender to the agent. --- .../browser/tools/runInTerminalTool.ts | 84 ++----------------- 1 file changed, 8 insertions(+), 76 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index e6b8fe783f633..5e6d6a4898b36 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -1346,7 +1346,6 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { let altBufferResult: IToolResult | undefined; let didTimeout = false; let didInputNeeded = false; - let didSensitiveInputNeeded = false; // Covers both terminals that start as background (persistentSession) and // foreground terminals that later move to background (timeout/continue-in-bg). let isBackgroundExecution = executionOptions.persistentSession; @@ -1512,12 +1511,12 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const raceCleanup = new DisposableStore(); // Sensitive prompts (passwords, OTPs, …) must never reach the model. // Show a confirmation dialog that focuses the terminal so the user - // types the secret directly, and resolve the race so the agent's - // tool call returns immediately — otherwise it would hang waiting - // for executionPromise (which can't complete until the user types - // the secret in the focused terminal). The elicitation lives on the - // chat model and persists past the race resolving so the user can - // still interact with it. + // types the secret directly. The race is *not* resolved by sensitive + // prompts — the running command keeps waiting for user input until + // either it completes (executionPromise wins) or the user cancels + // it from the dialog (which cancels execution and also makes + // executionPromise resolve). This means we never hand a secret + // prompt back to the model; the user is always in control. if (outputMonitor) { raceCleanup.add(this._registerSensitiveInputElicitation( chatSessionResource, @@ -1526,7 +1525,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { () => executeCancellation.cancel(), )); } - const raceCandidates: Promise<{ type: 'completed'; result: ITerminalExecuteStrategyResult } | { type: 'background' } | { type: 'timeout' } | { type: 'inputNeeded' } | { type: 'sensitiveInputNeeded' }>[] = [ + const raceCandidates: Promise<{ type: 'completed'; result: ITerminalExecuteStrategyResult } | { type: 'background' } | { type: 'timeout' } | { type: 'inputNeeded' }>[] = [ executionPromise.then(result => ({ type: 'completed' as const, result })), continueInBackgroundPromise.then(() => ({ type: 'background' as const })), new Promise<{ type: 'inputNeeded' }>(resolve => { @@ -1535,58 +1534,6 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { raceCleanup.add(outputMonitor.onDidDetectInputNeeded(() => resolve({ type: 'inputNeeded' as const }))); } }); - }), - new Promise<{ type: 'sensitiveInputNeeded' }>(resolve => { - startMarkerPromise.then(() => { - if (!outputMonitor || raceCleanup.isDisposed) { - return; - } - // When a sensitive prompt is detected we don't immediately - // surrender the tool call to the agent — we first give the - // user a chance to actually engage with the terminal (focus - // it and type their secret). If executionPromise resolves - // during that window the outer Promise.race takes the - // 'completed' branch and we never fire here. If the user - // doesn't engage we fall through to 'sensitiveInputNeeded' - // so the agent gets control back rather than hanging - // forever. - // - // "Engagement" is measured as terminal data activity (user - // keystrokes / shell echo): every onData event resets the - // grace timer. This means once the user starts typing we - // keep waiting, but if they ignore the dialog we eventually - // hand control back. - const SENSITIVE_GRACE_MS = 45_000; - let pendingTimer: { dispose(): void } | undefined; - const armTimer = () => { - pendingTimer?.dispose(); - const cancel = new CancellationTokenSource(); - pendingTimer = { - dispose: () => { - cancel.cancel(); - cancel.dispose(); - }, - }; - timeout(SENSITIVE_GRACE_MS, cancel.token).then(() => { - if (!cancel.token.isCancellationRequested) { - resolve({ type: 'sensitiveInputNeeded' as const }); - } - }).catch(() => { /* cancelled */ }); - }; - raceCleanup.add(outputMonitor.onDidDetectSensitiveInputNeeded(() => { - if (pendingTimer) { - return; - } - armTimer(); - // Reset the grace timer on terminal activity so the - // user can take their time typing the secret. - raceCleanup.add(toolTerminal.instance.onData(() => armTimer())); - raceCleanup.add(toDisposable(() => { - pendingTimer?.dispose(); - pendingTimer = undefined; - })); - })); - }); }) ]; if (timeoutRacePromise) { @@ -1609,19 +1556,6 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const idleOutput = execution.getOutput(); outputLineCount = idleOutput ? count(idleOutput.trim(), '\n') + 1 : 0; terminalResult = idleOutput ?? ''; - } else if (raceResult.type === 'sensitiveInputNeeded') { - // Output monitor detected a password / passphrase / secret prompt. - // The user has been shown an elicitation that focuses the terminal - // so they can type the secret directly. We return control to the - // agent immediately so its tool call doesn't hang waiting for - // the secret to be typed. The terminal stays foreground. - this._logService.debug(`RunInTerminalTool: Output monitor detected sensitive input needed in foreground terminal, returning output to agent`); - error = 'sensitiveInputNeeded'; - didInputNeeded = true; - didSensitiveInputNeeded = true; - const idleOutput = execution.getOutput(); - outputLineCount = idleOutput ? count(idleOutput.trim(), '\n') + 1 : 0; - terminalResult = idleOutput ?? ''; } else if (raceResult.type === 'background') { // Moved to background - execution continues running, just return current output this._logService.debug(`RunInTerminalTool: Continue in background triggered, returning output collected so far`); @@ -1888,9 +1822,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { resultText.push(`Note: This terminal execution was moved to the background using the ID ${termId}\n`); } } - if (didSensitiveInputNeeded) { - resultText.push(`Note: The command in terminal ID ${termId} appears to be waiting for a password, passphrase, or other secret. The user has already been shown a confirmation dialog to focus the terminal and type the secret directly. Do NOT call ${TerminalToolId.SendToTerminal}, do NOT call vscode_askQuestions, and do NOT guess the value — stop and let the user enter the secret. After acknowledging this, end your turn.\n\n`); - } else if (didInputNeeded) { + if (didInputNeeded) { resultText.push(`Note: The command is running in terminal ID ${termId} and may be waiting for input.\n${this._buildInputNeededSteeringText(chatSessionResource, termId, /*mentionTimeout*/ false)}\n\n`); } else if (didTimeout && timeoutValue !== undefined && timeoutValue > 0) { const notificationHint = shouldSendNotifications From 7fc992dbf1a6781786b94c8b77edd7c76a8001ac Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 6 May 2026 16:27:30 -0400 Subject: [PATCH 05/11] Chat terminal: defer sensitive elicitation registration until start marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit outputMonitor is created lazily inside execution.strategy.onDidCreateStartMarker, which fires after the foreground race is set up. The previous synchronous 'if (outputMonitor)' check ran before the start marker had fired, so outputMonitor was undefined and the elicitation listener was never attached — meaning sudo / password / passphrase prompts produced no dialog. Wrap the registration in startMarkerPromise.then(...) the same way the existing onDidDetectInputNeeded listener does. --- .../browser/tools/runInTerminalTool.ts | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 5e6d6a4898b36..b1b2574dc71f0 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -1517,14 +1517,21 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { // it from the dialog (which cancels execution and also makes // executionPromise resolve). This means we never hand a secret // prompt back to the model; the user is always in control. - if (outputMonitor) { - raceCleanup.add(this._registerSensitiveInputElicitation( - chatSessionResource, - toolTerminal.instance, - outputMonitor, - () => executeCancellation.cancel(), - )); - } + // + // outputMonitor is created later inside `onDidCreateStartMarker`, + // so we must wait on `startMarkerPromise` before registering the + // listener — otherwise outputMonitor is still undefined here and + // the sensitive event never reaches us. + startMarkerPromise.then(() => { + if (outputMonitor && !raceCleanup.isDisposed) { + raceCleanup.add(this._registerSensitiveInputElicitation( + chatSessionResource, + toolTerminal.instance, + outputMonitor, + () => executeCancellation.cancel(), + )); + } + }); const raceCandidates: Promise<{ type: 'completed'; result: ITerminalExecuteStrategyResult } | { type: 'background' } | { type: 'timeout' } | { type: 'inputNeeded' }>[] = [ executionPromise.then(result => ({ type: 'completed' as const, result })), continueInBackgroundPromise.then(() => ({ type: 'background' as const })), From 0d8d130f77b9f0bd270de92df40e4427c13ef783 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 6 May 2026 16:33:43 -0400 Subject: [PATCH 06/11] Chat terminal: auto-cancel sensitive prompts in autopilot / auto-approve In auto-approve / autopilot mode there is no human in the loop to type a secret into the focused terminal, so the prior 'wait for the user' behavior would just hang the command forever. Detect the auto-approve session level inside _registerSensitiveInputElicitation and: - Cancel the running command immediately. - Surface a one-shot informational chat part explaining what happened. - Set didSensitiveAutoCancelled so the tool result text includes a steering note telling the model NOT to retry the command, NOT to call send_to_terminal, and NOT to ask_questions for the secret \u2014 instead to ask the user to run the command interactively. Non-autopilot sessions still get the original Focus Terminal / Cancel Command dialog. Then update the PR description to remove the open question. --- .../browser/tools/runInTerminalTool.ts | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index b1b2574dc71f0..90efe37459aa9 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -1100,15 +1100,48 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { terminalInstance: ITerminalInstance, outputMonitor: { onDidDetectSensitiveInputNeeded: Event }, cancelExecution: () => void, + onAutoCancelled?: () => void, ): IDisposable { const store = new DisposableStore(); let pending: { hide: () => void } | undefined; + let autoCancelled = false; store.add(outputMonitor.onDidDetectSensitiveInputNeeded(() => { - if (pending) { + if (pending || autoCancelled) { return; } + const isAutoApproved = chatSessionResource && isSessionAutoApproveLevel(chatSessionResource, this._configurationService, this._chatWidgetService, this._chatService); const chatModel = chatSessionResource && this._chatService.getSession(chatSessionResource); + if (isAutoApproved) { + // Autopilot / auto-approve: there is no human in the loop to type + // the secret, so we cancel the command and surface a one-shot + // informational note. The outer race resolves via the cancelled + // executionPromise; onAutoCancelled lets the caller emit a + // dedicated steering message in the tool result. + autoCancelled = true; + if (chatModel instanceof ChatModel) { + const request = chatModel.getRequests().at(-1); + if (request) { + const infoPart = new ChatElicitationRequestPart( + new MarkdownString(localize('runInTerminal.sensitiveInput.autoCancelTitle', "Terminal command cancelled — sensitive input required")), + new MarkdownString(localize('runInTerminal.sensitiveInput.autoCancelMessage', "The terminal command was prompting for a password or other secret. In auto-approve / autopilot mode there is no way for you to type it safely, so the command has been cancelled. Run the command interactively if you need to provide a secret.")), + '', + localize('runInTerminal.sensitiveInput.dismiss', "Dismiss"), + '', + async () => { infoPart.hide(); return ElicitationState.Accepted; }, + async () => { infoPart.hide(); return ElicitationState.Rejected; }, + undefined, + undefined, + undefined, + undefined, + ); + chatModel.acceptResponseProgress(request, infoPart); + } + } + onAutoCancelled?.(); + cancelExecution(); + return; + } if (!(chatModel instanceof ChatModel)) { // No chat surface to attach to — fall back to focusing the // terminal directly so the user is at least not left blocked. @@ -1346,6 +1379,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { let altBufferResult: IToolResult | undefined; let didTimeout = false; let didInputNeeded = false; + let didSensitiveAutoCancelled = false; // Covers both terminals that start as background (persistentSession) and // foreground terminals that later move to background (timeout/continue-in-bg). let isBackgroundExecution = executionOptions.persistentSession; @@ -1529,6 +1563,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { toolTerminal.instance, outputMonitor, () => executeCancellation.cancel(), + () => { didSensitiveAutoCancelled = true; }, )); } }); @@ -1829,7 +1864,9 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { resultText.push(`Note: This terminal execution was moved to the background using the ID ${termId}\n`); } } - if (didInputNeeded) { + if (didSensitiveAutoCancelled) { + resultText.push(`Note: The command in terminal ID ${termId} was prompting for a password, passphrase, or other secret. Auto-approve / autopilot mode cannot safely supply secrets, so the command has been cancelled. Stop and tell the user to run the command interactively if they need to provide a secret — do NOT retry it, do NOT call ${TerminalToolId.SendToTerminal}, and do NOT call vscode_askQuestions for the secret.\n\n`); + } else if (didInputNeeded) { resultText.push(`Note: The command is running in terminal ID ${termId} and may be waiting for input.\n${this._buildInputNeededSteeringText(chatSessionResource, termId, /*mentionTimeout*/ false)}\n\n`); } else if (didTimeout && timeoutValue !== undefined && timeoutValue > 0) { const notificationHint = shouldSendNotifications From 649eb1e8cfb119c666d0fea94f6d48284b3cb053 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 6 May 2026 16:39:04 -0400 Subject: [PATCH 07/11] Chat terminal: in autopilot keep Focus Terminal/Cancel dialog while auto-cancelling Previously in auto-approve / autopilot we replaced the dialog with a dismiss-only info part. That removed the Focus Terminal button, so a user who happened to be watching couldn't grab the terminal and finish the command manually. Show the same dialog as the default branch in autopilot (Focus Terminal / Cancel Command), but with a message that mentions the command was auto-cancelled. Set autoCancelled / onAutoCancelled and cancelExecution() right after attaching the part so the agent's tool call still returns immediately with the steering note. The dialog persists so the user can click Focus Terminal to take over after the fact (the running command itself is gone, but they can still type into the terminal as usual). --- .../browser/tools/runInTerminalTool.ts | 51 ++++++++----------- 1 file changed, 20 insertions(+), 31 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 90efe37459aa9..6939696bd574e 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -1112,42 +1112,17 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } const isAutoApproved = chatSessionResource && isSessionAutoApproveLevel(chatSessionResource, this._configurationService, this._chatWidgetService, this._chatService); const chatModel = chatSessionResource && this._chatService.getSession(chatSessionResource); - if (isAutoApproved) { - // Autopilot / auto-approve: there is no human in the loop to type - // the secret, so we cancel the command and surface a one-shot - // informational note. The outer race resolves via the cancelled - // executionPromise; onAutoCancelled lets the caller emit a - // dedicated steering message in the tool result. - autoCancelled = true; - if (chatModel instanceof ChatModel) { - const request = chatModel.getRequests().at(-1); - if (request) { - const infoPart = new ChatElicitationRequestPart( - new MarkdownString(localize('runInTerminal.sensitiveInput.autoCancelTitle', "Terminal command cancelled — sensitive input required")), - new MarkdownString(localize('runInTerminal.sensitiveInput.autoCancelMessage', "The terminal command was prompting for a password or other secret. In auto-approve / autopilot mode there is no way for you to type it safely, so the command has been cancelled. Run the command interactively if you need to provide a secret.")), - '', - localize('runInTerminal.sensitiveInput.dismiss', "Dismiss"), - '', - async () => { infoPart.hide(); return ElicitationState.Accepted; }, - async () => { infoPart.hide(); return ElicitationState.Rejected; }, - undefined, - undefined, - undefined, - undefined, - ); - chatModel.acceptResponseProgress(request, infoPart); - } - } - onAutoCancelled?.(); - cancelExecution(); - return; - } if (!(chatModel instanceof ChatModel)) { // No chat surface to attach to — fall back to focusing the // terminal directly so the user is at least not left blocked. this._terminalService.setActiveInstance(terminalInstance); this._terminalService.revealTerminal(terminalInstance, true).catch(() => { }); terminalInstance.focus(); + if (isAutoApproved) { + autoCancelled = true; + onAutoCancelled?.(); + cancelExecution(); + } return; } const request = chatModel.getRequests().at(-1); @@ -1157,7 +1132,9 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const part = new ChatElicitationRequestPart( new MarkdownString(localize('runInTerminal.sensitiveInput.title', "Terminal is waiting for sensitive input")), - new MarkdownString(localize('runInTerminal.sensitiveInput.message', "The terminal command appears to be prompting for a password or other sensitive value. Focus the terminal to type it directly — secrets must not be sent through chat.")), + new MarkdownString(isAutoApproved + ? localize('runInTerminal.sensitiveInput.autoCancelMessage', "The terminal command appears to be prompting for a password or other sensitive value. Auto-approve / autopilot mode cannot safely supply secrets, so the command has been cancelled. Focus the terminal to type the value yourself if you want to continue.") + : localize('runInTerminal.sensitiveInput.message', "The terminal command appears to be prompting for a password or other sensitive value. Focus the terminal to type it directly — secrets must not be sent through chat.")), '', localize('runInTerminal.sensitiveInput.focus', "Focus Terminal"), localize('runInTerminal.sensitiveInput.cancel', "Cancel Command"), @@ -1187,6 +1164,18 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { // dispose: the elicitation must persist past the tool call returning so the // user can still focus the terminal (and type their secret) after the // agent has surrendered its turn. The part hides itself on accept/reject. + + if (isAutoApproved) { + // Autopilot / auto-approve: keep the same Focus Terminal / Cancel + // dialog so a watching user can still grab the terminal, but + // also cancel execution immediately and notify the caller — the + // agent must not be left waiting on a secret that can never be + // auto-supplied. The dialog persists so the user can still take + // over manually if they're around. + autoCancelled = true; + onAutoCancelled?.(); + cancelExecution(); + } })); return store; From ac338ef98b9984b650f0dc2fef2de4abfdc587c4 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 6 May 2026 16:42:58 -0400 Subject: [PATCH 08/11] Chat terminal: in autopilot, drop Focus Terminal button and tell agent user is unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the autopilot tool call returns, the terminal can't reliably be focused (it may be cleaned up), so a Focus Terminal button there is misleading — clicking it does nothing visible. Replace the Focus/Cancel dialog with a one-shot info part (Dismiss only) and update the steering note in the tool result to explicitly tell the agent the user is unavailable, the command has been cancelled, and to ask the user to run the command interactively when they are available. Default / Ask sessions are unchanged: they still get the Focus Terminal / Cancel Command dialog, since in those sessions the terminal stays alive after the tool call returns and Focus Terminal actually works. --- .../browser/tools/runInTerminalTool.ts | 54 +++++++++++-------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 6939696bd574e..fae29fd3ba119 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -1112,17 +1112,43 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } const isAutoApproved = chatSessionResource && isSessionAutoApproveLevel(chatSessionResource, this._configurationService, this._chatWidgetService, this._chatService); const chatModel = chatSessionResource && this._chatService.getSession(chatSessionResource); + if (isAutoApproved) { + // Autopilot / auto-approve: there is no human in the loop to type + // the secret, and the terminal can't reliably be focused after the + // tool returns (it may be cleaned up). Cancel the command and let + // the caller emit a steering note that tells the agent the user + // is unavailable. We also surface a one-shot informational chat + // part so a watching user can see what happened. + autoCancelled = true; + if (chatModel instanceof ChatModel) { + const request = chatModel.getRequests().at(-1); + if (request) { + const infoPart = new ChatElicitationRequestPart( + new MarkdownString(localize('runInTerminal.sensitiveInput.autoCancelTitle', "Terminal command cancelled — sensitive input required")), + new MarkdownString(localize('runInTerminal.sensitiveInput.autoCancelMessage', "The terminal command was prompting for a password or other secret. Auto-approve / autopilot mode cannot safely supply secrets, so the command was cancelled. Run the command interactively if you want to provide the secret.")), + '', + localize('runInTerminal.sensitiveInput.dismiss', "Dismiss"), + '', + async () => { infoPart.hide(); return ElicitationState.Accepted; }, + async () => { infoPart.hide(); return ElicitationState.Rejected; }, + undefined, + undefined, + undefined, + undefined, + ); + chatModel.acceptResponseProgress(request, infoPart); + } + } + onAutoCancelled?.(); + cancelExecution(); + return; + } if (!(chatModel instanceof ChatModel)) { // No chat surface to attach to — fall back to focusing the // terminal directly so the user is at least not left blocked. this._terminalService.setActiveInstance(terminalInstance); this._terminalService.revealTerminal(terminalInstance, true).catch(() => { }); terminalInstance.focus(); - if (isAutoApproved) { - autoCancelled = true; - onAutoCancelled?.(); - cancelExecution(); - } return; } const request = chatModel.getRequests().at(-1); @@ -1132,9 +1158,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const part = new ChatElicitationRequestPart( new MarkdownString(localize('runInTerminal.sensitiveInput.title', "Terminal is waiting for sensitive input")), - new MarkdownString(isAutoApproved - ? localize('runInTerminal.sensitiveInput.autoCancelMessage', "The terminal command appears to be prompting for a password or other sensitive value. Auto-approve / autopilot mode cannot safely supply secrets, so the command has been cancelled. Focus the terminal to type the value yourself if you want to continue.") - : localize('runInTerminal.sensitiveInput.message', "The terminal command appears to be prompting for a password or other sensitive value. Focus the terminal to type it directly — secrets must not be sent through chat.")), + new MarkdownString(localize('runInTerminal.sensitiveInput.message', "The terminal command appears to be prompting for a password or other sensitive value. Focus the terminal to type it directly — secrets must not be sent through chat.")), '', localize('runInTerminal.sensitiveInput.focus', "Focus Terminal"), localize('runInTerminal.sensitiveInput.cancel', "Cancel Command"), @@ -1164,18 +1188,6 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { // dispose: the elicitation must persist past the tool call returning so the // user can still focus the terminal (and type their secret) after the // agent has surrendered its turn. The part hides itself on accept/reject. - - if (isAutoApproved) { - // Autopilot / auto-approve: keep the same Focus Terminal / Cancel - // dialog so a watching user can still grab the terminal, but - // also cancel execution immediately and notify the caller — the - // agent must not be left waiting on a secret that can never be - // auto-supplied. The dialog persists so the user can still take - // over manually if they're around. - autoCancelled = true; - onAutoCancelled?.(); - cancelExecution(); - } })); return store; @@ -1854,7 +1866,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } } if (didSensitiveAutoCancelled) { - resultText.push(`Note: The command in terminal ID ${termId} was prompting for a password, passphrase, or other secret. Auto-approve / autopilot mode cannot safely supply secrets, so the command has been cancelled. Stop and tell the user to run the command interactively if they need to provide a secret — do NOT retry it, do NOT call ${TerminalToolId.SendToTerminal}, and do NOT call vscode_askQuestions for the secret.\n\n`); + resultText.push(`Note: The command in terminal ID ${termId} was prompting for a password, passphrase, or other secret. The user is unavailable (auto-approve / autopilot mode is on, so no human can focus the terminal to type a secret) and the command has been cancelled. Stop, do NOT retry the command, do NOT call ${TerminalToolId.SendToTerminal}, and do NOT call vscode_askQuestions for the secret. Tell the user to run the command interactively when they are available.\n\n`); } else if (didInputNeeded) { resultText.push(`Note: The command is running in terminal ID ${termId} and may be waiting for input.\n${this._buildInputNeededSteeringText(chatSessionResource, termId, /*mentionTimeout*/ false)}\n\n`); } else if (didTimeout && timeoutValue !== undefined && timeoutValue > 0) { From 3d57bb83fe1576b9727a4623d52c2cccb4d21d34 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 6 May 2026 16:46:57 -0400 Subject: [PATCH 09/11] =?UTF-8?q?Chat=20terminal:=20drop=20autopilot=20inf?= =?UTF-8?q?o=20part=20=E2=80=94=20agent=20response=20already=20surfaces=20?= =?UTF-8?q?cancel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In autopilot there is no human to act on a chat elicitation, and the agent's own reply (driven by the steering note in the tool result) already tells the user the command was cancelled and to run it interactively. Skip the redundant info part and just cancel + steer. --- .../browser/tools/runInTerminalTool.ts | 31 ++++--------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index fae29fd3ba119..31e58d04e9023 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -1113,32 +1113,13 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const isAutoApproved = chatSessionResource && isSessionAutoApproveLevel(chatSessionResource, this._configurationService, this._chatWidgetService, this._chatService); const chatModel = chatSessionResource && this._chatService.getSession(chatSessionResource); if (isAutoApproved) { - // Autopilot / auto-approve: there is no human in the loop to type - // the secret, and the terminal can't reliably be focused after the - // tool returns (it may be cleaned up). Cancel the command and let - // the caller emit a steering note that tells the agent the user - // is unavailable. We also surface a one-shot informational chat - // part so a watching user can see what happened. + // Autopilot / auto-approve: no human is in the loop to type the + // secret, and the terminal can't reliably be focused after the + // tool returns. Cancel the command and let the caller emit a + // steering note that tells the agent the user is unavailable — + // the agent's own response will then surface what happened, so + // we don't need to render a separate chat part for the user. autoCancelled = true; - if (chatModel instanceof ChatModel) { - const request = chatModel.getRequests().at(-1); - if (request) { - const infoPart = new ChatElicitationRequestPart( - new MarkdownString(localize('runInTerminal.sensitiveInput.autoCancelTitle', "Terminal command cancelled — sensitive input required")), - new MarkdownString(localize('runInTerminal.sensitiveInput.autoCancelMessage', "The terminal command was prompting for a password or other secret. Auto-approve / autopilot mode cannot safely supply secrets, so the command was cancelled. Run the command interactively if you want to provide the secret.")), - '', - localize('runInTerminal.sensitiveInput.dismiss', "Dismiss"), - '', - async () => { infoPart.hide(); return ElicitationState.Accepted; }, - async () => { infoPart.hide(); return ElicitationState.Rejected; }, - undefined, - undefined, - undefined, - undefined, - ); - chatModel.acceptResponseProgress(request, infoPart); - } - } onAutoCancelled?.(); cancelExecution(); return; From 6570739955605fc8c782c7a98f3d7a4b395d6a0a Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 6 May 2026 16:49:29 -0400 Subject: [PATCH 10/11] Chat terminal: restore autopilot dismiss-only info part for visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts part of the previous commit. Without any UI affordance, when autopilot cancels a sensitive prompt the user just sees the command stop with no explanation — looks like a bug. Bring back the dismiss-only info chat part so the user always sees what happened, even if the agent doesn't follow up with a message. --- .../browser/tools/runInTerminalTool.ts | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 31e58d04e9023..4f318d98d4e61 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -1116,10 +1116,30 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { // Autopilot / auto-approve: no human is in the loop to type the // secret, and the terminal can't reliably be focused after the // tool returns. Cancel the command and let the caller emit a - // steering note that tells the agent the user is unavailable — - // the agent's own response will then surface what happened, so - // we don't need to render a separate chat part for the user. + // steering note that tells the agent the user is unavailable. + // We also surface a small dismiss-only chat part so the user + // can see what happened even if the agent doesn't follow up + // with a message of its own. autoCancelled = true; + if (chatModel instanceof ChatModel) { + const request = chatModel.getRequests().at(-1); + if (request) { + const infoPart = new ChatElicitationRequestPart( + new MarkdownString(localize('runInTerminal.sensitiveInput.autoCancelTitle', "Terminal command cancelled — sensitive input required")), + new MarkdownString(localize('runInTerminal.sensitiveInput.autoCancelMessage', "The terminal command was prompting for a password or other secret. Auto-approve / autopilot mode cannot safely supply secrets, so the command was cancelled. Run the command interactively if you want to provide the secret.")), + '', + localize('runInTerminal.sensitiveInput.dismiss', "Dismiss"), + '', + async () => { infoPart.hide(); return ElicitationState.Accepted; }, + async () => { infoPart.hide(); return ElicitationState.Rejected; }, + undefined, + undefined, + undefined, + undefined, + ); + chatModel.acceptResponseProgress(request, infoPart); + } + } onAutoCancelled?.(); cancelExecution(); return; From 865acc0f1382efe9f3cc30e89df36035223d82c8 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 6 May 2026 16:52:11 -0400 Subject: [PATCH 11/11] Address PR feedback: harden sensitive-input accept handler + tighten test stub types - Wrap the Focus Terminal accept handler's reveal/focus sequence in a try/catch so a rejection from revealTerminal can't leave the elicitation in a bad state. Still returns Accepted. - Update the runInTerminalTool.test.ts outputMonitor stub type casts to include onDidDetectSensitiveInputNeeded so future refactors that drop the field fail at compile time, not runtime. --- .../browser/tools/runInTerminalTool.ts | 10 +++++++--- .../test/electron-browser/runInTerminalTool.test.ts | 12 ++++++------ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 4f318d98d4e61..4d458c25718f9 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -1166,9 +1166,13 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { async () => { pending = undefined; part.hide(); - this._terminalService.setActiveInstance(terminalInstance); - await this._terminalService.revealTerminal(terminalInstance, true); - terminalInstance.focus(); + try { + this._terminalService.setActiveInstance(terminalInstance); + await this._terminalService.revealTerminal(terminalInstance, true); + terminalInstance.focus(); + } catch (err) { + this._logService.warn(`RunInTerminalTool: failed to reveal terminal for sensitive input`, err); + } return ElicitationState.Accepted; }, async () => { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index fe09f07725ad3..70d3fb3721e6b 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -1983,14 +1983,14 @@ suite('RunInTerminalTool', () => { onDidDetectSensitiveInputNeeded: Event.None, continueMonitoringAsync: () => { }, dispose: () => { }, - } as unknown as { onDidDetectInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }; + } as unknown as { onDidDetectInputNeeded: Event; onDidDetectSensitiveInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }; (runInTerminalTool.constructor as unknown as { _activeExecutions: Map })._activeExecutions.set(termId, { getOutput: () => output, }); // eslint-disable-next-line @typescript-eslint/naming-convention - (runInTerminalTool as unknown as { _registerCompletionNotification: (terminal: ITerminalInstance, termId: string, session: URI, commandName: string, outputMonitor: { onDidDetectInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }) => void }) + (runInTerminalTool as unknown as { _registerCompletionNotification: (terminal: ITerminalInstance, termId: string, session: URI, commandName: string, outputMonitor: { onDidDetectInputNeeded: Event; onDidDetectSensitiveInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }) => void }) ._registerCompletionNotification(terminalInstance, termId, sessionResource, 'npm init', outputMonitor); inputNeededEmitter.fire(); @@ -2025,7 +2025,7 @@ suite('RunInTerminalTool', () => { onDidDetectSensitiveInputNeeded: Event.None, continueMonitoringAsync: () => { }, dispose: () => { }, - } as unknown as { onDidDetectInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }; + } as unknown as { onDidDetectInputNeeded: Event; onDidDetectSensitiveInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }; (runInTerminalTool.constructor as unknown as { _activeExecutions: Map })._activeExecutions.set(termId, { getOutput: () => output, @@ -2036,7 +2036,7 @@ suite('RunInTerminalTool', () => { // monitor's first re-detection of the same prompt must not fire a steering // message that would yield the agent's in-flight `send_to_terminal` reply. // eslint-disable-next-line @typescript-eslint/naming-convention - (runInTerminalTool as unknown as { _registerCompletionNotification: (terminal: ITerminalInstance, termId: string, session: URI, commandName: string, outputMonitor: { onDidDetectInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }, alreadyNotifiedInputNeededOutput?: string) => void }) + (runInTerminalTool as unknown as { _registerCompletionNotification: (terminal: ITerminalInstance, termId: string, session: URI, commandName: string, outputMonitor: { onDidDetectInputNeeded: Event; onDidDetectSensitiveInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }, alreadyNotifiedInputNeededOutput?: string) => void }) ._registerCompletionNotification(terminalInstance, termId, sessionResource, 'mkdir -p foo && cd foo && npm init', outputMonitor, output); inputNeededEmitter.fire(); @@ -2071,7 +2071,7 @@ suite('RunInTerminalTool', () => { onDidDetectSensitiveInputNeeded: Event.None, continueMonitoringAsync: () => { }, dispose: () => { }, - } as unknown as { onDidDetectInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }; + } as unknown as { onDidDetectInputNeeded: Event; onDidDetectSensitiveInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }; // Set up fg terminal association and active execution runInTerminalTool.sessionTerminalAssociations.set(sessionResource, { @@ -2085,7 +2085,7 @@ suite('RunInTerminalTool', () => { }); // eslint-disable-next-line @typescript-eslint/naming-convention - (runInTerminalTool as unknown as { _registerCompletionNotification: (terminal: ITerminalInstance, termId: string, session: URI, commandName: string, outputMonitor: { onDidDetectInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }) => void }) + (runInTerminalTool as unknown as { _registerCompletionNotification: (terminal: ITerminalInstance, termId: string, session: URI, commandName: string, outputMonitor: { onDidDetectInputNeeded: Event; onDidDetectSensitiveInputNeeded: Event; continueMonitoringAsync: () => void; dispose: () => void }) => void }) ._registerCompletionNotification(terminalInstance, termId, sessionResource, 'ssh host', outputMonitor); // Fire inputNeeded — this simulates the output monitor detecting a prompt