Fix terminal output capture: strip command echo/prompt, fix premature idle detection, improve sandbox failure detection, force bash over sh (#303754)

* 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.

* Fix compilation errors

* 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%", "<div>", or "item #".

* Review feedback

* 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.

* 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

* 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)

* fix: detect sandbox failures heuristically when exit code is unavailable

* Relax some tests when shell integration is off

* refactor: extract findCommandEcho and use prompt evidence to narrow trailing prompt regex matching

* Cover case where the command is duplicated in `stripCommandEchoAndPrompt`

* 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

* Fix slash history test

* 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.

* 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

* Fix mock in unit test

* 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)

* Install bubblewrap and socat in Linux CI pipelines

These are required for terminal sandbox integration tests.

* 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.

* 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

* Distinguish complete vs fragment prompts to prevent false stripping

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.

* Attempt to cover up the `run_in_terminal` tool not being registered quickly
This commit is contained in:
Alexandru Dima
2026-03-22 11:37:34 +01:00
committed by GitHub
24 changed files with 1492 additions and 28 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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<vscode.LanguageModelToolResult> | 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<string> {
setupParticipant();
const resultPromise = new DeferredPromise<vscode.LanguageModelToolResult>();
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<string, unknown> };
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);
});
});
}
});

View File

@@ -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<void>();

View File

@@ -12,6 +12,7 @@ export interface IRemoteAgentEnvironment {
pid: number;
connectionToken: string;
appRoot: URI;
execPath: string;
tmpDir: URI;
settingsPath: URI;
mcpResource: URI;

View File

@@ -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,

View File

@@ -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'),

View File

@@ -634,7 +634,7 @@ const terminalConfiguration: IStringDictionary<IConfigurationPropertySchema> = {
},
[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,

View File

@@ -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);
}

View File

@@ -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<number>(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');

View File

@@ -160,6 +160,7 @@ export async function trackIdleOnPrompt(
instance: ITerminalInstance,
idleDurationMs: number,
store: DisposableStore,
promptFallbackMs?: number,
): Promise<void> {
const idleOnPrompt = new DeferredPromise<void>();
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

View File

@@ -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<number>(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<void>(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,

View File

@@ -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<number>(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');

View File

@@ -18,7 +18,7 @@ export function setupRecreatingStartMarker(
fire: (marker: IXtermMarker | undefined) => void,
store: DisposableStore,
log?: (message: string) => void,
): void {
): IDisposable {
const markerListener = new MutableDisposable<IDisposable>();
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 };
}

View File

@@ -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 };
}

View File

@@ -17,10 +17,14 @@ export class SandboxOutputAnalyzer extends Disposable implements IOutputAnalyzer
}
async analyze(options: IOutputAnalyzerOptions): Promise<string | undefined> {
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);
}

View File

@@ -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<IConfigurati
markdownDescription: localize('blockFileWrites.description', "Controls whether detected file write operations are blocked in the run in terminal tool. When detected, this will require explicit approval regardless of whether the command would normally be auto approved. Note that this cannot detect all possible methods of writing files, this is what is currently detected:\n\n- File redirection (detected via the bash or PowerShell tree sitter grammar)\n- `sed` in-place editing (`-i`, `-I`, `--in-place`)"),
},
[TerminalChatAgentToolsSettingId.ShellIntegrationTimeout]: {
markdownDescription: localize('shellIntegrationTimeout.description', "Configures the duration in milliseconds to wait for shell integration to be detected when the run in terminal tool launches a new terminal. Set to `0` to wait the minimum time, the default value `-1` means the wait time is variable based on the value of {0} and whether it's a remote window. A large value can be useful if your shell starts very slowly and a low value if you're intentionally not using shell integration.", `\`#${TerminalSettingId.ShellIntegrationEnabled}#\``),
markdownDescription: localize('shellIntegrationTimeout.description', "Configures the duration in milliseconds to wait for shell integration to be detected when the run in terminal tool launches a new terminal. Set to `0` to skip the wait entirely, the default value `-1` uses a variable wait time based on the value of {0} and whether it's a remote window. A large value can be useful if your shell starts very slowly.", `\`#${TerminalSettingId.ShellIntegrationEnabled}#\``),
type: 'integer',
minimum: -1,
maximum: 60000,
default: -1,
markdownDeprecationMessage: localize('shellIntegrationTimeout.deprecated', 'Use {0} instead', `\`#${TerminalSettingId.ShellIntegrationTimeout}#\``)
},
[TerminalChatAgentToolsSettingId.IdlePollInterval]: {
markdownDescription: localize('idlePollInterval.description', "Configures the idle poll interval in milliseconds used by the run in terminal tool to detect when commands have finished executing. Lower values make command detection faster but may cause false positives on slow systems. This primarily affects terminals without shell integration where idle detection is used instead of shell integration events."),
type: 'integer',
minimum: 50,
maximum: 10000,
default: 1000,
},
[TerminalChatAgentToolsSettingId.TerminalProfileLinux]: {
restricted: true,
markdownDescription: localize('terminalChatAgentProfile.linux', "The terminal profile to use on Linux for chat agent's run in terminal tool."),

View File

@@ -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');

View File

@@ -0,0 +1,125 @@
/*---------------------------------------------------------------------------------------------
* 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 { CancellationTokenSource } from '../../../../../../base/common/cancellation.js';
import { Emitter, Event } from '../../../../../../base/common/event.js';
import { NullLogService } from '../../../../../../platform/log/common/log.js';
import type { ITerminalLogService } from '../../../../../../platform/terminal/common/terminal.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
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();
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<string>;
} {
const onDataEmitter = store.add(new Emitter<string>());
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');
}));
});

View File

@@ -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);
});
}
});

View File

@@ -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 "<div>"', '<div>', 'user@host:~ $ '].join('\n'),
'echo "<div>"'
),
'<div>',
'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$'
);
});
});
});

View File

@@ -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'),

View File

@@ -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),