From 34dd7810b7a1ef1c8d1390a5a5e7bd288920df16 Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sat, 21 Mar 2026 17:43:25 +0100 Subject: [PATCH 01/22] fix: strip command echo and prompt from terminal output (#303531) Prevent sandbox-wrapped command lines from leaking as output when commands produce no actual output. Adds stripCommandEchoAndPrompt() to isolate real output from marker-based terminal buffer captures. Also adds configurable idle poll interval and shell integration timeout=0 support for faster test execution. --- .../chat.runInTerminal.test.ts | 324 ++++++++++++++++++ .../terminal/common/terminalEnvironment.ts | 2 + .../executeStrategy/basicExecuteStrategy.ts | 21 +- .../executeStrategy/executeStrategy.ts | 3 +- .../executeStrategy/noneExecuteStrategy.ts | 25 +- .../executeStrategy/richExecuteStrategy.ts | 7 +- .../executeStrategy/strategyHelpers.ts | 80 +++++ .../browser/tools/runInTerminalTool.ts | 6 +- .../terminalChatAgentToolsConfiguration.ts | 8 + .../test/browser/noneExecuteStrategy.test.ts | 122 +++++++ .../test/browser/strategyHelpers.test.ts | 207 +++++++++++ 11 files changed, 794 insertions(+), 11 deletions(-) create mode 100644 extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts create mode 100644 src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/noneExecuteStrategy.test.ts create mode 100644 src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts new file mode 100644 index 00000000000..4f226d1d844 --- /dev/null +++ b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts @@ -0,0 +1,324 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import 'mocha'; +import * as vscode from 'vscode'; +import { DeferredPromise, assertNoRpc, closeAllEditors, disposeAll } from '../utils'; + +const isWindows = process.platform === 'win32'; + +/** + * Extracts all text content from a LanguageModelToolResult. + */ +function extractTextContent(result: vscode.LanguageModelToolResult): string { + return result.content + .filter((c): c is vscode.LanguageModelTextPart => c instanceof vscode.LanguageModelTextPart) + .map(c => c.value) + .join(''); +} + +// https://github.com/microsoft/vscode/issues/303531 +(vscode.env.uiKind === vscode.UIKind.Web ? suite.skip : suite)('chat - run_in_terminal (issue #303531)', () => { + + let disposables: vscode.Disposable[] = []; + + setup(async () => { + disposables = []; + + // Register a dummy default model required for participant requests + disposables.push(vscode.lm.registerLanguageModelChatProvider('copilot', { + async provideLanguageModelChatInformation(_options, _token) { + return [{ + id: 'test-lm', + name: 'test-lm', + family: 'test', + version: '1.0.0', + maxInputTokens: 100, + maxOutputTokens: 100, + isDefault: true, + isUserSelectable: true, + capabilities: {} + }]; + }, + async provideLanguageModelChatResponse(_model, _messages, _options, _progress, _token) { + return undefined; + }, + async provideTokenCount(_model, _text, _token) { + return 1; + }, + })); + + // Enable global auto-approve + skip the confirmation dialog via test-mode context key + const chatToolsConfig = vscode.workspace.getConfiguration('chat.tools.global'); + await chatToolsConfig.update('autoApprove', true, vscode.ConfigurationTarget.Global); + await vscode.commands.executeCommand('setContext', 'vscode.chat.tools.global.autoApprove.testMode', true); + }); + + teardown(async () => { + assertNoRpc(); + await closeAllEditors(); + disposeAll(disposables); + participantRegistered = false; + pendingResult = undefined; + pendingCommand = undefined; + pendingTimeout = undefined; + + const chatToolsConfig = vscode.workspace.getConfiguration('chat.tools.global'); + await chatToolsConfig.update('autoApprove', undefined, vscode.ConfigurationTarget.Global); + await vscode.commands.executeCommand('setContext', 'vscode.chat.tools.global.autoApprove.testMode', undefined); + }); + + /** + * Helper: invokes run_in_terminal via a chat participant and returns the tool result text. + * Each call creates a new chat session to avoid participant re-registration issues. + */ + let participantRegistered = false; + let pendingResult: DeferredPromise | undefined; + let pendingCommand: string | undefined; + let pendingTimeout: number | undefined; + + function setupParticipant() { + if (participantRegistered) { + return; + } + participantRegistered = true; + const participant = vscode.chat.createChatParticipant('api-test.participant', async (request, _context, _progress, _token) => { + if (!pendingResult || !pendingCommand) { + return {}; + } + const currentResult = pendingResult; + const currentCommand = pendingCommand; + const currentTimeout = pendingTimeout ?? 15000; + pendingResult = undefined; + pendingCommand = undefined; + pendingTimeout = undefined; + try { + const result = await vscode.lm.invokeTool('run_in_terminal', { + input: { + command: currentCommand, + explanation: 'Integration test command', + goal: 'Test run_in_terminal output', + isBackground: false, + timeout: currentTimeout + }, + toolInvocationToken: request.toolInvocationToken, + }); + currentResult.complete(result); + } catch (e) { + currentResult.error(e); + } + return {}; + }); + disposables.push(participant); + } + + async function invokeRunInTerminal(command: string, timeout = 15000): Promise { + setupParticipant(); + + const resultPromise = new DeferredPromise(); + pendingResult = resultPromise; + pendingCommand = command; + pendingTimeout = timeout; + + await vscode.commands.executeCommand('workbench.action.chat.newChat'); + vscode.commands.executeCommand('workbench.action.chat.open', { query: '@participant test' }); + + const result = await resultPromise.p; + return extractTextContent(result); + } + + test('tool should be registered with expected schema', () => { + const tool = vscode.lm.tools.find(t => t.name === 'run_in_terminal'); + assert.ok(tool, 'run_in_terminal tool should be registered'); + assert.ok(tool.inputSchema, 'Tool should have an input schema'); + + const schema = tool.inputSchema as { properties?: Record }; + assert.ok(schema.properties?.['command'], 'Schema should have a command property'); + assert.ok(schema.properties?.['explanation'], 'Schema should have an explanation property'); + assert.ok(schema.properties?.['goal'], 'Schema should have a goal property'); + assert.ok(schema.properties?.['isBackground'], 'Schema should have an isBackground property'); + }); + + // --- Shell integration OFF (fast idle polling) --- + + suite('shell integration off', () => { + + setup(async () => { + const termConfig = vscode.workspace.getConfiguration('terminal.integrated'); + await termConfig.update('shellIntegration.enabled', false, vscode.ConfigurationTarget.Global); + await termConfig.update('shellIntegration.timeout', 0, vscode.ConfigurationTarget.Global); + + const toolConfig = vscode.workspace.getConfiguration('chat.tools.terminal'); + await toolConfig.update('idlePollInterval', 50, vscode.ConfigurationTarget.Global); + }); + + teardown(async () => { + const termConfig = vscode.workspace.getConfiguration('terminal.integrated'); + await termConfig.update('shellIntegration.enabled', undefined, vscode.ConfigurationTarget.Global); + await termConfig.update('shellIntegration.timeout', undefined, vscode.ConfigurationTarget.Global); + + const toolConfig = vscode.workspace.getConfiguration('chat.tools.terminal'); + await toolConfig.update('idlePollInterval', undefined, vscode.ConfigurationTarget.Global); + }); + + defineTests(); + }); + + // --- Shell integration ON --- + + suite('shell integration on', () => { + defineTests(); + }); + + function defineTests() { + + // --- Sandbox OFF tests --- + + suite('sandbox off', () => { + + test('echo command returns exactly the echoed text', async function () { + this.timeout(60000); + + const marker = `MARKER_${Date.now()}_ECHO`; + const output = await invokeRunInTerminal(`echo ${marker}`); + + assert.strictEqual(output.trim(), marker); + }); + + test('no-output command reports empty output, not prompt echo (issue #303531)', async function () { + this.timeout(60000); + + // `true` on Unix exits 0 with no output; `cmd /c rem` on Windows is a no-op + const command = isWindows ? 'cmd /c rem' : 'true'; + const output = await invokeRunInTerminal(command); + + assert.strictEqual(output.trim(), 'Command produced no output'); + }); + + test('multi-line output preserves all lines in order', async function () { + this.timeout(60000); + + const m1 = `M1_${Date.now()}`; + const m2 = `M2_${Date.now()}`; + const m3 = `M3_${Date.now()}`; + const output = await invokeRunInTerminal(`echo ${m1} && echo ${m2} && echo ${m3}`); + + assert.strictEqual(output.trim(), `${m1}\n${m2}\n${m3}`); + }); + + test('non-zero exit code is reported', async function () { + this.timeout(60000); + + // Use a subshell so we don't kill the shared terminal + const command = isWindows ? 'cmd /c exit 42' : 'bash -c "exit 42"'; + const output = await invokeRunInTerminal(command); + + assert.strictEqual(output.trim(), 'Command produced no output\nCommand exited with code 42'); + }); + + test('output with special characters is captured verbatim', async function () { + this.timeout(60000); + + const marker = `SP_${Date.now()}`; + const output = await invokeRunInTerminal(`echo "${marker} hello & world"`); + + assert.strictEqual(output.trim(), `${marker} hello & world`); + }); + + }); + + // --- Sandbox ON tests (macOS and Linux only) --- + + (isWindows ? suite.skip : suite)('sandbox on', () => { + + setup(async () => { + const sandboxConfig = vscode.workspace.getConfiguration('chat.tools.terminal.sandbox'); + await sandboxConfig.update('enabled', true, vscode.ConfigurationTarget.Global); + }); + + teardown(async () => { + const sandboxConfig = vscode.workspace.getConfiguration('chat.tools.terminal.sandbox'); + await sandboxConfig.update('enabled', undefined, vscode.ConfigurationTarget.Global); + }); + + test('echo works in sandbox and output is clean', async function () { + this.timeout(60000); + + const marker = `SANDBOX_ECHO_${Date.now()}`; + const output = await invokeRunInTerminal(`echo ${marker}`); + + assert.strictEqual(output.trim(), marker); + }); + + test('network requests are blocked', async function () { + this.timeout(60000); + + const output = await invokeRunInTerminal('curl -s --max-time 5 https://example.com'); + + // The sandbox blocks network access. curl fails and the sandbox + // output analyzer prepends its error message. + assert.strictEqual(output.trim(), [ + 'Command failed while running in sandboxed mode. If the command failed due to sandboxing:', + '- If it would be reasonable to extend the sandbox rules, work with the user to update allowWrite for file system access problems in chat.tools.terminal.sandbox.macFileSystem, or to add required domains to chat.tools.terminal.sandbox.network.allowedDomains.', + '- Otherwise, immediately retry the command with requestUnsandboxedExecution=true. Do NOT ask the user \u2014 setting this flag automatically shows a confirmation prompt to the user.', + '', + 'Here is the output of the command:', + '', + '', + '', + 'Command produced no output', + 'Command exited with code 56', + ].join('\n')); + }); + + test('cannot write to /tmp', async function () { + this.timeout(60000); + + const marker = `SANDBOX_TMP_${Date.now()}`; + const output = await invokeRunInTerminal(`echo "${marker}" > /tmp/${marker}.txt`); + + assert.strictEqual(output.trim(), [ + 'Command failed while running in sandboxed mode. If the command failed due to sandboxing:', + '- If it would be reasonable to extend the sandbox rules, work with the user to update allowWrite for file system access problems in chat.tools.terminal.sandbox.macFileSystem, or to add required domains to chat.tools.terminal.sandbox.network.allowedDomains.', + '- Otherwise, immediately retry the command with requestUnsandboxedExecution=true. Do NOT ask the user \u2014 setting this flag automatically shows a confirmation prompt to the user.', + '', + 'Here is the output of the command:', + '', + `/bin/bash: /tmp/${marker}.txt: Operation not permitted`, + '', + '', + 'Command exited with code 1', + ].join('\n')); + }); + + test('can read files outside the workspace', async function () { + this.timeout(60000); + + const output = await invokeRunInTerminal('head -1 /etc/shells'); + + assert.strictEqual(output.trim(), '# List of acceptable shells for chpass(1).'); + }); + + test('can write inside the workspace folder', async function () { + this.timeout(60000); + + const marker = `SANDBOX_WS_${Date.now()}`; + const output = await invokeRunInTerminal(`echo "${marker}" > .sandbox-test-${marker}.tmp && cat .sandbox-test-${marker}.tmp && rm .sandbox-test-${marker}.tmp`); + + assert.strictEqual(output.trim(), marker); + }); + + test('$TMPDIR is writable inside the sandbox', async function () { + this.timeout(60000); + + const marker = `SANDBOX_TMPDIR_${Date.now()}`; + const output = await invokeRunInTerminal(`echo "${marker}" > "$TMPDIR/${marker}.tmp" && cat "$TMPDIR/${marker}.tmp" && rm "$TMPDIR/${marker}.tmp"`); + + assert.strictEqual(output.trim(), marker); + }); + }); + } +}); diff --git a/src/vs/workbench/contrib/terminal/common/terminalEnvironment.ts b/src/vs/workbench/contrib/terminal/common/terminalEnvironment.ts index a724394106a..b797a6d549a 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalEnvironment.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalEnvironment.ts @@ -425,6 +425,8 @@ export function getShellIntegrationTimeout( if (!isNumber(timeoutValue) || timeoutValue < 0) { timeoutMs = siInjectionEnabled ? 5000 : (isRemote ? 3000 : 2000); + } else if (timeoutValue === 0) { + timeoutMs = 0; } else { timeoutMs = Math.max(timeoutValue, 500); } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts index de2f1ae7056..c63a9d93b98 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts @@ -8,12 +8,14 @@ import { CancellationError } from '../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { isNumber } from '../../../../../../base/common/types.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import type { ICommandDetectionCapability } from '../../../../../../platform/terminal/common/capabilities/capabilities.js'; import { ITerminalLogService } from '../../../../../../platform/terminal/common/terminal.js'; import { trackIdleOnPrompt, waitForIdle, type ITerminalExecuteStrategy, type ITerminalExecuteStrategyResult } from './executeStrategy.js'; import type { IMarker as IXtermMarker } from '@xterm/xterm'; import { ITerminalInstance } from '../../../../terminal/browser/terminal.js'; -import { createAltBufferPromise, setupRecreatingStartMarker } from './strategyHelpers.js'; +import { createAltBufferPromise, setupRecreatingStartMarker, stripCommandEchoAndPrompt } from './strategyHelpers.js'; +import { TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js'; /** * This strategy is used when shell integration is enabled, but rich command detection was not @@ -49,6 +51,7 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute private readonly _instance: ITerminalInstance, private readonly _hasReceivedUserInput: () => boolean, private readonly _commandDetection: ICommandDetectionCapability, + @IConfigurationService private readonly _configurationService: IConfigurationService, @ITerminalLogService private readonly _logService: ITerminalLogService, ) { super(); @@ -58,7 +61,9 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute const store = new DisposableStore(); try { - const idlePromptPromise = trackIdleOnPrompt(this._instance, 1000, store); + const idlePollInterval = this._configurationService.getValue(TerminalChatAgentToolsSettingId.IdlePollInterval) ?? 1000; + + const idlePromptPromise = trackIdleOnPrompt(this._instance, idlePollInterval, store, idlePollInterval); const onDone = Promise.race([ Event.toPromise(this._commandDetection.onCommandFinished, store).then(e => { // When shell integration is basic, it means that the end execution event is @@ -82,7 +87,7 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute }), // A longer idle prompt event is used here as a catch all for unexpected cases where // the end event doesn't fire for some reason. - trackIdleOnPrompt(this._instance, 3000, store).then(() => { + trackIdleOnPrompt(this._instance, idlePollInterval * 3, store, idlePollInterval).then(() => { this._log('onDone long idle prompt'); }), ]); @@ -97,7 +102,7 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute // Wait for the terminal to idle before executing the command this._log('Waiting for idle'); - await waitForIdle(this._instance.onData, 1000); + await waitForIdle(this._instance.onData, idlePollInterval); setupRecreatingStartMarker( xterm, @@ -150,7 +155,7 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute // Wait for the terminal to idle this._log('Waiting for idle'); - await waitForIdle(this._instance.onData, 1000); + await waitForIdle(this._instance.onData, idlePollInterval); if (token.isCancellationRequested) { throw new CancellationError(); } @@ -170,6 +175,12 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute try { output = xterm.getContentsAsText(this._startMarker.value, endMarker); this._log('Fetched output via markers'); + + // The marker-based output includes the command echo and trailing + // prompt lines. Strip them to isolate the actual command output. + if (output !== undefined) { + output = stripCommandEchoAndPrompt(output, commandLine); + } } catch { this._log('Failed to fetch output via markers'); additionalInformationLines.push('Failed to retrieve command output'); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts index f95f297a958..805223b03f6 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts @@ -160,6 +160,7 @@ export async function trackIdleOnPrompt( instance: ITerminalInstance, idleDurationMs: number, store: DisposableStore, + promptFallbackMs?: number, ): Promise { const idleOnPrompt = new DeferredPromise(); const onData = instance.onData; @@ -176,7 +177,7 @@ export async function trackIdleOnPrompt( } state = TerminalState.PromptAfterExecuting; scheduler.schedule(); - }, 1000)); + }, promptFallbackMs ?? 1000)); // Only schedule when a prompt sequence (A) is seen after an execute sequence (C). This prevents // cases where the command is executed before the prompt is written. While not perfect, sitting // on an A without a C following shortly after is a very good indicator that the command is done diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts index ddc2a7f8939..16c0a21b528 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts @@ -7,11 +7,13 @@ import type { CancellationToken } from '../../../../../../base/common/cancellati import { CancellationError } from '../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { ITerminalLogService } from '../../../../../../platform/terminal/common/terminal.js'; import { waitForIdle, waitForIdleWithPromptHeuristics, type ITerminalExecuteStrategy, type ITerminalExecuteStrategyResult } from './executeStrategy.js'; import type { IMarker as IXtermMarker } from '@xterm/xterm'; import { ITerminalInstance } from '../../../../terminal/browser/terminal.js'; -import { createAltBufferPromise, setupRecreatingStartMarker } from './strategyHelpers.js'; +import { createAltBufferPromise, setupRecreatingStartMarker, stripCommandEchoAndPrompt } from './strategyHelpers.js'; +import { TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js'; /** * This strategy is used when no shell integration is available. There are very few extension APIs @@ -30,6 +32,7 @@ export class NoneExecuteStrategy extends Disposable implements ITerminalExecuteS constructor( private readonly _instance: ITerminalInstance, private readonly _hasReceivedUserInput: () => boolean, + @IConfigurationService private readonly _configurationService: IConfigurationService, @ITerminalLogService private readonly _logService: ITerminalLogService, ) { super(); @@ -50,9 +53,11 @@ export class NoneExecuteStrategy extends Disposable implements ITerminalExecuteS } const alternateBufferPromise = createAltBufferPromise(xterm, store, this._log.bind(this)); + const idlePollInterval = this._configurationService.getValue(TerminalChatAgentToolsSettingId.IdlePollInterval) ?? 1000; + // Wait for the terminal to idle before executing the command this._log('Waiting for idle'); - await waitForIdle(this._instance.onData, 1000); + await waitForIdle(this._instance.onData, idlePollInterval); if (token.isCancellationRequested) { throw new CancellationError(); } @@ -82,7 +87,7 @@ export class NoneExecuteStrategy extends Disposable implements ITerminalExecuteS // Assume the command is done when it's idle this._log('Waiting for idle with prompt heuristics'); const promptResultOrAltBuffer = await Promise.race([ - waitForIdleWithPromptHeuristics(this._instance.onData, this._instance, 1000, 10000), + waitForIdleWithPromptHeuristics(this._instance.onData, this._instance, idlePollInterval, idlePollInterval * 10), alternateBufferPromise.then(() => 'alternateBuffer' as const) ]); if (promptResultOrAltBuffer === 'alternateBuffer') { @@ -109,10 +114,24 @@ export class NoneExecuteStrategy extends Disposable implements ITerminalExecuteS try { output = xterm.getContentsAsText(this._startMarker.value, endMarker); this._log('Fetched output via markers'); + + // The marker-based output includes the command echo (the line where the + // command was typed) and the next prompt line. Strip them to isolate + // only the actual command output. The first line always contains the + // command echo (since the start marker is placed at the cursor before + // sendText), and trailing lines that look like shell prompts are removed. + if (output !== undefined) { + output = stripCommandEchoAndPrompt(output, commandLine); + } } catch { this._log('Failed to fetch output via markers'); additionalInformationLines.push('Failed to retrieve command output'); } + + if (output !== undefined && output.trim().length === 0) { + additionalInformationLines.push('Command produced no output'); + } + return { output, additionalInformation: additionalInformationLines.length > 0 ? additionalInformationLines.join('\n') : undefined, diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts index b68f7a499d4..db2ea00213f 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts @@ -8,12 +8,14 @@ import { CancellationError } from '../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { isNumber } from '../../../../../../base/common/types.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import type { ICommandDetectionCapability } from '../../../../../../platform/terminal/common/capabilities/capabilities.js'; import { ITerminalLogService } from '../../../../../../platform/terminal/common/terminal.js'; import type { ITerminalInstance } from '../../../../terminal/browser/terminal.js'; import { trackIdleOnPrompt, type ITerminalExecuteStrategy, type ITerminalExecuteStrategyResult } from './executeStrategy.js'; import type { IMarker as IXtermMarker } from '@xterm/xterm'; import { createAltBufferPromise, setupRecreatingStartMarker } from './strategyHelpers.js'; +import { TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js'; /** * This strategy is used when the terminal has rich shell integration/command detection is @@ -32,6 +34,7 @@ export class RichExecuteStrategy extends Disposable implements ITerminalExecuteS constructor( private readonly _instance: ITerminalInstance, private readonly _commandDetection: ICommandDetectionCapability, + @IConfigurationService private readonly _configurationService: IConfigurationService, @ITerminalLogService private readonly _logService: ITerminalLogService, ) { super(); @@ -48,6 +51,8 @@ export class RichExecuteStrategy extends Disposable implements ITerminalExecuteS } const alternateBufferPromise = createAltBufferPromise(xterm, store, this._log.bind(this)); + const idlePollInterval = this._configurationService.getValue(TerminalChatAgentToolsSettingId.IdlePollInterval) ?? 1000; + const onDone = Promise.race([ Event.toPromise(this._commandDetection.onCommandFinished, store).then(e => { this._log('onDone via end event'); @@ -63,7 +68,7 @@ export class RichExecuteStrategy extends Disposable implements ITerminalExecuteS this._log('onDone via terminal disposal'); return { type: 'disposal' } as const; }), - trackIdleOnPrompt(this._instance, 1000, store).then(() => { + trackIdleOnPrompt(this._instance, idlePollInterval, store, idlePollInterval).then(() => { this._log('onDone via idle prompt'); }), ]); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts index 5c63b233ec2..a79fa6303d1 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts @@ -70,3 +70,83 @@ export function createAltBufferPromise( return deferred.p; } + +/** + * Strips the command echo and trailing prompt lines from marker-based terminal output. + * Without shell integration (or when `getOutput()` is unavailable), `getContentsAsText` + * captures the entire terminal buffer between the start and end markers, which includes: + * 1. The command echo line (what `sendText` wrote) + * 2. The actual command output + * 3. The next shell prompt line(s) + * + * This function removes (1) and (3) to isolate the actual output. + */ +export function stripCommandEchoAndPrompt(output: string, commandLine: string): string { + const lines = output.split('\n'); + + // Strip leading lines that are part of the command echo. The start marker + // is placed at the cursor before sendText, so the first captured line(s) + // contain the prompt + command text, possibly wrapped across terminal columns. + let startIndex = 0; + const trimmedCommand = commandLine.trim(); + if (trimmedCommand.length > 0) { + // Use a short prefix of the command for matching — enough to be unique + // but handles terminal wrapping where lines are fragments. + const commandPrefix = trimmedCommand.substring(0, Math.min(30, trimmedCommand.length)); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Check if this line contains the beginning of the command (handles + // prompt prefix like `user@host:dir] $ ` on the first line) + if (line.includes(commandPrefix)) { + startIndex = i + 1; + continue; + } + + // For continuation lines of a wrapped command: if we already matched + // the first echo line, keep consuming lines whose content is clearly + // part of the wrapped command (e.g. long env var assignments, paths + // with slashes, or sandbox wrapper fragments). We require the line to + // end WITHOUT a newline-induced break in a word, so it must look like + // a proper continuation — not regular command output. + if (startIndex > 0 && i === startIndex) { + const lineContent = line.trim(); + // A continuation line of a wrapped command typically contains + // path separators, env var assignments, or quoted strings — not + // plain output text. Also require it to appear in the command. + if (lineContent.length > 0 && trimmedCommand.includes(lineContent) && + (/[\/\\=]/.test(lineContent) || /^['"]/.test(lineContent))) { + startIndex = i + 1; + continue; + } + } + + break; + } + } + + // Strip trailing lines that are part of the next shell prompt. Prompts may + // span multiple lines due to terminal column wrapping. We strip from the + // bottom any line that is either: + // - Empty/whitespace + // - Ends with a prompt character ($, >, #, %) + // - Looks like a shell prompt (contains ] $ or user@host patterns) + let endIndex = lines.length; + while (endIndex > startIndex) { + const line = lines[endIndex - 1].trimEnd(); + if ( + line.length === 0 || + /[>$#%]\s*$/.test(line) || + /\]\s*\$/.test(line) || + /\w+@[\w-]+:/.test(line) || + /^\[?\s*\w+@/.test(line) + ) { + endIndex--; + } else { + break; + } + } + + return lines.slice(startIndex, endIndex).join('\n'); +} 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 0fea4461bab..022dab65f9b 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -883,7 +883,11 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const didToolEditCommand = ( !didUserEditCommand && toolSpecificData.commandLine.toolEdited !== undefined && - toolSpecificData.commandLine.toolEdited !== toolSpecificData.commandLine.original + toolSpecificData.commandLine.toolEdited !== toolSpecificData.commandLine.original && + // Only consider it a meaningful edit if the display form also differs from the + // original. Cosmetic rewrites like prepending a space to prevent shell history + // should not trigger the "tool simplified the command" note. + normalizeTerminalCommandForDisplay(toolSpecificData.commandLine.toolEdited).trim() !== normalizeTerminalCommandForDisplay(toolSpecificData.commandLine.original).trim() ); const didSandboxWrapCommand = toolSpecificData.commandLine.isSandboxWrapped === true; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index f50dfea219f..e94af156a8f 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -26,6 +26,7 @@ export const enum TerminalChatAgentToolsSettingId { TerminalSandboxMacFileSystem = 'chat.tools.terminal.sandbox.macFileSystem', PreventShellHistory = 'chat.tools.terminal.preventShellHistory', EnforceTimeoutFromModel = 'chat.tools.terminal.enforceTimeoutFromModel', + IdlePollInterval = 'chat.tools.terminal.idlePollInterval', TerminalProfileLinux = 'chat.tools.terminal.terminalProfile.linux', TerminalProfileMacOs = 'chat.tools.terminal.terminalProfile.osx', @@ -442,6 +443,13 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function createLogService(): ITerminalLogService { + return new class extends NullLogService { readonly _logBrand = undefined; }; + } + + /** + * Creates a mock terminal instance and xterm for testing NoneExecuteStrategy. + * + * @param contentsAsText The text that `xterm.getContentsAsText()` will return (simulates + * the terminal buffer content between the start and end markers) + * @param cursorLineText The text at the cursor line, used by prompt detection heuristics + */ + function createMockTerminalAndXterm(contentsAsText: string, cursorLineText: string): { + instance: ITerminalInstance; + onDataEmitter: Emitter; + } { + const onDataEmitter = store.add(new Emitter()); + const activeBuffer = {}; + const alternateBuffer = {}; // different object → not alt buffer + + const mockXterm = { + raw: { + registerMarker: () => ({ + line: 0, + isDisposed: false, + onDispose: Event.None, + dispose: () => { }, + }), + buffer: { + active: { + ...activeBuffer, + baseY: 0, + cursorY: 0, + getLine: () => ({ + translateToString: () => cursorLineText, + }), + }, + alternate: alternateBuffer, + onBufferChange: () => ({ dispose: () => { } }), + }, + onWriteParsed: Event.None, + }, + getContentsAsText: () => contentsAsText, + }; + + const mockInstance = { + xtermReadyPromise: Promise.resolve(mockXterm), + onData: onDataEmitter.event, + sendText: () => { }, + } as unknown as ITerminalInstance; + + return { instance: mockInstance, onDataEmitter }; + } + + test('should report "Command produced no output" when output is empty', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + // Simulate a command that produces no output. Between the start and end markers, + // getContentsAsText returns only whitespace (no actual command output). + const { instance } = createMockTerminalAndXterm( + ' \n \n ', // only whitespace between markers + 'user@host:~$ ' // prompt at cursor line → triggers prompt detection + ); + + const logService = createLogService(); + const strategy = store.add(new NoneExecuteStrategy(instance, () => false, logService)); + const cts = store.add(new CancellationTokenSource()); + + const result = await strategy.execute('echo test', cts.token); + + assert.strictEqual(result.additionalInformation, 'Command produced no output'); + })); + + test('should not leak sandbox command echo as output when command produces no output', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + // This simulates the exact scenario from issue #303531: + // A sandboxed command produces no output, but getContentsAsText returns the + // prompt + sandbox-wrapped command echo + next prompt line. + const promptLine = '[ user@host:~/src (main) ] $ '; + const sandboxCommandEcho = 'ELECTRON_RUN_AS_NODE=1 PATH="$PATH:/app/node_modules/@vscode/ripgrep/bin" ' + + 'TMPDIR="/var/folders/bb/_8jjjyy971x2frm3nr3g7m4r0000gn/T" ' + + '"/app/Contents/MacOS/Code - Insiders" "/app/Contents/Resources/app/node_modules/@anthropic-ai/sandbox-runtime/dist/cli.js" ' + + '--settings "/var/folders/bb/_8jjjyy971x2frm3nr3g7m4r0000gn/T/vscode-sandbox-settings.json" ' + + '-c \' git diff 0e5d5949d13f..2c357a926df6 -- \'\\\'\'src/foo.ts\'\\\'\' | grep -A3 -B3 \'\\\'\'someFunc\'\\\'\'\''; + const terminalContent = `${promptLine}${sandboxCommandEcho}\n${' '.repeat(80)}\n${promptLine}`; + + const { instance } = createMockTerminalAndXterm( + terminalContent, + promptLine // prompt at cursor line → triggers prompt detection + ); + + const logService = createLogService(); + const strategy = store.add(new NoneExecuteStrategy(instance, () => false, logService)); + const cts = store.add(new CancellationTokenSource()); + + const result = await strategy.execute( + 'git diff 0e5d5949d13f..2c357a926df6 -- \'src/foo.ts\' | grep -A3 -B3 \'someFunc\'', + cts.token + ); + + // The output should NOT contain sandbox wrapper artifacts + assert.strictEqual(result.output?.includes('sandbox-runtime') ?? false, false, 'Output should not leak sandbox-runtime path'); + assert.strictEqual(result.output?.includes('ELECTRON_RUN_AS_NODE') ?? false, false, 'Output should not leak ELECTRON_RUN_AS_NODE'); + + // Should report that the command produced no output + assert.strictEqual(result.additionalInformation, 'Command produced no output'); + })); +}); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts new file mode 100644 index 00000000000..9fd2cec80be --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts @@ -0,0 +1,207 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { stripCommandEchoAndPrompt } from '../../browser/executeStrategy/strategyHelpers.js'; + +suite('stripCommandEchoAndPrompt', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('strips single-line command echo and trailing prompt', () => { + const output = [ + 'user@host:~/src $ echo hello', + 'hello', + 'user@host:~/src $ ', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo hello'), + 'hello' + ); + }); + + test('strips command echo with zsh-style prompt (] $ )', () => { + const output = [ + 's/testWorkspace (main**) ] $ true', + '[ alex@Alexandrus-MacBook-Pro:/Users/alex/src/vscode4/extensions/vscode-api-test', + 's/testWorkspace (main**) ] $ ', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'true'), + '' + ); + }); + + test('preserves actual command output between echo and prompt', () => { + const output = [ + 's/testWorkspace (main**) ] $ echo MARKER_123', + 'MARKER_123', + '[ alex@host:/some/path', + 's/testWorkspace (main**) ] $ ', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo MARKER_123'), + 'MARKER_123' + ); + }); + + test('preserves multi-line command output', () => { + const output = [ + 'user@host:~ $ echo line1 && echo line2 && echo line3', + 'line1', + 'line2', + 'line3', + 'user@host:~ $ ', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo line1 && echo line2 && echo line3'), + 'line1\nline2\nline3' + ); + }); + + test('handles empty output (no-output command)', () => { + const output = [ + 's/testWorkspace (main**) ] $ true', + '[ alex@host:/Users/alex/src/vscode4/extensions/vscode-api-test', + 's/testWorkspace (main**) ] $', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'true'), + '' + ); + }); + + test('strips sandbox-wrapped command echo (long wrapped lines)', () => { + const sandboxCommand = 'ELECTRON_RUN_AS_NODE=1 PATH="$PATH:/app/rg/bin" TMPDIR="/tmp/sandbox" "/app/sandbox-runtime/dist/cli.js" --settings "/tmp/sandbox-settings.json" -c \'curl -s https://example.com\''; + const output = [ + 's/testWorkspace (main**) ] $ ELECTRON_RUN_AS_NODE=1 PATH="$PATH:/app/rg/bin" T', + 'MPDIR="/tmp/sandbox" "/app/sandbox-runtime/dist/cli.js" --settings "/tmp/sand', + 'box-settings.json" -c \'curl -s https://example.com\'', + '[ alex@host:/Users/alex/src/vscode4/extensions/vscode-api-test', + 's/testWorkspace (main**) ] $ ', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, sandboxCommand), + '' + ); + }); + + test('strips trailing prompt with various prompt characters ($ > # %)', () => { + for (const promptChar of ['$', '>', '#', '%']) { + const output = [ + `prompt ${promptChar} echo hello`, + 'hello', + `prompt ${promptChar} `, + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo hello'), + 'hello', + `Failed for prompt character: ${promptChar}` + ); + } + }); + + test('handles command with leading space (history prevention)', () => { + const output = [ + 'user@host:~ $ echo hello', + 'hello', + 'user@host:~ $ ', + ].join('\n'); + + // The command has a leading space (from CommandLinePreventHistoryRewriter) + assert.strictEqual( + stripCommandEchoAndPrompt(output, ' echo hello'), + 'hello' + ); + }); + + test('does not strip actual output lines that happen to contain prompt chars', () => { + const output = [ + 'user@host:~ $ echo "price is $5"', + 'price is $5', + 'user@host:~ $ ', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo "price is $5"'), + 'price is $5' + ); + }); + + test('handles output with no trailing prompt (e.g. command still running)', () => { + const output = [ + 'user@host:~ $ echo hello', + 'hello', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo hello'), + 'hello' + ); + }); + + test('handles output with only the command echo and no prompt', () => { + const output = 'user@host:~ $ true'; + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'true'), + '' + ); + }); + + test('handles empty string input', () => { + assert.strictEqual( + stripCommandEchoAndPrompt('', 'echo hello'), + '' + ); + }); + + test('handles bash -c subshell command echo', () => { + const output = [ + 's/testWorkspace (main**) ] $ bash -c "exit 42"', + '[ alex@host:/Users/alex/src/vscode4/extensions/vscode-api-test', + 's/testWorkspace (main**) ] $ ', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'bash -c "exit 42"'), + '' + ); + }); + + test('strips wrapped prompt lines with user@hostname pattern', () => { + const output = [ + 'user@host:~ $ echo hi', + 'hi', + '[ alex@Alexandrus-MacBook-Pro:/very/long/path/that/wraps/across/terminal/col', + 'umns/in/the/test/workspace ] $', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo hi'), + 'hi' + ); + }); + + test('handles PowerShell-style prompt (PS C:\\>)', () => { + const output = [ + 'PS C:\\Users\\test> echo hello', + 'hello', + 'PS C:\\Users\\test>', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo hello'), + 'hello' + ); + }); +}); From 31a4a74df7bcd3da3354c0944ac978e10ef97e91 Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sat, 21 Mar 2026 17:58:07 +0100 Subject: [PATCH 02/22] Fix compilation errors --- .../test/browser/noneExecuteStrategy.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/noneExecuteStrategy.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/noneExecuteStrategy.test.ts index 05f9c72ca7b..96a6cc5f77b 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/noneExecuteStrategy.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/noneExecuteStrategy.test.ts @@ -12,6 +12,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; import { NoneExecuteStrategy } from '../../browser/executeStrategy/noneExecuteStrategy.js'; import type { ITerminalInstance } from '../../../../terminal/browser/terminal.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; suite('NoneExecuteStrategy', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -78,7 +79,8 @@ suite('NoneExecuteStrategy', () => { ); const logService = createLogService(); - const strategy = store.add(new NoneExecuteStrategy(instance, () => false, logService)); + const configService = new TestConfigurationService(); + const strategy = store.add(new NoneExecuteStrategy(instance, () => false, configService, logService)); const cts = store.add(new CancellationTokenSource()); const result = await strategy.execute('echo test', cts.token); @@ -104,7 +106,8 @@ suite('NoneExecuteStrategy', () => { ); const logService = createLogService(); - const strategy = store.add(new NoneExecuteStrategy(instance, () => false, logService)); + const configService = new TestConfigurationService(); + const strategy = store.add(new NoneExecuteStrategy(instance, () => false, configService, logService)); const cts = store.add(new CancellationTokenSource()); const result = await strategy.execute( From a32b488b9da58664550c8470c9af4003e44c3cce Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sat, 21 Mar 2026 18:05:15 +0100 Subject: [PATCH 03/22] fix: tighten trailing prompt stripping to avoid dropping legitimate output Anchor prompt-detection regexes to specific prompt shapes instead of broadly matching any line ending with $, #, %, or >. This prevents stripping real command output like "100%", "
", or "item #". --- .../executeStrategy/strategyHelpers.ts | 27 +++++-- .../test/browser/strategyHelpers.test.ts | 79 ++++++++++++++++--- 2 files changed, 85 insertions(+), 21 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts index a79fa6303d1..1877e0173b0 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts @@ -128,19 +128,30 @@ export function stripCommandEchoAndPrompt(output: string, commandLine: string): // Strip trailing lines that are part of the next shell prompt. Prompts may // span multiple lines due to terminal column wrapping. We strip from the - // bottom any line that is either: - // - Empty/whitespace - // - Ends with a prompt character ($, >, #, %) - // - Looks like a shell prompt (contains ] $ or user@host patterns) + // bottom any line that matches a known prompt pattern. Patterns are + // intentionally anchored and specific to avoid stripping legitimate output + // that happens to end with characters like $, #, %, or >. let endIndex = lines.length; while (endIndex > startIndex) { const line = lines[endIndex - 1].trimEnd(); if ( line.length === 0 || - /[>$#%]\s*$/.test(line) || - /\]\s*\$/.test(line) || - /\w+@[\w-]+:/.test(line) || - /^\[?\s*\w+@/.test(line) + // Bash/zsh prompt: user@host:path ending with $ or # + // e.g., "user@host:~/src $ " or "root@server:/var/log# " + /^\s*\w+@[\w.-]+:.*[#$]\s*$/.test(line) || + // Bracketed prompt start: [ user@host:/path (wrapped prompt first line) + // e.g., "[ alex@MacBook-Pro:/Users/alex/src/vscode4/extensions/vscode-api-test" + /^\[\s*\w+@[\w.-]+:/.test(line) || + // Bracketed prompt end: ...] $ or ...] # + // e.g., "s/testWorkspace (main**) ] $ " + /\]\s*[#$]\s*$/.test(line) || + // PowerShell prompt: PS C:\path> + /^PS\s+[A-Z]:\\.*>\s*$/.test(line) || + // Windows cmd prompt: C:\path> + /^[A-Z]:\\.*>\s*$/.test(line) || + // Starship prompt character (❯) // allow-any-unicode-next-line /\u276f\s*$/.test(line) || + // Python REPL prompt + /^>>>\s*$/.test(line) ) { endIndex--; } else { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts index 9fd2cec80be..1a9986c1767 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts @@ -94,20 +94,73 @@ suite('stripCommandEchoAndPrompt', () => { ); }); - test('strips trailing prompt with various prompt characters ($ > # %)', () => { - for (const promptChar of ['$', '>', '#', '%']) { - const output = [ - `prompt ${promptChar} echo hello`, - 'hello', - `prompt ${promptChar} `, - ].join('\n'); + test('strips trailing prompt with various prompt styles', () => { + // bash user@host:path $ + assert.strictEqual( + stripCommandEchoAndPrompt( + ['user@host:~ $ echo hello', 'hello', 'user@host:~ $ '].join('\n'), + 'echo hello' + ), + 'hello', + 'Failed for bash $ prompt' + ); + // root user@host:path # + assert.strictEqual( + stripCommandEchoAndPrompt( + ['root@server:/var/log# echo hello', 'hello', 'root@server:/var/log# '].join('\n'), + 'echo hello' + ), + 'hello', + 'Failed for root # prompt' + ); + // bracketed prompt ending with ] $ + assert.strictEqual( + stripCommandEchoAndPrompt( + ['s/workspace ] $ echo hello', 'hello', 's/workspace ] $ '].join('\n'), + 'echo hello' + ), + 'hello', + 'Failed for bracketed ] $ prompt' + ); + // PowerShell PS C:\> + assert.strictEqual( + stripCommandEchoAndPrompt( + ['PS C:\\Users\\test> echo hello', 'hello', 'PS C:\\Users\\test>'].join('\n'), + 'echo hello' + ), + 'hello', + 'Failed for PowerShell prompt' + ); + }); - assert.strictEqual( - stripCommandEchoAndPrompt(output, 'echo hello'), - 'hello', - `Failed for prompt character: ${promptChar}` - ); - } + test('does not strip output lines ending with prompt-like characters', () => { + // Output ending with % (e.g. percentage) + assert.strictEqual( + stripCommandEchoAndPrompt( + ['user@host:~ $ echo "100%"', '100%', 'user@host:~ $ '].join('\n'), + 'echo "100%"' + ), + '100%', + 'Should not strip line ending with %' + ); + // Output ending with > (e.g. HTML or comparison) + assert.strictEqual( + stripCommandEchoAndPrompt( + ['user@host:~ $ echo "
"', '
', 'user@host:~ $ '].join('\n'), + 'echo "
"' + ), + '
', + 'Should not strip line ending with >' + ); + // Output ending with # (e.g. comment marker) + assert.strictEqual( + stripCommandEchoAndPrompt( + ['user@host:~ $ echo "item #"', 'item #', 'user@host:~ $ '].join('\n'), + 'echo "item #"' + ), + 'item #', + 'Should not strip line ending with #' + ); }); test('handles command with leading space (history prevention)', () => { From d4359ab0d887839a59c38c8134548ce9ddf5c46e Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sat, 21 Mar 2026 18:07:41 +0100 Subject: [PATCH 04/22] Review feedback --- .../contrib/terminal/common/terminalConfiguration.ts | 2 +- .../browser/executeStrategy/strategyHelpers.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts index ab230b025f6..a6b81d3a1df 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts @@ -634,7 +634,7 @@ const terminalConfiguration: IStringDictionary = { }, [TerminalSettingId.ShellIntegrationTimeout]: { restricted: true, - markdownDescription: localize('terminal.integrated.shellIntegration.timeout', "Configures the duration in milliseconds to wait for shell integration after launch before declaring it's not there. Set to {0} to wait the minimum time (500ms), the default value {1} means the wait time is variable based on whether shell integration injection is enabled and whether it's a remote window. Consider setting this to a small value if you intentionally disabled shell integration, or a large value if your shell starts very slowly.", '`0`', '`-1`'), + markdownDescription: localize('terminal.integrated.shellIntegration.timeout', "Configures the duration in milliseconds to wait for shell integration after launch before declaring it's not there. Set to {0} to skip the wait entirely, the default value {1} means the wait time is variable based on whether shell integration injection is enabled and whether it's a remote window. Values between 1 and 499 are clamped to 500ms. Consider setting this to {0} if you intentionally disabled shell integration, or a large value if your shell starts very slowly.", '`0`', '`-1`'), type: 'integer', minimum: -1, maximum: 60000, diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts index 1877e0173b0..404563837d0 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts @@ -107,9 +107,9 @@ export function stripCommandEchoAndPrompt(output: string, commandLine: string): // For continuation lines of a wrapped command: if we already matched // the first echo line, keep consuming lines whose content is clearly // part of the wrapped command (e.g. long env var assignments, paths - // with slashes, or sandbox wrapper fragments). We require the line to - // end WITHOUT a newline-induced break in a word, so it must look like - // a proper continuation — not regular command output. + // with slashes, or sandbox wrapper fragments). This uses a heuristic + // based on the line's presence in the original command and patterns + // like path separators, env var assignments, or quoted strings. if (startIndex > 0 && i === startIndex) { const lineContent = line.trim(); // A continuation line of a wrapped command typically contains From 842c746bb2f68b23a69dc82209384418b9f99d23 Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sat, 21 Mar 2026 18:35:26 +0100 Subject: [PATCH 05/22] fix: skip stale prompt fragments before command echo in stripping In CI, ^C cancellations leave stale prompt fragments before the actual command echo line. The leading-strip loop now continues scanning past unmatched lines until it finds the command echo, instead of breaking on the first non-matching line. --- .../executeStrategy/strategyHelpers.ts | 7 +++ .../test/browser/strategyHelpers.test.ts | 45 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts index 404563837d0..79519627efb 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts @@ -122,6 +122,13 @@ export function stripCommandEchoAndPrompt(output: string, commandLine: string): } } + // If the command echo hasn't been found yet, keep scanning — + // there can be stale prompt fragments (e.g. from ^C) or wrapped + // prompt lines before the actual command echo line. + if (startIndex === 0) { + continue; + } + break; } } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts index 1a9986c1767..10ae82662ac 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts @@ -257,4 +257,49 @@ suite('stripCommandEchoAndPrompt', () => { 'hello' ); }); + + test('strips stale prompt fragments and ^C residue before command echo', () => { + // Simulates CI environment where previous ^C produces stale prompt + // fragments before the actual command echo line + const output = [ + 'ts/testWorkspace$ ^C', + 'cloudtest@5ac6b023c000000:/mnt/vss/_work/vscode/vscode/extensions/vscode-api-tes', + 'ts/testWorkspace$ echo MARKER_123', + 'MARKER_123', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo MARKER_123'), + 'MARKER_123' + ); + }); + + test('strips stale prompt fragments for no-output command', () => { + const output = [ + 'ts/testWorkspace$ ^C', + 'cloudtest@5ac6b023c000000:/mnt/vss/_work/vscode/vscode/extensions/vscode-api-tes', + 'ts/testWorkspace$ true', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'true'), + '' + ); + }); + + test('strips stale prompt fragments for multi-line output', () => { + const output = [ + 'ts/testWorkspace$ ^C', + 'cloudtest@5ac6b023c000000:/mnt/vss/_work/vscode/vscode/extensions/vscode-api-tes', + 'ts/testWorkspace$ echo M1 && echo M2 && echo M3', + 'M1', + 'M2', + 'M3', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo M1 && echo M2 && echo M3'), + 'M1\nM2\nM3' + ); + }); }); From 5c733b67acba3fb8389bf788ef3ed8d3d4a127c6 Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sat, 21 Mar 2026 18:50:17 +0100 Subject: [PATCH 06/22] fix: handle macOS CI prompt format and add stripping to rich strategy - Add trailing prompt patterns for hostname:path user$ (no @ sign) - Handle wrapped prompt fragments like "er$" at line boundaries - Add stripCommandEchoAndPrompt to RichExecuteStrategy marker fallback - Context-aware wrapped prompt continuation detection --- .../executeStrategy/richExecuteStrategy.ts | 8 +++- .../executeStrategy/strategyHelpers.ts | 12 ++++++ .../test/browser/strategyHelpers.test.ts | 40 +++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts index db2ea00213f..dc15b42de0b 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts @@ -14,7 +14,7 @@ import { ITerminalLogService } from '../../../../../../platform/terminal/common/ import type { ITerminalInstance } from '../../../../terminal/browser/terminal.js'; import { trackIdleOnPrompt, type ITerminalExecuteStrategy, type ITerminalExecuteStrategyResult } from './executeStrategy.js'; import type { IMarker as IXtermMarker } from '@xterm/xterm'; -import { createAltBufferPromise, setupRecreatingStartMarker } from './strategyHelpers.js'; +import { createAltBufferPromise, setupRecreatingStartMarker, stripCommandEchoAndPrompt } from './strategyHelpers.js'; import { TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js'; /** @@ -121,6 +121,12 @@ export class RichExecuteStrategy extends Disposable implements ITerminalExecuteS try { output = xterm.getContentsAsText(this._startMarker.value, endMarker); this._log('Fetched output via markers'); + + // The marker-based output includes the command echo and trailing + // prompt lines. Strip them to isolate the actual command output. + if (output !== undefined) { + output = stripCommandEchoAndPrompt(output, commandLine); + } } catch { this._log('Failed to fetch output via markers'); additionalInformationLines.push('Failed to retrieve command output'); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts index 79519627efb..830a77aafd3 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts @@ -139,6 +139,7 @@ export function stripCommandEchoAndPrompt(output: string, commandLine: string): // intentionally anchored and specific to avoid stripping legitimate output // that happens to end with characters like $, #, %, or >. let endIndex = lines.length; + let trailingStrippedCount = 0; while (endIndex > startIndex) { const line = lines[endIndex - 1].trimEnd(); if ( @@ -146,9 +147,19 @@ export function stripCommandEchoAndPrompt(output: string, commandLine: string): // Bash/zsh prompt: user@host:path ending with $ or # // e.g., "user@host:~/src $ " or "root@server:/var/log# " /^\s*\w+@[\w.-]+:.*[#$]\s*$/.test(line) || + // Prompt without @: hostname:path user$ or hostname:path user# + // e.g., "dsm12-be220-abc:testWorkspace runner$" + /^\s*[\w.-]+:\S.*\s\w+[#$]\s*$/.test(line) || + // Wrapped prompt fragment: short word ending with $ or # (e.g. "er$", "ner$") + // These appear when a prompt wraps across terminal columns. + /^\s*\w+[#$]\s*$/.test(line) || // Bracketed prompt start: [ user@host:/path (wrapped prompt first line) // e.g., "[ alex@MacBook-Pro:/Users/alex/src/vscode4/extensions/vscode-api-test" /^\[\s*\w+@[\w.-]+:/.test(line) || + // Wrapped prompt continuation: hostname:path or hostname:path user (no trailing $) + // Only matched after we've already stripped a prompt fragment below. + // e.g., "dsm12-be220-abc:testWorkspace runn" (the "er$" was on the next line) + (trailingStrippedCount > 0 && /^\s*[\w][-\w.]*:\S/.test(line)) || // Bracketed prompt end: ...] $ or ...] # // e.g., "s/testWorkspace (main**) ] $ " /\]\s*[#$]\s*$/.test(line) || @@ -161,6 +172,7 @@ export function stripCommandEchoAndPrompt(output: string, commandLine: string): /^>>>\s*$/.test(line) ) { endIndex--; + trailingStrippedCount++; } else { break; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts index 10ae82662ac..1cc296cf6ff 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts @@ -302,4 +302,44 @@ suite('stripCommandEchoAndPrompt', () => { 'M1\nM2\nM3' ); }); + + test('strips trailing prompt without @ (hostname:path user$)', () => { + const output = [ + 'dsm12-be220-abc:testWorkspace runner$ echo hello', + 'hello', + 'dsm12-be220-abc:testWorkspace runner$', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo hello'), + 'hello' + ); + }); + + test('strips wrapped trailing prompt without @ (hostname:path + fragment$)', () => { + const output = [ + 'dsm12-be220-abc:testWorkspace runner$ echo hello', + 'hello', + 'dsm12-be220-8627ea7f-2c5a-40cd-8ba1-bf324bb4f59a-DA35C080942E:testWorkspace runn', + 'er$', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo hello'), + 'hello' + ); + }); + + test('strips trailing prompt fragment for no-output command', () => { + const output = [ + 'dsm12-be220-abc:testWorkspace runner$ true', + 'dsm12-be220-8627ea7f-2c5a-40cd-8ba1-bf324bb4f59a-DA35C080942E:testWorkspace runn', + 'er$', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'true'), + '' + ); + }); }); From 865568dbbc37932202a945738e93d4d21a5f06c6 Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sat, 21 Mar 2026 19:06:50 +0100 Subject: [PATCH 07/22] fix: Linux CI sandbox prereqs, platform-aware tests, broader prompt stripping - Add bubblewrap and socat to Linux CI apt-get install - Make sandbox test assertions platform-aware (macFileSystem vs linuxFileSystem) - Make /etc/shells test accept both macOS and Linux first-line format - Broaden wrapped prompt fragment regex to handle path chars (ts/testWorkspace$) - Fix continuation pattern to match user@host:path wrapped lines - Apply stripCommandEchoAndPrompt to getOutput() in BasicExecuteStrategy (basic shell integration lacks reliable 133;C markers so getOutput() can include command echo) - Keep RichExecuteStrategy getOutput() unstripped (rich integration has reliable markers) --- .github/workflows/pr-linux-test.yml | 4 +++- .../chat.runInTerminal.test.ts | 16 +++++++++++++--- .../executeStrategy/basicExecuteStrategy.ts | 2 +- .../browser/executeStrategy/strategyHelpers.ts | 10 +++++----- .../test/browser/strategyHelpers.test.ts | 14 ++++++++++++++ 5 files changed, 36 insertions(+), 10 deletions(-) diff --git a/.github/workflows/pr-linux-test.yml b/.github/workflows/pr-linux-test.yml index 7922ec107f9..9d0fb76b436 100644 --- a/.github/workflows/pr-linux-test.yml +++ b/.github/workflows/pr-linux-test.yml @@ -42,7 +42,9 @@ jobs: libxkbfile-dev \ libkrb5-dev \ libgbm1 \ - rpm + rpm \ + bubblewrap \ + socat sudo cp build/azure-pipelines/linux/xvfb.init /etc/init.d/xvfb sudo chmod +x /etc/init.d/xvfb sudo update-rc.d xvfb defaults diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts index 4f226d1d844..47d5a9f0e84 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts @@ -9,6 +9,10 @@ import * as vscode from 'vscode'; import { DeferredPromise, assertNoRpc, closeAllEditors, disposeAll } from '../utils'; const isWindows = process.platform === 'win32'; +const isMacOS = process.platform === 'darwin'; +const sandboxFileSystemSetting = isMacOS + ? 'chat.tools.terminal.sandbox.macFileSystem' + : 'chat.tools.terminal.sandbox.linuxFileSystem'; /** * Extracts all text content from a LanguageModelToolResult. @@ -262,7 +266,7 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { // output analyzer prepends its error message. assert.strictEqual(output.trim(), [ 'Command failed while running in sandboxed mode. If the command failed due to sandboxing:', - '- If it would be reasonable to extend the sandbox rules, work with the user to update allowWrite for file system access problems in chat.tools.terminal.sandbox.macFileSystem, or to add required domains to chat.tools.terminal.sandbox.network.allowedDomains.', + `- If it would be reasonable to extend the sandbox rules, work with the user to update allowWrite for file system access problems in ${sandboxFileSystemSetting}, or to add required domains to chat.tools.terminal.sandbox.network.allowedDomains.`, '- Otherwise, immediately retry the command with requestUnsandboxedExecution=true. Do NOT ask the user \u2014 setting this flag automatically shows a confirmation prompt to the user.', '', 'Here is the output of the command:', @@ -282,7 +286,7 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { assert.strictEqual(output.trim(), [ 'Command failed while running in sandboxed mode. If the command failed due to sandboxing:', - '- If it would be reasonable to extend the sandbox rules, work with the user to update allowWrite for file system access problems in chat.tools.terminal.sandbox.macFileSystem, or to add required domains to chat.tools.terminal.sandbox.network.allowedDomains.', + `- If it would be reasonable to extend the sandbox rules, work with the user to update allowWrite for file system access problems in ${sandboxFileSystemSetting}, or to add required domains to chat.tools.terminal.sandbox.network.allowedDomains.`, '- Otherwise, immediately retry the command with requestUnsandboxedExecution=true. Do NOT ask the user \u2014 setting this flag automatically shows a confirmation prompt to the user.', '', 'Here is the output of the command:', @@ -299,7 +303,13 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { const output = await invokeRunInTerminal('head -1 /etc/shells'); - assert.strictEqual(output.trim(), '# List of acceptable shells for chpass(1).'); + const trimmed = output.trim(); + // macOS: "# List of acceptable shells for chpass(1)." + // Linux: "# /etc/shells: valid login shells" + assert.ok( + trimmed.startsWith('#'), + `Expected a comment line from /etc/shells, got: ${trimmed}` + ); }); test('can write inside the workspace folder', async function () { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts index c63a9d93b98..cf60da0ead5 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts @@ -168,7 +168,7 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute const commandOutput = finishedCommand?.getOutput(); if (commandOutput !== undefined) { this._log('Fetched output via finished command'); - output = commandOutput; + output = stripCommandEchoAndPrompt(commandOutput, commandLine); } } if (output === undefined) { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts index 830a77aafd3..eab542d9ed3 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts @@ -150,16 +150,16 @@ export function stripCommandEchoAndPrompt(output: string, commandLine: string): // Prompt without @: hostname:path user$ or hostname:path user# // e.g., "dsm12-be220-abc:testWorkspace runner$" /^\s*[\w.-]+:\S.*\s\w+[#$]\s*$/.test(line) || - // Wrapped prompt fragment: short word ending with $ or # (e.g. "er$", "ner$") + // Wrapped prompt fragment ending with $ or # (e.g. "er$", "ts/testWorkspace$") // These appear when a prompt wraps across terminal columns. - /^\s*\w+[#$]\s*$/.test(line) || + /^\s*[\w/.-]+[#$]\s*$/.test(line) || // Bracketed prompt start: [ user@host:/path (wrapped prompt first line) // e.g., "[ alex@MacBook-Pro:/Users/alex/src/vscode4/extensions/vscode-api-test" /^\[\s*\w+@[\w.-]+:/.test(line) || - // Wrapped prompt continuation: hostname:path or hostname:path user (no trailing $) + // Wrapped prompt continuation: user@host:path or hostname:path (no trailing $) // Only matched after we've already stripped a prompt fragment below. - // e.g., "dsm12-be220-abc:testWorkspace runn" (the "er$" was on the next line) - (trailingStrippedCount > 0 && /^\s*[\w][-\w.]*:\S/.test(line)) || + // e.g., "cloudtest@host:/mnt/vss/.../vscode-api-tes" or "dsm12-abc:testWorkspace runn" + (trailingStrippedCount > 0 && /^\s*[\w][-\w.]*(@[\w.-]+)?:\S/.test(line)) || // Bracketed prompt end: ...] $ or ...] # // e.g., "s/testWorkspace (main**) ] $ " /\]\s*[#$]\s*$/.test(line) || diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts index 1cc296cf6ff..ef23e5bb7a8 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts @@ -330,6 +330,20 @@ suite('stripCommandEchoAndPrompt', () => { ); }); + test('strips wrapped trailing prompt with path-like fragment (ts/testWorkspace$)', () => { + const output = [ + 'user@host:~ $ echo hello', + 'hello', + 'cloudtest@d4b0d881c000000:/mnt/vss/_work/vscode/vscode/extensions/vscode-api-tes', + 'ts/testWorkspace$', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo hello'), + 'hello' + ); + }); + test('strips trailing prompt fragment for no-output command', () => { const output = [ 'dsm12-be220-abc:testWorkspace runner$ true', From 22913a387deb8b642607e5ac8afbb6604a690bef Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sat, 21 Mar 2026 19:20:59 +0100 Subject: [PATCH 08/22] fix: detect sandbox failures heuristically when exit code is unavailable --- .../browser/tools/sandboxOutputAnalyzer.ts | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts index 658c64ad05a..9772e0a2cce 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts @@ -17,10 +17,14 @@ export class SandboxOutputAnalyzer extends Disposable implements IOutputAnalyzer } async analyze(options: IOutputAnalyzerOptions): Promise { - if (options.exitCode === undefined || options.exitCode === 0) { + if (!options.isSandboxWrapped) { return undefined; } - if (!options.isSandboxWrapped) { + + const knownFailure = options.exitCode !== undefined && options.exitCode !== 0; + const suspectedFailure = !knownFailure && options.exitCode === undefined && this._outputLooksSandboxBlocked(options.exitResult); + + if (!knownFailure && !suspectedFailure) { return undefined; } @@ -28,10 +32,22 @@ export class SandboxOutputAnalyzer extends Disposable implements IOutputAnalyzer const fileSystemSetting = os === OperatingSystem.Linux ? TerminalChatAgentToolsSettingId.TerminalSandboxLinuxFileSystem : TerminalChatAgentToolsSettingId.TerminalSandboxMacFileSystem; - return `Command failed while running in sandboxed mode. If the command failed due to sandboxing: + + const prefix = knownFailure + ? 'Command failed while running in sandboxed mode. If the command failed due to sandboxing:' + : 'Command ran in sandboxed mode and may have been blocked by the sandbox. If the command failed due to sandboxing:'; + return `${prefix} - If it would be reasonable to extend the sandbox rules, work with the user to update allowWrite for file system access problems in ${fileSystemSetting}, or to add required domains to ${TerminalChatAgentToolsSettingId.TerminalSandboxNetwork}.allowedDomains. - Otherwise, immediately retry the command with requestUnsandboxedExecution=true. Do NOT ask the user — setting this flag automatically shows a confirmation prompt to the user. Here is the output of the command:\n`; } + + /** + * Checks whether the command output contains strings that typically indicate + * the sandbox blocked the operation. Used when exit code is unavailable. + */ + private _outputLooksSandboxBlocked(output: string): boolean { + return /Operation not permitted|Permission denied|sandbox-exec|bwrap|sandbox_violation/i.test(output); + } } From f4c042bfc8379fa33b1d00a915c9a6ebec1963ed Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sat, 21 Mar 2026 19:56:04 +0100 Subject: [PATCH 09/22] Relax some tests when shell integration is off --- .../chat.runInTerminal.test.ts | 61 +++++++++++-------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts index 47d5a9f0e84..08f7c11d3be 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts @@ -168,16 +168,16 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { await toolConfig.update('idlePollInterval', undefined, vscode.ConfigurationTarget.Global); }); - defineTests(); + defineTests(false); }); // --- Shell integration ON --- suite('shell integration on', () => { - defineTests(); + defineTests(true); }); - function defineTests() { + function defineTests(hasShellIntegration: boolean) { // --- Sandbox OFF tests --- @@ -220,7 +220,12 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { const command = isWindows ? 'cmd /c exit 42' : 'bash -c "exit 42"'; const output = await invokeRunInTerminal(command); - assert.strictEqual(output.trim(), 'Command produced no output\nCommand exited with code 42'); + // Without shell integration, exit codes are unavailable + const acceptable = [ + 'Command produced no output\nCommand exited with code 42', + ...(!hasShellIntegration ? ['Command produced no output'] : []), + ]; + assert.ok(acceptable.includes(output.trim()), `Unexpected output: ${JSON.stringify(output.trim())}`); }); test('output with special characters is captured verbatim', async function () { @@ -262,20 +267,25 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { const output = await invokeRunInTerminal('curl -s --max-time 5 https://example.com'); - // The sandbox blocks network access. curl fails and the sandbox - // output analyzer prepends its error message. - assert.strictEqual(output.trim(), [ - 'Command failed while running in sandboxed mode. If the command failed due to sandboxing:', - `- If it would be reasonable to extend the sandbox rules, work with the user to update allowWrite for file system access problems in ${sandboxFileSystemSetting}, or to add required domains to chat.tools.terminal.sandbox.network.allowedDomains.`, - '- Otherwise, immediately retry the command with requestUnsandboxedExecution=true. Do NOT ask the user \u2014 setting this flag automatically shows a confirmation prompt to the user.', - '', - 'Here is the output of the command:', - '', - '', - '', - 'Command produced no output', - 'Command exited with code 56', - ].join('\n')); + // Without shell integration, exit code is unavailable and + // curl produces no sandbox-specific error strings, so the + // sandbox analyzer may not trigger. + const acceptable = [ + [ + 'Command failed while running in sandboxed mode. If the command failed due to sandboxing:', + `- If it would be reasonable to extend the sandbox rules, work with the user to update allowWrite for file system access problems in ${sandboxFileSystemSetting}, or to add required domains to chat.tools.terminal.sandbox.network.allowedDomains.`, + '- Otherwise, immediately retry the command with requestUnsandboxedExecution=true. Do NOT ask the user \u2014 setting this flag automatically shows a confirmation prompt to the user.', + '', + 'Here is the output of the command:', + '', + '', + '', + 'Command produced no output', + 'Command exited with code 56', + ].join('\n'), + ...(!hasShellIntegration ? ['Command produced no output'] : []), + ]; + assert.ok(acceptable.includes(output.trim()), `Unexpected output: ${JSON.stringify(output.trim())}`); }); test('cannot write to /tmp', async function () { @@ -284,18 +294,21 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { const marker = `SANDBOX_TMP_${Date.now()}`; const output = await invokeRunInTerminal(`echo "${marker}" > /tmp/${marker}.txt`); - assert.strictEqual(output.trim(), [ - 'Command failed while running in sandboxed mode. If the command failed due to sandboxing:', + const sandboxBody = [ `- If it would be reasonable to extend the sandbox rules, work with the user to update allowWrite for file system access problems in ${sandboxFileSystemSetting}, or to add required domains to chat.tools.terminal.sandbox.network.allowedDomains.`, '- Otherwise, immediately retry the command with requestUnsandboxedExecution=true. Do NOT ask the user \u2014 setting this flag automatically shows a confirmation prompt to the user.', '', 'Here is the output of the command:', '', `/bin/bash: /tmp/${marker}.txt: Operation not permitted`, - '', - '', - 'Command exited with code 1', - ].join('\n')); + ].join('\n'); + const acceptable = [ + // With shell integration: known failure with exit code + `Command failed while running in sandboxed mode. If the command failed due to sandboxing:\n${sandboxBody}\n\n\nCommand exited with code 1`, + // Without shell integration: heuristic detection, no exit code + ...(!hasShellIntegration ? [`Command ran in sandboxed mode and may have been blocked by the sandbox. If the command failed due to sandboxing:\n${sandboxBody}`] : []), + ]; + assert.ok(acceptable.includes(output.trim()), `Unexpected output: ${JSON.stringify(output.trim())}`); }); test('can read files outside the workspace', async function () { From 7517f8a3ee7ee242c6fe72e2479149780edba0de Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sat, 21 Mar 2026 21:44:03 +0100 Subject: [PATCH 10/22] refactor: extract findCommandEcho and use prompt evidence to narrow trailing prompt regex matching --- .../executeStrategy/basicExecuteStrategy.ts | 4 +- .../executeStrategy/noneExecuteStrategy.ts | 2 +- .../executeStrategy/richExecuteStrategy.ts | 8 +- .../executeStrategy/strategyHelpers.ts | 144 ++++++++++-------- .../test/browser/strategyHelpers.test.ts | 24 +++ 5 files changed, 117 insertions(+), 65 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts index cf60da0ead5..dab3f1829c5 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts @@ -168,7 +168,7 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute const commandOutput = finishedCommand?.getOutput(); if (commandOutput !== undefined) { this._log('Fetched output via finished command'); - output = stripCommandEchoAndPrompt(commandOutput, commandLine); + output = stripCommandEchoAndPrompt(commandOutput, commandLine, this._log.bind(this)); } } if (output === undefined) { @@ -179,7 +179,7 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute // The marker-based output includes the command echo and trailing // prompt lines. Strip them to isolate the actual command output. if (output !== undefined) { - output = stripCommandEchoAndPrompt(output, commandLine); + output = stripCommandEchoAndPrompt(output, commandLine, this._log.bind(this)); } } catch { this._log('Failed to fetch output via markers'); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts index 16c0a21b528..2d359f2171e 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts @@ -121,7 +121,7 @@ export class NoneExecuteStrategy extends Disposable implements ITerminalExecuteS // command echo (since the start marker is placed at the cursor before // sendText), and trailing lines that look like shell prompts are removed. if (output !== undefined) { - output = stripCommandEchoAndPrompt(output, commandLine); + output = stripCommandEchoAndPrompt(output, commandLine, this._log.bind(this)); } } catch { this._log('Failed to fetch output via markers'); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts index dc15b42de0b..f22472af0f3 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts @@ -114,7 +114,11 @@ export class RichExecuteStrategy extends Disposable implements ITerminalExecuteS const commandOutput = finishedCommand?.getOutput(); if (commandOutput !== undefined) { this._log('Fetched output via finished command'); - output = commandOutput; + // On some platforms (e.g. Windows/PowerShell), shell integration + // markers can misfire and getOutput() includes the command echo. + // Strip it defensively — the function is a no-op when the output + // is already clean. + output = stripCommandEchoAndPrompt(commandOutput, commandLine, this._log.bind(this)); } } if (output === undefined) { @@ -125,7 +129,7 @@ export class RichExecuteStrategy extends Disposable implements ITerminalExecuteS // The marker-based output includes the command echo and trailing // prompt lines. Strip them to isolate the actual command output. if (output !== undefined) { - output = stripCommandEchoAndPrompt(output, commandLine); + output = stripCommandEchoAndPrompt(output, commandLine, this._log.bind(this)); } } catch { this._log('Failed to fetch output via markers'); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts index eab542d9ed3..477fcb9206a 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts @@ -81,57 +81,25 @@ export function createAltBufferPromise( * * This function removes (1) and (3) to isolate the actual output. */ -export function stripCommandEchoAndPrompt(output: string, commandLine: string): string { - const lines = output.split('\n'); +export function stripCommandEchoAndPrompt(output: string, commandLine: string, log?: (message: string) => void): string { + log?.(`stripCommandEchoAndPrompt input: ${JSON.stringify(output)}, commandLine: ${JSON.stringify(commandLine)}`); - // Strip leading lines that are part of the command echo. The start marker - // is placed at the cursor before sendText, so the first captured line(s) - // contain the prompt + command text, possibly wrapped across terminal columns. - let startIndex = 0; - const trimmedCommand = commandLine.trim(); - if (trimmedCommand.length > 0) { - // Use a short prefix of the command for matching — enough to be unique - // but handles terminal wrapping where lines are fragments. - const commandPrefix = trimmedCommand.substring(0, Math.min(30, trimmedCommand.length)); + // Strip leading lines that are part of the command echo using findCommandEcho. + const echoResult = findCommandEcho(output, commandLine); + const lines = echoResult ? echoResult.linesAfter : output.split('\n'); + const startIndex = 0; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - // Check if this line contains the beginning of the command (handles - // prompt prefix like `user@host:dir] $ ` on the first line) - if (line.includes(commandPrefix)) { - startIndex = i + 1; - continue; - } - - // For continuation lines of a wrapped command: if we already matched - // the first echo line, keep consuming lines whose content is clearly - // part of the wrapped command (e.g. long env var assignments, paths - // with slashes, or sandbox wrapper fragments). This uses a heuristic - // based on the line's presence in the original command and patterns - // like path separators, env var assignments, or quoted strings. - if (startIndex > 0 && i === startIndex) { - const lineContent = line.trim(); - // A continuation line of a wrapped command typically contains - // path separators, env var assignments, or quoted strings — not - // plain output text. Also require it to appear in the command. - if (lineContent.length > 0 && trimmedCommand.includes(lineContent) && - (/[\/\\=]/.test(lineContent) || /^['"]/.test(lineContent))) { - startIndex = i + 1; - continue; - } - } - - // If the command echo hasn't been found yet, keep scanning — - // there can be stale prompt fragments (e.g. from ^C) or wrapped - // prompt lines before the actual command echo line. - if (startIndex === 0) { - continue; - } - - break; - } - } + // Use evidence from the prompt prefix (content before the command echo) + // to narrow down which trailing prompt patterns to check. + const promptBefore = echoResult?.contentBefore ?? ''; + const isUnixAt = /\w+@[\w.-]+:/.test(promptBefore); + const isUnixHost = !isUnixAt && /[\w.-]+:\S/.test(promptBefore); + const isUnix = isUnixAt || isUnixHost; + const isPowerShell = /^PS\s/i.test(promptBefore); + const isCmd = !isPowerShell && /^[A-Z]:\\/.test(promptBefore); + const isStarship = /\u276f/.test(promptBefore); + const isPython = />>>/.test(promptBefore); + const knownPrompt = isUnix || isPowerShell || isCmd || isStarship || isPython; // Strip trailing lines that are part of the next shell prompt. Prompts may // span multiple lines due to terminal column wrapping. We strip from the @@ -146,30 +114,32 @@ export function stripCommandEchoAndPrompt(output: string, commandLine: string): line.length === 0 || // Bash/zsh prompt: user@host:path ending with $ or # // e.g., "user@host:~/src $ " or "root@server:/var/log# " - /^\s*\w+@[\w.-]+:.*[#$]\s*$/.test(line) || + (!knownPrompt || isUnixAt) && /^\s*\w+@[\w.-]+:.*[#$]\s*$/.test(line) || // Prompt without @: hostname:path user$ or hostname:path user# // e.g., "dsm12-be220-abc:testWorkspace runner$" - /^\s*[\w.-]+:\S.*\s\w+[#$]\s*$/.test(line) || + (!knownPrompt || isUnixHost) && /^\s*[\w.-]+:\S.*\s\w+[#$]\s*$/.test(line) || // Wrapped prompt fragment ending with $ or # (e.g. "er$", "ts/testWorkspace$") // These appear when a prompt wraps across terminal columns. - /^\s*[\w/.-]+[#$]\s*$/.test(line) || + (!knownPrompt || isUnix) && /^\s*[\w/.-]+[#$]\s*$/.test(line) || // Bracketed prompt start: [ user@host:/path (wrapped prompt first line) // e.g., "[ alex@MacBook-Pro:/Users/alex/src/vscode4/extensions/vscode-api-test" - /^\[\s*\w+@[\w.-]+:/.test(line) || + (!knownPrompt || isUnixAt) && /^\[\s*\w+@[\w.-]+:/.test(line) || // Wrapped prompt continuation: user@host:path or hostname:path (no trailing $) // Only matched after we've already stripped a prompt fragment below. // e.g., "cloudtest@host:/mnt/vss/.../vscode-api-tes" or "dsm12-abc:testWorkspace runn" - (trailingStrippedCount > 0 && /^\s*[\w][-\w.]*(@[\w.-]+)?:\S/.test(line)) || + (!knownPrompt || isUnix) && trailingStrippedCount > 0 && /^\s*[\w][-\w.]*(@[\w.-]+)?:\S/.test(line) || // Bracketed prompt end: ...] $ or ...] # // e.g., "s/testWorkspace (main**) ] $ " - /\]\s*[#$]\s*$/.test(line) || + (!knownPrompt || isUnixAt) && /\]\s*[#$]\s*$/.test(line) || // PowerShell prompt: PS C:\path> - /^PS\s+[A-Z]:\\.*>\s*$/.test(line) || + (!knownPrompt || isPowerShell) && /^PS\s+[A-Z]:\\.*>\s*$/.test(line) || // Windows cmd prompt: C:\path> - /^[A-Z]:\\.*>\s*$/.test(line) || - // Starship prompt character (❯) // allow-any-unicode-next-line /\u276f\s*$/.test(line) || + (!knownPrompt || isCmd) && /^[A-Z]:\\.*>\s*$/.test(line) || + // Starship prompt character + // allow-any-unicode-next-line + (!knownPrompt || isStarship) && /\u276f\s*$/.test(line) || // Python REPL prompt - /^>>>\s*$/.test(line) + (!knownPrompt || isPython) && /^>>>\s*$/.test(line) ) { endIndex--; trailingStrippedCount++; @@ -178,5 +148,59 @@ export function stripCommandEchoAndPrompt(output: string, commandLine: string): } } - return lines.slice(startIndex, endIndex).join('\n'); + const result = lines.slice(startIndex, endIndex).join('\n'); + log?.(`stripCommandEchoAndPrompt result: ${JSON.stringify(result)} (startIndex=${startIndex}, endIndex=${endIndex}, totalLines=${lines.length})`); + return result; +} + +export function findCommandEcho(output: string, commandLine: string): { contentBefore: string; linesAfter: string[] } | undefined { + const trimmedCommand = commandLine.trim(); + if (trimmedCommand.length === 0) { + return undefined; + } + + // Strip newlines from the output so we can find the command as a + // contiguous substring even when terminal wrapping splits it across lines. + const { strippedOutput, indexMapping } = stripNewLinesAndBuildMapping(output); + const matchIndex = strippedOutput.indexOf(trimmedCommand); + if (matchIndex === -1) { + return undefined; + } + + // The content before the command in the stripped output is the prompt text + // (e.g. "user@host:~/src $ "). Trim whitespace to get the meaningful part. + const contentBefore = strippedOutput.substring(0, matchIndex).trim(); + + // Map the match end back to the original output position and determine + // which line it falls on to split linesAfter. + const originalEnd = indexMapping[matchIndex + trimmedCommand.length - 1]; + + const lines = output.split('\n'); + let echoEndLine = 0; + let offset = 0; + for (let i = 0; i < lines.length; i++) { + const lineEnd = offset + lines[i].length; // excludes the \n + if (offset <= originalEnd && originalEnd <= lineEnd) { + echoEndLine = i + 1; + break; + } + offset = lineEnd + 1; // +1 for the \n + } + + return { + contentBefore, + linesAfter: lines.slice(echoEndLine), + }; +} + +export function stripNewLinesAndBuildMapping(output: string): { strippedOutput: string; indexMapping: number[] } { + const indexMapping: number[] = []; + let strippedOutput = ''; + for (let i = 0; i < output.length; i++) { + if (output[i] !== '\n') { + strippedOutput += output[i]; + indexMapping.push(i); + } + } + return { strippedOutput, indexMapping }; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts index ef23e5bb7a8..638d23fe4f4 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts @@ -356,4 +356,28 @@ suite('stripCommandEchoAndPrompt', () => { '' ); }); + + test('strips mid-word wrapped command continuation (PowerShell/Windows)', () => { + // PowerShell wraps "echo MARKER_123_ECHO" across lines at column boundary + const output = [ + 'PS D:\\a\\_work\\vscode\\testWorkspace> echo MARK', + 'ER_123_ECHO', + 'MARKER_123_ECHO', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo MARKER_123_ECHO'), + 'MARKER_123_ECHO' + ); + }); + + test('strips PowerShell prompt from getOutput() result', () => { + // When shell integration markers misfire, getOutput() includes the prompt + command + const output = 'PS D:\\a\\_work\\vscode\\testWorkspace> cmd /c exit 42'; + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'cmd /c exit 42'), + '' + ); + }); }); From f4644120bce148e9f6c063002e2c0456cae73e53 Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sat, 21 Mar 2026 22:28:19 +0100 Subject: [PATCH 11/22] Cover case where the command is duplicated in `stripCommandEchoAndPrompt` --- .../chat.runInTerminal.test.ts | 6 ++--- .../executeStrategy/strategyHelpers.ts | 15 +++++++++++ .../test/browser/strategyHelpers.test.ts | 25 +++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts index 08f7c11d3be..d53006ce05e 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts @@ -25,7 +25,7 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { } // https://github.com/microsoft/vscode/issues/303531 -(vscode.env.uiKind === vscode.UIKind.Web ? suite.skip : suite)('chat - run_in_terminal (issue #303531)', () => { +(vscode.env.uiKind === vscode.UIKind.Web ? suite.skip : suite)('chat - run_in_terminal', () => { let disposables: vscode.Disposable[] = []; @@ -156,7 +156,7 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { await termConfig.update('shellIntegration.timeout', 0, vscode.ConfigurationTarget.Global); const toolConfig = vscode.workspace.getConfiguration('chat.tools.terminal'); - await toolConfig.update('idlePollInterval', 50, vscode.ConfigurationTarget.Global); + await toolConfig.update('idlePollInterval', 100, vscode.ConfigurationTarget.Global); }); teardown(async () => { @@ -304,7 +304,7 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { ].join('\n'); const acceptable = [ // With shell integration: known failure with exit code - `Command failed while running in sandboxed mode. If the command failed due to sandboxing:\n${sandboxBody}\n\n\nCommand exited with code 1`, + `Command failed while running in sandboxed mode. If the command failed due to sandboxing:\n${sandboxBody}\n\nCommand exited with code 1`, // Without shell integration: heuristic detection, no exit code ...(!hasShellIntegration ? [`Command ran in sandboxed mode and may have been blocked by the sandbox. If the command failed due to sandboxing:\n${sandboxBody}`] : []), ]; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts index 477fcb9206a..326947c875c 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts @@ -84,6 +84,21 @@ export function createAltBufferPromise( export function stripCommandEchoAndPrompt(output: string, commandLine: string, log?: (message: string) => void): string { log?.(`stripCommandEchoAndPrompt input: ${JSON.stringify(output)}, commandLine: ${JSON.stringify(commandLine)}`); + const result = _stripCommandEchoAndPromptOnce(output, commandLine, log); + + // After stripping the first command echo and trailing prompt, the remaining + // content may still contain the command re-echoed by the shell (prompt + echo). + // This happens when the terminal buffer captures both the raw sendText output + // and the shell's subsequent prompt + command echo. If the command appears again + // in the remaining text, strip it one more time. + if (result.trim().length > 0 && findCommandEcho(result, commandLine)) { + return _stripCommandEchoAndPromptOnce(result, commandLine, log); + } + + return result; +} + +function _stripCommandEchoAndPromptOnce(output: string, commandLine: string, log?: (message: string) => void): string { // Strip leading lines that are part of the command echo using findCommandEcho. const echoResult = findCommandEcho(output, commandLine); const lines = echoResult ? echoResult.linesAfter : output.split('\n'); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts index 638d23fe4f4..38d1880fbde 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts @@ -380,4 +380,29 @@ suite('stripCommandEchoAndPrompt', () => { '' ); }); + + test('strips sandbox-wrapped command echo with error output and trailing prompt', () => { + const commandLine = 'ELECTRON_RUN_AS_NODE=1 PATH="$PATH:/Users/alex/src/vscode4/node_modules/@vscode/ripgrep/bin" TMPDIR="/Users/alex/.vscode-oss-dev/tmp" CLAUDE_TMPDIR="/Users/alex/.vscode-oss-dev/tmp" "/Users/alex/src/vscode4/node_modules/@anthropic-ai/sandbox-runtime/dist/cli.js" --settings "/Users/alex/.vscode-oss-dev/tmp/vscode-sandbox-settings-cf5b6232-825b-4f4c-8902-32a8591007fd.json" -c \' echo "SANDBOX_TMP_1774127409076" > /tmp/SANDBOX_TMP_1774127409076.txt\''; + const output = [ + 'ELECTRON_RUN_AS_NODE=1 PATH="$PATH:/Users/alex/src/vscode4/node_modules/@vscode/', + 'ripgrep/bin" TMPDIR="/Users/alex/.vscode-oss-dev/tmp" CLAUDE_TMPDIR="/Users/alex', + '/.vscode-oss-dev/tmp" "/Users/alex/src/vscode4/node_modules/@anthropic-ai/sandbo', + 'x-runtime/dist/cli.js" --settings "/Users/alex/.vscode-oss-dev/tmp/vscode-sandbo', + 'x-settings-cf5b6232-825b-4f4c-8902-32a8591007fd.json" -c \' echo "SANDBOX_TMP_177', + '4127409076" > /tmp/SANDBOX_TMP_1774127409076.txt\'', + '[ alex@Alexandrus-MacBook-Pro:/Users/alex/src/vscode4/extensions/vscode-api-test', + 's/testWorkspace (alexdima/fix-303531-sandbox-no-output-leak**) ] $ ELECTRON_RUN_', + 'AS_NODE=1 PATH="$PATH:/Users/alex/src/vscode4/node_modules/@vscode/ripgrep/bin" ', + 'TMPDIR="/Users/alex/.vscode-oss-dev/tmp" CLAUDE_TMPDIR="/Users/alex/.vscode-oss-', + 'dev/tmp" "/Users/alex/src/vscode4/node_modules/@anthropic-ai/sandbox-runtime/dis', + 't/cli.js" --settings "/Users/alex/.vscode-oss-dev/tmp/vscode-sandbox-settings-cf', + '5b6232-825b-4f4c-8902-32a8591007fd.json" -c \' echo "SANDBOX_TMP_1774127409076" >', + ' /tmp/SANDBOX_TMP_1774127409076.txt\'', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, commandLine), + '' + ); + }); }); From 436b09abc3d1c6e578ce37b6dc335d98336dd340 Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sat, 21 Mar 2026 22:36:04 +0100 Subject: [PATCH 12/22] Fix sandbox tests for Linux: handle different shell path and error message - Handle /usr/bin/bash (Linux) vs /bin/bash (macOS) in /tmp write test - Handle 'Read-only file system' (Linux) vs 'Operation not permitted' (macOS) - Add 'Read-only file system' to outputLooksSandboxBlocked heuristic - Replace newlines with spaces (not empty) to handle terminal wrapping - Extract outputLooksSandboxBlocked as exported function with unit tests --- .../chat.runInTerminal.test.ts | 8 +++- .../browser/tools/sandboxOutputAnalyzer.ts | 15 ++++++- .../browser/sandboxOutputAnalyzer.test.ts | 41 +++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sandboxOutputAnalyzer.test.ts diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts index d53006ce05e..e84949f0e10 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts @@ -294,13 +294,19 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { const marker = `SANDBOX_TMP_${Date.now()}`; const output = await invokeRunInTerminal(`echo "${marker}" > /tmp/${marker}.txt`); + // macOS sandbox-exec returns "Operation not permitted" via /bin/bash; + // Linux read-only bind mount returns "Read-only file system" via /usr/bin/bash. + // Some shells include "line N:" in the error (e.g. "/usr/bin/bash: line 1: …"). + const shellError = isMacOS + ? `/bin/bash: /tmp/${marker}.txt: Operation not permitted` + : `/usr/bin/bash: line 1: /tmp/${marker}.txt: Read-only file system`; const sandboxBody = [ `- If it would be reasonable to extend the sandbox rules, work with the user to update allowWrite for file system access problems in ${sandboxFileSystemSetting}, or to add required domains to chat.tools.terminal.sandbox.network.allowedDomains.`, '- Otherwise, immediately retry the command with requestUnsandboxedExecution=true. Do NOT ask the user \u2014 setting this flag automatically shows a confirmation prompt to the user.', '', 'Here is the output of the command:', '', - `/bin/bash: /tmp/${marker}.txt: Operation not permitted`, + shellError, ].join('\n'); const acceptable = [ // With shell integration: known failure with exit code diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts index 9772e0a2cce..8b1687f1d35 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts @@ -46,8 +46,21 @@ Here is the output of the command:\n`; /** * Checks whether the command output contains strings that typically indicate * the sandbox blocked the operation. Used when exit code is unavailable. + * + * The output may contain newlines inserted by terminal wrapping, so we + * strip them before testing. */ private _outputLooksSandboxBlocked(output: string): boolean { - return /Operation not permitted|Permission denied|sandbox-exec|bwrap|sandbox_violation/i.test(output); + return outputLooksSandboxBlocked(output); } } + +/** + * Checks whether the command output contains strings that typically indicate + * the sandbox blocked the operation. The output may contain newlines inserted + * by terminal wrapping, so we strip them before testing. + */ +export function outputLooksSandboxBlocked(output: string): boolean { + const normalized = output.replace(/\n/g, ' '); + return /Operation not permitted|Permission denied|Read-only file system|sandbox-exec|bwrap|sandbox_violation/i.test(normalized); +} diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sandboxOutputAnalyzer.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sandboxOutputAnalyzer.test.ts new file mode 100644 index 00000000000..2349129f7f5 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sandboxOutputAnalyzer.test.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { strictEqual } from 'assert'; +import { outputLooksSandboxBlocked } from '../../browser/tools/sandboxOutputAnalyzer.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; + +suite('outputLooksSandboxBlocked', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const positives: [string, string][] = [ + ['macOS sandbox file write', '/bin/bash: /tmp/test.txt: Operation not permitted'], + ['Linux sandbox file write', '/usr/bin/bash: /tmp/test.txt: Read-only file system'], + ['Permission denied', 'bash: ./script.sh: Permission denied'], + ['sandbox-exec reference', 'sandbox-exec: some error occurred'], + ['bwrap reference', 'bwrap: error setting up namespace'], + ['sandbox_violation', 'sandbox_violation: deny(1) file-write-create /tmp/foo'], + ['case insensitive', '/bin/bash: OPERATION NOT PERMITTED'], + ['wrapped across lines', '/bin/bash: Operation not\npermitted'], + ]; + + for (const [label, output] of positives) { + test(`detects: ${label}`, () => { + strictEqual(outputLooksSandboxBlocked(output), true); + }); + } + + const negatives: [string, string][] = [ + ['normal output', 'hello world'], + ['empty output', ''], + ['unrelated error', 'Error: ENOENT: no such file or directory'], + ]; + + for (const [label, output] of negatives) { + test(`ignores: ${label}`, () => { + strictEqual(outputLooksSandboxBlocked(output), false); + }); + } +}); From 4ed68ee36f92b4adc19ca043a869ec27f569bd9e Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sat, 21 Mar 2026 23:23:32 +0100 Subject: [PATCH 13/22] Fix slash history test --- extensions/vscode-api-tests/src/singlefolder-tests/chat.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/chat.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/chat.test.ts index 6ed6c911718..863f18bb29e 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/chat.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/chat.test.ts @@ -60,6 +60,7 @@ suite('chat', () => { test('participant and slash command history', async () => { const onRequest = setupParticipant(); + await commands.executeCommand('workbench.action.chat.newChat'); commands.executeCommand('workbench.action.chat.open', { query: '@participant /hello friend' }); const deferred = new DeferredPromise(); From 5059232618601fc3be6ef449175773eef8fabb38 Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sun, 22 Mar 2026 00:17:30 +0100 Subject: [PATCH 14/22] Fix sandbox execPath resolution for remote environments Add execPath to IRemoteAgentEnvironment so the server sends its actual process.execPath to the client. The sandbox service now uses this instead of hardcoding appRoot + '/node', which only works in production builds. --- src/vs/platform/remote/common/remoteAgentEnvironment.ts | 1 + src/vs/server/node/remoteAgentEnvironmentImpl.ts | 1 + .../debug/test/browser/debugConfigurationManager.test.ts | 1 + .../chatAgentTools/common/terminalSandboxService.ts | 3 +-- .../chatAgentTools/test/browser/terminalSandboxService.test.ts | 1 + .../services/remote/common/remoteAgentEnvironmentChannel.ts | 2 ++ 6 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/vs/platform/remote/common/remoteAgentEnvironment.ts b/src/vs/platform/remote/common/remoteAgentEnvironment.ts index 51cb401dcfb..e63de4e540f 100644 --- a/src/vs/platform/remote/common/remoteAgentEnvironment.ts +++ b/src/vs/platform/remote/common/remoteAgentEnvironment.ts @@ -12,6 +12,7 @@ export interface IRemoteAgentEnvironment { pid: number; connectionToken: string; appRoot: URI; + execPath: string; tmpDir: URI; settingsPath: URI; mcpResource: URI; diff --git a/src/vs/server/node/remoteAgentEnvironmentImpl.ts b/src/vs/server/node/remoteAgentEnvironmentImpl.ts index 6505a5aa7d8..640c74695a5 100644 --- a/src/vs/server/node/remoteAgentEnvironmentImpl.ts +++ b/src/vs/server/node/remoteAgentEnvironmentImpl.ts @@ -112,6 +112,7 @@ export class RemoteAgentEnvironmentChannel implements IServerChannel { pid: process.pid, connectionToken: (this._connectionToken.type !== ServerConnectionTokenType.None ? this._connectionToken.value : ''), appRoot: URI.file(this._environmentService.appRoot), + execPath: process.execPath, tmpDir: this._environmentService.tmpDir, settingsPath: this._environmentService.machineSettingsResource, mcpResource: this._environmentService.mcpResource, diff --git a/src/vs/workbench/contrib/debug/test/browser/debugConfigurationManager.test.ts b/src/vs/workbench/contrib/debug/test/browser/debugConfigurationManager.test.ts index c75b3fde56d..ecbb3b1ba44 100644 --- a/src/vs/workbench/contrib/debug/test/browser/debugConfigurationManager.test.ts +++ b/src/vs/workbench/contrib/debug/test/browser/debugConfigurationManager.test.ts @@ -137,6 +137,7 @@ suite('debugConfigurationManager', () => { pid: 1, connectionToken: 'token', appRoot: URI.file('/remote/app'), + execPath: '/remote/app/node', tmpDir: URI.file('/remote/tmp'), settingsPath: URI.file('/remote/settings.json'), mcpResource: URI.file('/remote/mcp.json'), diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts index 5b80c60e8b8..19b64d484cf 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts @@ -177,9 +177,8 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb this._srtPathResolved = true; const remoteEnv = this._remoteEnvDetails || await this._remoteEnvDetailsPromise; if (remoteEnv) { - this._appRoot = remoteEnv.appRoot.path; - this._execPath = this._pathJoin(this._appRoot, 'node'); + this._execPath = remoteEnv.execPath; } this._srtPath = this._pathJoin(this._appRoot, 'node_modules', '@anthropic-ai', 'sandbox-runtime', 'dist', 'cli.js'); this._rgPath = this._pathJoin(this._appRoot, 'node_modules', '@vscode', 'ripgrep', 'bin', 'rg'); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts index 0ef5c2bd5a5..33d74053c2b 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts @@ -77,6 +77,7 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { os: OperatingSystem.Linux, tmpDir: URI.file('/tmp'), appRoot: URI.file('/app'), + execPath: '/app/node', pid: 1234, connectionToken: 'test-token', settingsPath: URI.file('/settings'), diff --git a/src/vs/workbench/services/remote/common/remoteAgentEnvironmentChannel.ts b/src/vs/workbench/services/remote/common/remoteAgentEnvironmentChannel.ts index 8e18f822ead..4195dd56c66 100644 --- a/src/vs/workbench/services/remote/common/remoteAgentEnvironmentChannel.ts +++ b/src/vs/workbench/services/remote/common/remoteAgentEnvironmentChannel.ts @@ -29,6 +29,7 @@ export interface IRemoteAgentEnvironmentDTO { pid: number; connectionToken: string; appRoot: UriComponents; + execPath: string; tmpDir: UriComponents; settingsPath: UriComponents; mcpResource: UriComponents; @@ -67,6 +68,7 @@ export class RemoteExtensionEnvironmentChannelClient { pid: data.pid, connectionToken: data.connectionToken, appRoot: URI.revive(data.appRoot), + execPath: data.execPath, tmpDir: URI.revive(data.tmpDir), settingsPath: URI.revive(data.settingsPath), mcpResource: URI.revive(data.mcpResource), From 5563927f890bf4d0cfd1d2b0fb1bd00f26572ab8 Mon Sep 17 00:00:00 2001 From: Alexandru Dima Date: Sun, 22 Mar 2026 00:46:15 +0100 Subject: [PATCH 15/22] Fix terminal output capture: prevent premature idle detection and handle partial command echoes - setupRecreatingStartMarker returns IDisposable to stop marker recreation before sending commands (prevents marker jumping on PSReadLine re-renders) - noneExecuteStrategy waits for cursor to move past start line after sendText before starting idle detection (prevents end marker at same line as start) - findCommandEcho supports suffix matching for partial command echoes from wrapped getOutput() results (shell integration ON with long commands) - Suffix matching requires mid-word split to avoid false positives on output that happens to be a suffix of the command (e.g. echo output) - Integration tests: use ; separator on Windows, add && conversion test, handle Windows exit code quirks with cmd /c --- .../chat.runInTerminal.test.ts | 23 +++++++- .../executeStrategy/basicExecuteStrategy.ts | 3 +- .../executeStrategy/noneExecuteStrategy.ts | 25 ++++++++- .../executeStrategy/richExecuteStrategy.ts | 3 +- .../executeStrategy/strategyHelpers.ts | 55 ++++++++++++++++--- .../test/browser/strategyHelpers.test.ts | 16 ++++++ 6 files changed, 111 insertions(+), 14 deletions(-) diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts index e84949f0e10..8a37810fed0 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts @@ -208,11 +208,26 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { const m1 = `M1_${Date.now()}`; const m2 = `M2_${Date.now()}`; const m3 = `M3_${Date.now()}`; - const output = await invokeRunInTerminal(`echo ${m1} && echo ${m2} && echo ${m3}`); + // Use `;` on Windows (PowerShell) since `&&` is rewritten to `;` + const sep = isWindows ? ';' : '&&'; + const output = await invokeRunInTerminal(`echo ${m1} ${sep} echo ${m2} ${sep} echo ${m3}`); assert.strictEqual(output.trim(), `${m1}\n${m2}\n${m3}`); }); + (isWindows ? test : test.skip)('&& operators are converted to ; on PowerShell', async function () { + this.timeout(60000); + + const m1 = `CHAIN_${Date.now()}_A`; + const m2 = `CHAIN_${Date.now()}_B`; + const output = await invokeRunInTerminal(`echo ${m1} && echo ${m2}`); + + // The rewriter prepends a note explaining the simplification + const trimmed = output.trim(); + assert.ok(trimmed.startsWith('Note: The tool simplified the command to'), `Expected rewrite note, got: ${trimmed}`); + assert.ok(trimmed.endsWith(`${m1}\n${m2}`), `Expected markers at end, got: ${trimmed}`); + }); + test('non-zero exit code is reported', async function () { this.timeout(60000); @@ -220,10 +235,14 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { const command = isWindows ? 'cmd /c exit 42' : 'bash -c "exit 42"'; const output = await invokeRunInTerminal(command); - // Without shell integration, exit codes are unavailable + // Without shell integration, exit codes are unavailable. + // On Windows with shell integration, `cmd /c exit 42` may report + // exit code 1 instead of 42 due to how PowerShell propagates + // cmd.exe exit codes through shell integration sequences. const acceptable = [ 'Command produced no output\nCommand exited with code 42', ...(!hasShellIntegration ? ['Command produced no output'] : []), + ...(isWindows && hasShellIntegration ? ['Command produced no output\nCommand exited with code 1'] : []), ]; assert.ok(acceptable.includes(output.trim()), `Unexpected output: ${JSON.stringify(output.trim())}`); }); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts index dab3f1829c5..6de862cb380 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts @@ -104,7 +104,7 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute this._log('Waiting for idle'); await waitForIdle(this._instance.onData, idlePollInterval); - setupRecreatingStartMarker( + const markerRecreation = setupRecreatingStartMarker( xterm, this._startMarker, m => this._onDidCreateStartMarker.fire(m), @@ -128,6 +128,7 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute // ^C being sent and also to return the exit code of 130 when from the shell when that // occurs. this._log(`Executing command line \`${commandLine}\``); + markerRecreation.dispose(); this._instance.sendText(commandLine, true); // Wait for the next end execution event - note that this may not correspond to the actual diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts index 2d359f2171e..f9391f329cc 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts @@ -62,7 +62,7 @@ export class NoneExecuteStrategy extends Disposable implements ITerminalExecuteS throw new CancellationError(); } - setupRecreatingStartMarker( + const markerRecreation = setupRecreatingStartMarker( xterm, this._startMarker, m => this._onDidCreateStartMarker.fire(m), @@ -82,8 +82,31 @@ export class NoneExecuteStrategy extends Disposable implements ITerminalExecuteS // is used as sending ctrl+c before a shell is initialized (eg. PSReadLine) can result // in failure (https://github.com/microsoft/vscode/issues/258989) this._log(`Executing command line \`${commandLine}\``); + markerRecreation.dispose(); + const startLine = this._startMarker.value?.line; this._instance.sendText(commandLine, true); + // Wait for the cursor to move past the command line before + // starting idle detection. Without this, the idle poll may + // resolve immediately on the existing prompt if the shell + // hasn't started processing the command yet. + if (startLine !== undefined) { + this._log('Waiting for cursor to move past start line'); + await new Promise(resolve => { + const check = () => { + const buffer = xterm.raw.buffer.active; + const cursorLine = buffer.baseY + buffer.cursorY; + if (cursorLine > startLine) { + resolve(); + } else { + store.add(Event.once(this._instance.onData)(() => check())); + } + }; + check(); + }); + } + + // Assume the command is done when it's idle this._log('Waiting for idle with prompt heuristics'); const promptResultOrAltBuffer = await Promise.race([ diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts index f22472af0f3..a4c896596ac 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts @@ -73,7 +73,7 @@ export class RichExecuteStrategy extends Disposable implements ITerminalExecuteS }), ]); - setupRecreatingStartMarker( + const markerRecreation = setupRecreatingStartMarker( xterm, this._startMarker, m => this._onDidCreateStartMarker.fire(m), @@ -83,6 +83,7 @@ export class RichExecuteStrategy extends Disposable implements ITerminalExecuteS // Execute the command this._log(`Executing command line \`${commandLine}\``); + markerRecreation.dispose(); this._instance.runCommand(commandLine, true, commandId); // Wait for the terminal to idle diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts index 326947c875c..2830c7b90e9 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts @@ -18,7 +18,7 @@ export function setupRecreatingStartMarker( fire: (marker: IXtermMarker | undefined) => void, store: DisposableStore, log?: (message: string) => void, -): void { +): IDisposable { const markerListener = new MutableDisposable(); const recreateStartMarker = () => { if (store.isDisposed) { @@ -43,6 +43,12 @@ export function setupRecreatingStartMarker( fire(undefined); })); store.add(startMarker); + + // Return a disposable that stops the recreation loop without clearing + // the current marker. Callers should dispose this before sending a + // command so that prompt re-renders (e.g. PSReadLine transient prompts) + // don't move the start marker past the command output. + return toDisposable(() => markerListener.dispose()); } export function createAltBufferPromise( @@ -100,7 +106,9 @@ export function stripCommandEchoAndPrompt(output: string, commandLine: string, l function _stripCommandEchoAndPromptOnce(output: string, commandLine: string, log?: (message: string) => void): string { // Strip leading lines that are part of the command echo using findCommandEcho. - const echoResult = findCommandEcho(output, commandLine); + // Allow suffix matching to handle partial command echoes from getOutput() + // where the prompt line is not included. + const echoResult = findCommandEcho(output, commandLine, /*allowSuffixMatch*/ true); const lines = echoResult ? echoResult.linesAfter : output.split('\n'); const startIndex = 0; @@ -168,7 +176,7 @@ function _stripCommandEchoAndPromptOnce(output: string, commandLine: string, log return result; } -export function findCommandEcho(output: string, commandLine: string): { contentBefore: string; linesAfter: string[] } | undefined { +export function findCommandEcho(output: string, commandLine: string, allowSuffixMatch?: boolean): { contentBefore: string; linesAfter: string[] } | undefined { const trimmedCommand = commandLine.trim(); if (trimmedCommand.length === 0) { return undefined; @@ -178,17 +186,46 @@ export function findCommandEcho(output: string, commandLine: string): { contentB // contiguous substring even when terminal wrapping splits it across lines. const { strippedOutput, indexMapping } = stripNewLinesAndBuildMapping(output); const matchIndex = strippedOutput.indexOf(trimmedCommand); - if (matchIndex === -1) { + + let matchEndInStripped: number; + let contentBefore: string; + + if (matchIndex !== -1) { + // Full command found in the output + contentBefore = strippedOutput.substring(0, matchIndex).trim(); + matchEndInStripped = matchIndex + trimmedCommand.length - 1; + } else if (allowSuffixMatch) { + // If the full command wasn't found, check if the output starts with a + // suffix of the command. This happens when getOutput() doesn't include + // the prompt line, so only the wrapped continuation of the command echo + // appears at the beginning of the output. + let suffixLen = 0; + for (let len = trimmedCommand.length - 1; len >= 1; len--) { + const suffix = trimmedCommand.substring(trimmedCommand.length - len); + if (strippedOutput.startsWith(suffix)) { + // Require the suffix to start mid-word in the command (not at + // a word boundary). A word-boundary match like "MARKER_123" + // matching the tail of "echo MARKER_123" is almost certainly + // actual output, not a wrapped command continuation. + const charBefore = trimmedCommand[trimmedCommand.length - len - 1]; + if (charBefore !== undefined && charBefore !== ' ' && charBefore !== '\t') { + suffixLen = len; + } + break; + } + } + if (suffixLen === 0) { + return undefined; + } + contentBefore = ''; + matchEndInStripped = suffixLen - 1; + } else { return undefined; } - // The content before the command in the stripped output is the prompt text - // (e.g. "user@host:~/src $ "). Trim whitespace to get the meaningful part. - const contentBefore = strippedOutput.substring(0, matchIndex).trim(); - // Map the match end back to the original output position and determine // which line it falls on to split linesAfter. - const originalEnd = indexMapping[matchIndex + trimmedCommand.length - 1]; + const originalEnd = indexMapping[matchEndInStripped]; const lines = output.split('\n'); let echoEndLine = 0; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts index 38d1880fbde..03f53788a18 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts @@ -381,6 +381,22 @@ suite('stripCommandEchoAndPrompt', () => { ); }); + test('strips partial command echo (suffix from wrapped getOutput)', () => { + // When getOutput() doesn't include the prompt line, only the wrapped + // continuation of the command echo appears at the start of the output. + const output = [ + '90741 ; echo M2_1774133190741 ; echo M3_1774133190741', + 'M1_1774133190741', + 'M2_1774133190741', + 'M3_1774133190741', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo M1_1774133190741 ; echo M2_1774133190741 ; echo M3_1774133190741'), + 'M1_1774133190741\nM2_1774133190741\nM3_1774133190741' + ); + }); + test('strips sandbox-wrapped command echo with error output and trailing prompt', () => { const commandLine = 'ELECTRON_RUN_AS_NODE=1 PATH="$PATH:/Users/alex/src/vscode4/node_modules/@vscode/ripgrep/bin" TMPDIR="/Users/alex/.vscode-oss-dev/tmp" CLAUDE_TMPDIR="/Users/alex/.vscode-oss-dev/tmp" "/Users/alex/src/vscode4/node_modules/@anthropic-ai/sandbox-runtime/dist/cli.js" --settings "/Users/alex/.vscode-oss-dev/tmp/vscode-sandbox-settings-cf5b6232-825b-4f4c-8902-32a8591007fd.json" -c \' echo "SANDBOX_TMP_1774127409076" > /tmp/SANDBOX_TMP_1774127409076.txt\''; const output = [ From bd3ecf8f4d0db75e05f59f8b490d04b41aca7d7e Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sun, 22 Mar 2026 00:54:34 +0100 Subject: [PATCH 16/22] Fix mock in unit test --- .../chatAgentTools/test/browser/noneExecuteStrategy.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/noneExecuteStrategy.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/noneExecuteStrategy.test.ts index 96a6cc5f77b..5618680c591 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/noneExecuteStrategy.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/noneExecuteStrategy.test.ts @@ -48,7 +48,7 @@ suite('NoneExecuteStrategy', () => { active: { ...activeBuffer, baseY: 0, - cursorY: 0, + cursorY: 1, getLine: () => ({ translateToString: () => cursorLineText, }), From e1fdfd1f1b8bd82fa1ed1197c5992ab277ee2a33 Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sun, 22 Mar 2026 01:36:32 +0100 Subject: [PATCH 17/22] Address PR feedback: logging, performance, timeout, and docs - Strip sensitive data from debug logs (log metadata only) - Use array join instead of O(n^2) string concat in stripNewLinesAndBuildMapping - Add 5s timeout to cursor-move wait to prevent indefinite hangs - Align shellIntegrationTimeout descriptions (0 = skip the wait) --- .../terminal/common/terminalConfiguration.ts | 2 +- .../executeStrategy/noneExecuteStrategy.ts | 16 +++++++++++++--- .../browser/executeStrategy/strategyHelpers.ts | 10 +++++----- .../terminalChatAgentToolsConfiguration.ts | 2 +- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts index a6b81d3a1df..2d1dd15ce52 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts @@ -634,7 +634,7 @@ const terminalConfiguration: IStringDictionary = { }, [TerminalSettingId.ShellIntegrationTimeout]: { restricted: true, - markdownDescription: localize('terminal.integrated.shellIntegration.timeout', "Configures the duration in milliseconds to wait for shell integration after launch before declaring it's not there. Set to {0} to skip the wait entirely, the default value {1} means the wait time is variable based on whether shell integration injection is enabled and whether it's a remote window. Values between 1 and 499 are clamped to 500ms. Consider setting this to {0} if you intentionally disabled shell integration, or a large value if your shell starts very slowly.", '`0`', '`-1`'), + markdownDescription: localize('terminal.integrated.shellIntegration.timeout', "Configures the duration in milliseconds to wait for shell integration after launch before declaring it's not there. Set to {0} to skip the wait entirely. The default value {1} uses a variable wait time based on whether shell integration injection is enabled and whether it's a remote window. Values between 1 and 499 are clamped to 500ms. Consider setting this to {0} if you intentionally disabled shell integration, or a large value if your shell starts very slowly.", '`0`', '`-1`'), type: 'integer', minimum: -1, maximum: 60000, diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts index f9391f329cc..f72379411ca 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts @@ -92,18 +92,28 @@ export class NoneExecuteStrategy extends Disposable implements ITerminalExecuteS // hasn't started processing the command yet. if (startLine !== undefined) { this._log('Waiting for cursor to move past start line'); - await new Promise(resolve => { + const cursorMovedPromise = new Promise(resolve => { const check = () => { const buffer = xterm.raw.buffer.active; const cursorLine = buffer.baseY + buffer.cursorY; if (cursorLine > startLine) { resolve(); - } else { - store.add(Event.once(this._instance.onData)(() => check())); } }; + const listener = this._instance.onData(() => check()); + store.add(listener); check(); }); + + const cursorMoveTimeout = new Promise<'timeout'>(resolve => { + const handle = setTimeout(() => resolve('timeout'), 5000); + store.add({ dispose: () => clearTimeout(handle) }); + }); + + const raceResult = await Promise.race([cursorMovedPromise, cursorMoveTimeout]); + if (raceResult === 'timeout') { + this._log('Cursor did not move past start line before timeout, proceeding with idle detection'); + } } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts index 2830c7b90e9..28416349eda 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts @@ -88,7 +88,7 @@ export function createAltBufferPromise( * This function removes (1) and (3) to isolate the actual output. */ export function stripCommandEchoAndPrompt(output: string, commandLine: string, log?: (message: string) => void): string { - log?.(`stripCommandEchoAndPrompt input: ${JSON.stringify(output)}, commandLine: ${JSON.stringify(commandLine)}`); + log?.(`stripCommandEchoAndPrompt input: output length=${output.length}, commandLine length=${commandLine.length}`); const result = _stripCommandEchoAndPromptOnce(output, commandLine, log); @@ -172,7 +172,7 @@ function _stripCommandEchoAndPromptOnce(output: string, commandLine: string, log } const result = lines.slice(startIndex, endIndex).join('\n'); - log?.(`stripCommandEchoAndPrompt result: ${JSON.stringify(result)} (startIndex=${startIndex}, endIndex=${endIndex}, totalLines=${lines.length})`); + log?.(`stripCommandEchoAndPrompt result: length=${result.length} (startIndex=${startIndex}, endIndex=${endIndex}, totalLines=${lines.length})`); return result; } @@ -247,12 +247,12 @@ export function findCommandEcho(output: string, commandLine: string, allowSuffix export function stripNewLinesAndBuildMapping(output: string): { strippedOutput: string; indexMapping: number[] } { const indexMapping: number[] = []; - let strippedOutput = ''; + const strippedChars: string[] = []; for (let i = 0; i < output.length; i++) { if (output[i] !== '\n') { - strippedOutput += output[i]; + strippedChars.push(output[i]); indexMapping.push(i); } } - return { strippedOutput, indexMapping }; + return { strippedOutput: strippedChars.join(''), indexMapping }; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index e94af156a8f..e55ce632fd1 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -436,7 +436,7 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary Date: Sun, 22 Mar 2026 01:49:00 +0100 Subject: [PATCH 18/22] Install bubblewrap and socat in Linux CI pipelines These are required for terminal sandbox integration tests. --- .../linux/product-build-linux-node-modules.yml | 4 +++- .../linux/steps/product-build-linux-compile.yml | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/build/azure-pipelines/linux/product-build-linux-node-modules.yml b/build/azure-pipelines/linux/product-build-linux-node-modules.yml index 290a3fe1b29..ad0e1498160 100644 --- a/build/azure-pipelines/linux/product-build-linux-node-modules.yml +++ b/build/azure-pipelines/linux/product-build-linux-node-modules.yml @@ -41,7 +41,9 @@ jobs: libxkbfile-dev \ libkrb5-dev \ libgbm1 \ - rpm + rpm \ + bubblewrap \ + socat sudo cp build/azure-pipelines/linux/xvfb.init /etc/init.d/xvfb sudo chmod +x /etc/init.d/xvfb sudo update-rc.d xvfb defaults diff --git a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml index a09758329cb..4b5a5d08cd0 100644 --- a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml +++ b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml @@ -48,7 +48,9 @@ steps: libxkbfile-dev \ libkrb5-dev \ libgbm1 \ - rpm + rpm \ + bubblewrap \ + socat sudo cp build/azure-pipelines/linux/xvfb.init /etc/init.d/xvfb sudo chmod +x /etc/init.d/xvfb sudo update-rc.d xvfb defaults From 6e84e45d569d066fe59e9bf1272eb496ecf0c7b8 Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sun, 22 Mar 2026 02:07:34 +0100 Subject: [PATCH 19/22] Force /bin/bash over /bin/sh for copilot terminal profile Shell integration cannot be injected into /bin/sh, causing loss of exit code detection. This matches the existing cmd.exe -> powershell override pattern. --- .../chatAgentTools/browser/tools/runInTerminalTool.ts | 9 +++++++++ 1 file changed, 9 insertions(+) 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 2d9651ed43a..29a02e9d006 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -1748,6 +1748,15 @@ export class TerminalProfileFetcher { }; } + // Force bash over sh as sh doesn't have shell integration + if (defaultProfile.path === '/bin/sh') { + return { + ...defaultProfile, + path: '/bin/bash', + profileName: 'bash', + }; + } + // Setting icon: undefined allows the system to use the default AI terminal icon (not overridden or removed) return { ...defaultProfile, icon: undefined }; } From 9644aa33b467eb7dbdcd6d72613e57f350e0b2fe Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sun, 22 Mar 2026 09:16:43 +0100 Subject: [PATCH 20/22] Fix bracketed prompt without @ and cap trailing prompt stripping at 2 lines - Extend bracketed prompt patterns from isUnixAt to isUnix so prompts like [W007DV9PF9-1:~/path] are recognized (CI macOS prompt format) - Cap trailing prompt stripping at 2 non-empty lines to prevent over-stripping legitimate output - Add unit tests for bracketed prompt without @ format --- .../executeStrategy/strategyHelpers.ts | 64 ++++++++++--------- .../test/browser/strategyHelpers.test.ts | 36 +++++++++++ 2 files changed, 71 insertions(+), 29 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts index 28416349eda..5d5682c2e43 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts @@ -131,41 +131,47 @@ function _stripCommandEchoAndPromptOnce(output: string, commandLine: string, log // that happens to end with characters like $, #, %, or >. let endIndex = lines.length; let trailingStrippedCount = 0; + const maxTrailingPromptLines = 2; while (endIndex > startIndex) { const line = lines[endIndex - 1].trimEnd(); if ( line.length === 0 || - // Bash/zsh prompt: user@host:path ending with $ or # - // e.g., "user@host:~/src $ " or "root@server:/var/log# " - (!knownPrompt || isUnixAt) && /^\s*\w+@[\w.-]+:.*[#$]\s*$/.test(line) || - // Prompt without @: hostname:path user$ or hostname:path user# - // e.g., "dsm12-be220-abc:testWorkspace runner$" - (!knownPrompt || isUnixHost) && /^\s*[\w.-]+:\S.*\s\w+[#$]\s*$/.test(line) || - // Wrapped prompt fragment ending with $ or # (e.g. "er$", "ts/testWorkspace$") - // These appear when a prompt wraps across terminal columns. - (!knownPrompt || isUnix) && /^\s*[\w/.-]+[#$]\s*$/.test(line) || - // Bracketed prompt start: [ user@host:/path (wrapped prompt first line) - // e.g., "[ alex@MacBook-Pro:/Users/alex/src/vscode4/extensions/vscode-api-test" - (!knownPrompt || isUnixAt) && /^\[\s*\w+@[\w.-]+:/.test(line) || - // Wrapped prompt continuation: user@host:path or hostname:path (no trailing $) - // Only matched after we've already stripped a prompt fragment below. - // e.g., "cloudtest@host:/mnt/vss/.../vscode-api-tes" or "dsm12-abc:testWorkspace runn" - (!knownPrompt || isUnix) && trailingStrippedCount > 0 && /^\s*[\w][-\w.]*(@[\w.-]+)?:\S/.test(line) || - // Bracketed prompt end: ...] $ or ...] # - // e.g., "s/testWorkspace (main**) ] $ " - (!knownPrompt || isUnixAt) && /\]\s*[#$]\s*$/.test(line) || - // PowerShell prompt: PS C:\path> - (!knownPrompt || isPowerShell) && /^PS\s+[A-Z]:\\.*>\s*$/.test(line) || - // Windows cmd prompt: C:\path> - (!knownPrompt || isCmd) && /^[A-Z]:\\.*>\s*$/.test(line) || - // Starship prompt character - // allow-any-unicode-next-line - (!knownPrompt || isStarship) && /\u276f\s*$/.test(line) || - // Python REPL prompt - (!knownPrompt || isPython) && /^>>>\s*$/.test(line) + (trailingStrippedCount < maxTrailingPromptLines && ( + // Bash/zsh prompt: user@host:path ending with $ or # + // e.g., "user@host:~/src $ " or "root@server:/var/log# " + (!knownPrompt || isUnixAt) && /^\s*\w+@[\w.-]+:.*[#$]\s*$/.test(line) || + // Prompt without @: hostname:path user$ or hostname:path user# + // e.g., "dsm12-be220-abc:testWorkspace runner$" + (!knownPrompt || isUnixHost) && /^\s*[\w.-]+:\S.*\s\w+[#$]\s*$/.test(line) || + // Wrapped prompt fragment ending with $ or # (e.g. "er$", "ts/testWorkspace$") + // These appear when a prompt wraps across terminal columns. + (!knownPrompt || isUnix) && /^\s*[\w/.-]+[#$]\s*$/.test(line) || + // Bracketed prompt start: [ hostname:/path or [ user@host:/path (wrapped prompt first line) + // e.g., "[ alex@MacBook-Pro:/Users/alex/src/vscode4/extensions/vscode-api-test" + // e.g., "[W007DV9PF9-1:~/vss/_work/1/s/extensions/vscode-api-tests/testWorkspace] cloudte" + (!knownPrompt || isUnix) && /^\[\s*[\w.-]+(@[\w.-]+)?:[~\/]/.test(line) || + // Wrapped prompt continuation: user@host:path or hostname:path (no trailing $) + // Only matched after we've already stripped a prompt fragment below. + // e.g., "cloudtest@host:/mnt/vss/.../vscode-api-tes" or "dsm12-abc:testWorkspace runn" + (!knownPrompt || isUnix) && trailingStrippedCount > 0 && /^\s*[\w][-\w.]*(@[\w.-]+)?:\S/.test(line) || + // Bracketed prompt end: ...] $ or ...] # + // e.g., "s/testWorkspace (main**) ] $ " + (!knownPrompt || isUnix) && /\]\s*[#$]\s*$/.test(line) || + // PowerShell prompt: PS C:\path> + (!knownPrompt || isPowerShell) && /^PS\s+[A-Z]:\\.*>\s*$/.test(line) || + // Windows cmd prompt: C:\path> + (!knownPrompt || isCmd) && /^[A-Z]:\\.*>\s*$/.test(line) || + // Starship prompt character + // allow-any-unicode-next-line + (!knownPrompt || isStarship) && /\u276f\s*$/.test(line) || + // Python REPL prompt + (!knownPrompt || isPython) && /^>>>\s*$/.test(line) + )) ) { endIndex--; - trailingStrippedCount++; + if (line.length > 0) { + trailingStrippedCount++; + } } else { break; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts index 03f53788a18..71c0d7d9bab 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts @@ -397,6 +397,42 @@ suite('stripCommandEchoAndPrompt', () => { ); }); + test('strips bracketed prompt without @ (hostname:path format)', () => { + // macOS CI prompt: [hostname:path] username$ (wrapped so username is truncated) + const output = [ + '[W007DV9PF9-1:~/vss/_work/1/s/extensions/vscode-api-tests/testWorkspace] cloudte', + 'st$', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'true'), + '' + ); + }); + + test('strips bracketed prompt without @ (single line, no trailing $)', () => { + // When the terminal captures just the prompt (no-output command) + const output = '[W007DV9PF9-1:~/vss/_work/1/s/extensions/vscode-api-tests/testWorkspace] cloudte'; + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'true'), + '' + ); + }); + + test('strips bracketed prompt without @ with command echo', () => { + const output = [ + '[W007DV9PF9-1:~/vss/_work] cloudtest$ echo MARKER_123', + 'MARKER_123', + '[W007DV9PF9-1:~/vss/_work] cloudtest$', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo MARKER_123'), + 'MARKER_123' + ); + }); + test('strips sandbox-wrapped command echo with error output and trailing prompt', () => { const commandLine = 'ELECTRON_RUN_AS_NODE=1 PATH="$PATH:/Users/alex/src/vscode4/node_modules/@vscode/ripgrep/bin" TMPDIR="/Users/alex/.vscode-oss-dev/tmp" CLAUDE_TMPDIR="/Users/alex/.vscode-oss-dev/tmp" "/Users/alex/src/vscode4/node_modules/@anthropic-ai/sandbox-runtime/dist/cli.js" --settings "/Users/alex/.vscode-oss-dev/tmp/vscode-sandbox-settings-cf5b6232-825b-4f4c-8902-32a8591007fd.json" -c \' echo "SANDBOX_TMP_1774127409076" > /tmp/SANDBOX_TMP_1774127409076.txt\''; const output = [ From b2b4e0e207cc4273ae1c9da89923beca1060c72b Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sun, 22 Mar 2026 10:10:09 +0100 Subject: [PATCH 21/22] Distinguish complete vs fragment prompts to prevent false stripping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split trailing prompt patterns into two categories: - Complete prompts (user@host:~ $, PS C:\>, etc.) stop stripping immediately — anything above is command output, not a wrapped prompt - Fragment patterns (er$, ] $, [host:~/path...) allow continued stripping to reassemble wrapped prompts This prevents falsely stripping output lines that happen to end with $ or # when a real complete prompt sits below them. Added adversarial tests verifying correct behavior for output containing prompt-like characters. --- .../executeStrategy/strategyHelpers.ts | 89 ++++++++------- .../test/browser/strategyHelpers.test.ts | 104 ++++++++++++++++++ 2 files changed, 156 insertions(+), 37 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts index 5d5682c2e43..c372a8c78af 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts @@ -134,44 +134,59 @@ function _stripCommandEchoAndPromptOnce(output: string, commandLine: string, log const maxTrailingPromptLines = 2; while (endIndex > startIndex) { const line = lines[endIndex - 1].trimEnd(); - if ( - line.length === 0 || - (trailingStrippedCount < maxTrailingPromptLines && ( - // Bash/zsh prompt: user@host:path ending with $ or # - // e.g., "user@host:~/src $ " or "root@server:/var/log# " - (!knownPrompt || isUnixAt) && /^\s*\w+@[\w.-]+:.*[#$]\s*$/.test(line) || - // Prompt without @: hostname:path user$ or hostname:path user# - // e.g., "dsm12-be220-abc:testWorkspace runner$" - (!knownPrompt || isUnixHost) && /^\s*[\w.-]+:\S.*\s\w+[#$]\s*$/.test(line) || - // Wrapped prompt fragment ending with $ or # (e.g. "er$", "ts/testWorkspace$") - // These appear when a prompt wraps across terminal columns. - (!knownPrompt || isUnix) && /^\s*[\w/.-]+[#$]\s*$/.test(line) || - // Bracketed prompt start: [ hostname:/path or [ user@host:/path (wrapped prompt first line) - // e.g., "[ alex@MacBook-Pro:/Users/alex/src/vscode4/extensions/vscode-api-test" - // e.g., "[W007DV9PF9-1:~/vss/_work/1/s/extensions/vscode-api-tests/testWorkspace] cloudte" - (!knownPrompt || isUnix) && /^\[\s*[\w.-]+(@[\w.-]+)?:[~\/]/.test(line) || - // Wrapped prompt continuation: user@host:path or hostname:path (no trailing $) - // Only matched after we've already stripped a prompt fragment below. - // e.g., "cloudtest@host:/mnt/vss/.../vscode-api-tes" or "dsm12-abc:testWorkspace runn" - (!knownPrompt || isUnix) && trailingStrippedCount > 0 && /^\s*[\w][-\w.]*(@[\w.-]+)?:\S/.test(line) || - // Bracketed prompt end: ...] $ or ...] # - // e.g., "s/testWorkspace (main**) ] $ " - (!knownPrompt || isUnix) && /\]\s*[#$]\s*$/.test(line) || - // PowerShell prompt: PS C:\path> - (!knownPrompt || isPowerShell) && /^PS\s+[A-Z]:\\.*>\s*$/.test(line) || - // Windows cmd prompt: C:\path> - (!knownPrompt || isCmd) && /^[A-Z]:\\.*>\s*$/.test(line) || - // Starship prompt character - // allow-any-unicode-next-line - (!knownPrompt || isStarship) && /\u276f\s*$/.test(line) || - // Python REPL prompt - (!knownPrompt || isPython) && /^>>>\s*$/.test(line) - )) - ) { + if (line.length === 0) { endIndex--; - if (line.length > 0) { - trailingStrippedCount++; - } + continue; + } + if (trailingStrippedCount >= maxTrailingPromptLines) { + break; + } + + // Complete (self-contained) prompt patterns: these have a recognizable + // prefix and a trailing marker ($, #, >). After stripping one complete + // prompt line, stop — lines above it are command output, not wrapped + // prompt continuation lines. + const isCompletePrompt = + // Bash/zsh: user@host:path ending with $ or # + // e.g., "user@host:~/src $ " or "root@server:/var/log# " + ((!knownPrompt || isUnixAt) && /^\s*\w+@[\w.-]+:.*[#$]\s*$/.test(line)) || + // hostname:path user$ or hostname:path user# + // e.g., "dsm12-be220-abc:testWorkspace runner$" + ((!knownPrompt || isUnixHost) && /^\s*[\w.-]+:\S.*\s\w+[#$]\s*$/.test(line)) || + // PowerShell: PS C:\path> + ((!knownPrompt || isPowerShell) && /^PS\s+[A-Z]:\\.*>\s*$/.test(line)) || + // Windows cmd: C:\path> + ((!knownPrompt || isCmd) && /^[A-Z]:\\.*>\s*$/.test(line)) || + // Starship prompt character + // allow-any-unicode-next-line + ((!knownPrompt || isStarship) && /\u276f\s*$/.test(line)) || + // Python REPL + ((!knownPrompt || isPython) && /^>>>\s*$/.test(line)); + + // Fragment/partial prompt patterns: these represent pieces of a prompt + // that wraps across multiple terminal lines due to column width. + const isPromptFragment = + // Wrapped fragment ending with $ or # (e.g. "er$", "ts/testWorkspace$") + ((!knownPrompt || isUnix) && /^\s*[\w/.-]+[#$]\s*$/.test(line)) || + // Bracketed prompt start: [ hostname:/path or [ user@host:/path + // e.g., "[ alex@MacBook-Pro:/Users/alex/src/vscode4/extensions/vscode-api-test" + // e.g., "[W007DV9PF9-1:~/vss/_work/1/s/extensions/vscode-api-tests/testWorkspace] cloudte" + ((!knownPrompt || isUnix) && /^\[\s*[\w.-]+(@[\w.-]+)?:[~\/]/.test(line)) || + // Wrapped continuation: user@host:path or hostname:path (no trailing $) + // Only matched after we've already stripped a prompt fragment below. + // e.g., "cloudtest@host:/mnt/vss/.../vscode-api-tes" or "dsm12-abc:testWorkspace runn" + ((!knownPrompt || isUnix) && trailingStrippedCount > 0 && /^\s*[\w][-\w.]*(@[\w.-]+)?:\S/.test(line)) || + // Bracketed prompt end: ...] $ or ...] # + // e.g., "s/testWorkspace (main**) ] $ " + ((!knownPrompt || isUnix) && /\]\s*[#$]\s*$/.test(line)); + + if (isCompletePrompt) { + endIndex--; + trailingStrippedCount++; + break; // Complete prompt = nothing above can be prompt wrap + } else if (isPromptFragment) { + endIndex--; + trailingStrippedCount++; } else { break; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts index 71c0d7d9bab..8e22ff4a36e 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts @@ -457,4 +457,108 @@ suite('stripCommandEchoAndPrompt', () => { '' ); }); + + // --- Adversarial tests: output that looks like prompts --- + // These verify that realistic output is NOT falsely stripped. + + suite('adversarial: output resembling prompts', () => { + + test('output ending with $ is preserved (not confused with wrapped prompt)', () => { + const output = [ + 'user@host:~ $ echo \'test$\'', + 'test$', + 'user@host:~ $', + ].join('\n'); + + // 'user@host:~ $' is a complete prompt → stripped and loop stops. + // 'test$' is preserved because nothing above a complete prompt is stripped. + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo \'test$\''), + 'test$' + ); + }); + + test('output ending with # is preserved (not confused with wrapped prompt)', () => { + const output = [ + 'user@host:~ $ echo \'div#\'', + 'div#', + 'user@host:~ $', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo \'div#\''), + 'div#' + ); + }); + + test('bracketed log output [tag:~/path] is preserved', () => { + const output = [ + 'user@host:~ $ node build.js', + '[build:~/dist] compiled successfully', + 'user@host:~ $', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'node build.js'), + '[build:~/dist] compiled successfully' + ); + }); + + test('output containing user@host:path ending with # is preserved', () => { + const output = [ + 'user@host:~ $ cat /etc/motd', + 'admin@server:~/docs #', + 'user@host:~ $', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'cat /etc/motd'), + 'admin@server:~/docs #' + ); + }); + + test('output ending with ] $ is preserved', () => { + const output = [ + 'user@host:~ $ echo \'values: [a, b] $\'', + 'values: [a, b] $', + 'user@host:~ $', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo \'values: [a, b] $\''), + 'values: [a, b] $' + ); + }); + + test('multiple prompt-like output lines are all preserved', () => { + // Complete prompt at the bottom stops stripping immediately, + // so all prompt-like output lines above are preserved. + const output = [ + 'user@host:~ $ cat prompts.txt', + 'admin@server:~/docs $', + 'root@box:/var/log #', + 'test@dev:~ $', + 'user@host:~ $', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'cat prompts.txt'), + 'admin@server:~/docs $\nroot@box:/var/log #\ntest@dev:~ $' + ); + }); + + test('multi-line output where last line has $ after non-word chars is preserved', () => { + const output = [ + 'user@host:~ $ ./report.sh', + 'Revenue: 1000', + 'Currency: USD$', + 'user@host:~ $', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, './report.sh'), + 'Revenue: 1000\nCurrency: USD$' + ); + }); + }); }); From 487646cee829dc3bd95477babcd40e83044cbc7a Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sun, 22 Mar 2026 10:50:35 +0100 Subject: [PATCH 22/22] Attempt to cover up the `run_in_terminal` tool not being registered quickly --- .../singlefolder-tests/chat.runInTerminal.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts index 8a37810fed0..39008b8be3b 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts @@ -134,8 +134,18 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { return extractTextContent(result); } - test('tool should be registered with expected schema', () => { - const tool = vscode.lm.tools.find(t => t.name === 'run_in_terminal'); + test('tool should be registered with expected schema', async function () { + this.timeout(15000); + // The run_in_terminal tool is registered asynchronously (it needs to + // resolve terminal profiles), so poll until it appears. + let tool: vscode.LanguageModelToolInformation | undefined; + for (let i = 0; i < 50; i++) { + tool = vscode.lm.tools.find(t => t.name === 'run_in_terminal'); + if (tool) { + break; + } + await new Promise(r => setTimeout(r, 200)); + } assert.ok(tool, 'run_in_terminal tool should be registered'); assert.ok(tool.inputSchema, 'Tool should have an input schema');