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/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 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..39008b8be3b --- /dev/null +++ b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts @@ -0,0 +1,382 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; +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. + */ +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', () => { + + 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', 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'); + + 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', 100, 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(false); + }); + + // --- Shell integration ON --- + + suite('shell integration on', () => { + defineTests(true); + }); + + function defineTests(hasShellIntegration: boolean) { + + // --- 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()}`; + // 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); + + // 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); + + // 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())}`); + }); + + 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'); + + // 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 () { + this.timeout(60000); + + 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:', + '', + shellError, + ].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\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 () { + this.timeout(60000); + + const output = await invokeRunInTerminal('head -1 /etc/shells'); + + 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 () { + 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/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(); 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/terminal/common/terminalConfiguration.ts b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts index ab230b025f6..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 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} 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/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..6de862cb380 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,9 +102,9 @@ 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( + const markerRecreation = setupRecreatingStartMarker( xterm, this._startMarker, m => this._onDidCreateStartMarker.fire(m), @@ -123,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 @@ -150,7 +156,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(); } @@ -163,13 +169,19 @@ 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, this._log.bind(this)); } } if (output === undefined) { 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, this._log.bind(this)); + } } 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..f72379411ca 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,14 +53,16 @@ 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(); } - setupRecreatingStartMarker( + const markerRecreation = setupRecreatingStartMarker( xterm, this._startMarker, m => this._onDidCreateStartMarker.fire(m), @@ -77,12 +82,45 @@ 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'); + const cursorMovedPromise = new Promise(resolve => { + const check = () => { + const buffer = xterm.raw.buffer.active; + const cursorLine = buffer.baseY + buffer.cursorY; + if (cursorLine > startLine) { + resolve(); + } + }; + 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'); + } + } + + // 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 +147,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, this._log.bind(this)); + } } 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..a4c896596ac 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 { createAltBufferPromise, setupRecreatingStartMarker, stripCommandEchoAndPrompt } 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,12 +68,12 @@ 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'); }), ]); - setupRecreatingStartMarker( + const markerRecreation = setupRecreatingStartMarker( xterm, this._startMarker, m => this._onDidCreateStartMarker.fire(m), @@ -78,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 @@ -109,13 +115,23 @@ 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) { 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, this._log.bind(this)); + } } 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 5c63b233ec2..c372a8c78af 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( @@ -70,3 +76,204 @@ 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, log?: (message: string) => void): string { + log?.(`stripCommandEchoAndPrompt input: output length=${output.length}, commandLine length=${commandLine.length}`); + + 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. + // 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; + + // 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 + // 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; + let trailingStrippedCount = 0; + const maxTrailingPromptLines = 2; + while (endIndex > startIndex) { + const line = lines[endIndex - 1].trimEnd(); + if (line.length === 0) { + endIndex--; + 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; + } + } + + const result = lines.slice(startIndex, endIndex).join('\n'); + log?.(`stripCommandEchoAndPrompt result: length=${result.length} (startIndex=${startIndex}, endIndex=${endIndex}, totalLines=${lines.length})`); + return result; +} + +export function findCommandEcho(output: string, commandLine: string, allowSuffixMatch?: boolean): { 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); + + 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; + } + + // Map the match end back to the original output position and determine + // which line it falls on to split linesAfter. + const originalEnd = indexMapping[matchEndInStripped]; + + 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[] = []; + const strippedChars: string[] = []; + for (let i = 0; i < output.length; i++) { + if (output[i] !== '\n') { + strippedChars.push(output[i]); + indexMapping.push(i); + } + } + return { strippedOutput: strippedChars.join(''), indexMapping }; +} 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 c7d5baff23a..29a02e9d006 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -884,7 +884,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; @@ -1744,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 }; } 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..8b1687f1d35 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,35 @@ 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. + * + * The output may contain newlines inserted by terminal wrapping, so we + * strip them before testing. + */ + private _outputLooksSandboxBlocked(output: string): boolean { + 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/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index f50dfea219f..e55ce632fd1 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', @@ -435,13 +436,20 @@ 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: 1, + 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 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); + + 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 configService = new TestConfigurationService(); + const strategy = store.add(new NoneExecuteStrategy(instance, () => false, configService, 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/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); + }); + } +}); 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..8e22ff4a36e --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts @@ -0,0 +1,564 @@ +/*--------------------------------------------------------------------------------------------- + * 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 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' + ); + }); + + 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)', () => { + 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' + ); + }); + + 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' + ); + }); + + 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 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', + 'dsm12-be220-8627ea7f-2c5a-40cd-8ba1-bf324bb4f59a-DA35C080942E:testWorkspace runn', + 'er$', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'true'), + '' + ); + }); + + 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'), + '' + ); + }); + + 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 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 = [ + '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), + '' + ); + }); + + // --- 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$' + ); + }); + }); +}); 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),