Agent sandboxing: detect missing dependencies before execution and offer installation (#305898)

* updating tests

* sandbox dependencies check for linux

* sandbox dependencies check for linux

* review comment

* Injecting sandboxhelperservice for web
This commit is contained in:
dileepyavan
2026-03-28 13:56:52 -07:00
committed by GitHub
parent 9d62267654
commit c9b8ed1bcf
21 changed files with 765 additions and 40 deletions

View File

@@ -49,6 +49,8 @@ import { IExtensionHostStarter, ipcExtensionHostStarterChannelName } from '../..
import { ExtensionHostStarter } from '../../platform/extensions/electron-main/extensionHostStarter.js';
import { IExternalTerminalMainService } from '../../platform/externalTerminal/electron-main/externalTerminal.js';
import { LinuxExternalTerminalService, MacExternalTerminalService, WindowsExternalTerminalService } from '../../platform/externalTerminal/node/externalTerminalService.js';
import { ISandboxHelperMainService } from '../../platform/sandbox/electron-main/sandboxHelperService.js';
import { SandboxHelperService } from '../../platform/sandbox/node/sandboxHelper.js';
import { LOCAL_FILE_SYSTEM_CHANNEL_NAME } from '../../platform/files/common/diskFileSystemProviderClient.js';
import { IFileService } from '../../platform/files/common/files.js';
import { DiskFileSystemProviderChannel } from '../../platform/files/electron-main/diskFileSystemProviderServer.js';
@@ -1150,6 +1152,7 @@ export class CodeApplication extends Disposable {
} else if (isLinux) {
services.set(IExternalTerminalMainService, new SyncDescriptor(LinuxExternalTerminalService));
}
services.set(ISandboxHelperMainService, new SyncDescriptor(SandboxHelperService));
// Backups
const backupMainService = new BackupMainService(this.environmentMainService, this.configurationService, this.logService, this.stateService);
@@ -1320,6 +1323,10 @@ export class CodeApplication extends Disposable {
const externalTerminalChannel = ProxyChannel.fromService(accessor.get(IExternalTerminalMainService), disposables);
mainProcessElectronServer.registerChannel('externalTerminal', externalTerminalChannel);
// Sandbox Helper
const sandboxHelperChannel = ProxyChannel.fromService(accessor.get(ISandboxHelperMainService), disposables);
mainProcessElectronServer.registerChannel('sandboxHelper', sandboxHelperChannel);
// MCP
const mcpDiscoveryChannel = ProxyChannel.fromService(accessor.get(INativeMcpDiscoveryHelperService), disposables);
mainProcessElectronServer.registerChannel(NativeMcpDiscoveryHelperChannelName, mcpDiscoveryChannel);

View File

@@ -0,0 +1,23 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js';
import { ISandboxDependencyStatus, ISandboxHelperService } from '../common/sandboxHelperService.js';
class NullSandboxHelperService implements ISandboxHelperService {
declare readonly _serviceBrand: undefined;
async checkSandboxDependencies(): Promise<ISandboxDependencyStatus> {
// Web targets cannot inspect host sandbox dependencies directly.
// Treat them as satisfied so browser workbench targets do not fail DI
// or block sandbox flows on an unavailable host-side capability.
return {
bubblewrapInstalled: true,
socatInstalled: true,
};
}
}
registerSingleton(ISandboxHelperService, NullSandboxHelperService, InstantiationType.Delayed);

View File

@@ -0,0 +1,39 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Event } from '../../../base/common/event.js';
import { CancellationToken } from '../../../base/common/cancellation.js';
import { IChannel, IServerChannel } from '../../../base/parts/ipc/common/ipc.js';
import { ISandboxDependencyStatus, ISandboxHelperService } from './sandboxHelperService.js';
export const SANDBOX_HELPER_CHANNEL_NAME = 'sandboxHelper';
export class SandboxHelperChannel implements IServerChannel {
constructor(private readonly service: ISandboxHelperService) { }
listen<T>(_context: unknown, _event: string): Event<T> {
throw new Error('Invalid listen');
}
call<T>(_context: unknown, command: string, _arg?: unknown, _cancellationToken?: CancellationToken): Promise<T> {
switch (command) {
case 'checkSandboxDependencies':
return this.service.checkSandboxDependencies() as Promise<T>;
}
throw new Error('Invalid call');
}
}
export class SandboxHelperChannelClient implements ISandboxHelperService {
declare readonly _serviceBrand: undefined;
constructor(private readonly channel: IChannel) { }
checkSandboxDependencies(): Promise<ISandboxDependencyStatus | undefined> {
return this.channel.call<ISandboxDependencyStatus | undefined>('checkSandboxDependencies');
}
}

View File

@@ -0,0 +1,18 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { createDecorator } from '../../instantiation/common/instantiation.js';
export const ISandboxHelperService = createDecorator<ISandboxHelperService>('sandboxHelperService');
export interface ISandboxDependencyStatus {
readonly bubblewrapInstalled: boolean;
readonly socatInstalled: boolean;
}
export interface ISandboxHelperService {
readonly _serviceBrand: undefined;
checkSandboxDependencies(): Promise<ISandboxDependencyStatus | undefined>;
}

View File

@@ -0,0 +1,9 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { registerMainProcessRemoteService } from '../../ipc/electron-browser/services.js';
import { ISandboxHelperService } from '../common/sandboxHelperService.js';
registerMainProcessRemoteService(ISandboxHelperService, 'sandboxHelper');

View File

@@ -0,0 +1,13 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { createDecorator } from '../../instantiation/common/instantiation.js';
import { ISandboxHelperService } from '../common/sandboxHelperService.js';
export const ISandboxHelperMainService = createDecorator<ISandboxHelperMainService>('sandboxHelper');
export interface ISandboxHelperMainService extends ISandboxHelperService {
readonly _serviceBrand: undefined;
}

View File

@@ -0,0 +1,34 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { isLinux } from '../../../base/common/platform.js';
import { findExecutable } from '../../../base/node/processes.js';
import { ISandboxDependencyStatus, ISandboxHelperService } from '../common/sandboxHelperService.js';
type FindCommand = (command: string) => Promise<string | undefined>;
export class SandboxHelperService implements ISandboxHelperService {
declare readonly _serviceBrand: undefined;
static async checkSandboxDependenciesWith(findCommand: FindCommand, linux: boolean = isLinux): Promise<ISandboxDependencyStatus | undefined> {
if (!linux) {
return undefined;
}
const [bubblewrapPath, socatPath] = await Promise.all([
findCommand('bwrap'),
findCommand('socat'),
]);
return {
bubblewrapInstalled: !!bubblewrapPath,
socatInstalled: !!socatPath,
};
}
checkSandboxDependencies(): Promise<ISandboxDependencyStatus | undefined> {
return SandboxHelperService.checkSandboxDependenciesWith(findExecutable);
}
}

View File

@@ -99,6 +99,8 @@ import { McpManagementChannel } from '../../platform/mcp/common/mcpManagementIpc
import { AllowedMcpServersService } from '../../platform/mcp/common/allowedMcpServersService.js';
import { IMcpGalleryManifestService } from '../../platform/mcp/common/mcpGalleryManifest.js';
import { McpGalleryManifestIPCService } from '../../platform/mcp/common/mcpGalleryManifestServiceIpc.js';
import { SANDBOX_HELPER_CHANNEL_NAME, SandboxHelperChannel } from '../../platform/sandbox/common/sandboxHelperIpc.js';
import { SandboxHelperService } from '../../platform/sandbox/node/sandboxHelper.js';
const eventPrefix = 'monacoworkbench';
@@ -265,6 +267,8 @@ export async function setupServerServices(connectionToken: ServerConnectionToken
const telemetryChannel = new ServerTelemetryChannel(accessor.get(IServerTelemetryService), oneDsAppender);
socketServer.registerChannel('telemetry', telemetryChannel);
socketServer.registerChannel(SANDBOX_HELPER_CHANNEL_NAME, new SandboxHelperChannel(new SandboxHelperService()));
socketServer.registerChannel(REMOTE_TERMINAL_CHANNEL_NAME, new RemoteTerminalChannel(environmentService, logService, ptyHostService, productService, extensionManagementService, configurationService));
const remoteExtensionsScanner = new RemoteExtensionsScannerService(instantiationService.createInstance(ExtensionManagementCLI, productService.extensionsForceVersionByQuality ?? [], logService), environmentService, userDataProfilesService, extensionsScannerService, logService, extensionGalleryService, languagePackService, extensionManagementService);

View File

@@ -89,6 +89,7 @@ import '../workbench/services/extensions/electron-browser/nativeExtensionService
import '../platform/userDataProfile/electron-browser/userDataProfileStorageService.js';
import '../workbench/services/auxiliaryWindow/electron-browser/auxiliaryWindowService.js';
import '../platform/extensionManagement/electron-browser/extensionsProfileScannerService.js';
import '../platform/sandbox/electron-browser/sandboxHelperService.js';
import '../platform/webContentExtractor/electron-browser/webContentExtractorService.js';
import '../workbench/services/browserView/electron-browser/playwrightWorkbenchService.js';
import '../workbench/services/process/electron-browser/processService.js';

View File

@@ -71,6 +71,7 @@ import '../platform/extensionResourceLoader/browser/extensionResourceLoaderServi
import '../workbench/services/auxiliaryWindow/browser/auxiliaryWindowService.js';
import '../workbench/services/browserElements/browser/webBrowserElementsService.js';
import '../workbench/services/power/browser/powerService.js';
import '../platform/sandbox/browser/sandboxHelperService.js';
import { InstantiationType, registerSingleton } from '../platform/instantiation/common/extensions.js';
import { IAccessibilityService } from '../platform/accessibility/common/accessibility.js';

View File

@@ -0,0 +1,70 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as dom from '../../../../../../../base/browser/dom.js';
import { MarkdownString } from '../../../../../../../base/common/htmlContent.js';
import { localize } from '../../../../../../../nls.js';
import { IContextKeyService } from '../../../../../../../platform/contextkey/common/contextkey.js';
import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js';
import { IKeybindingService } from '../../../../../../../platform/keybinding/common/keybinding.js';
import { IMarkdownRenderer } from '../../../../../../../platform/markdown/browser/markdownRenderer.js';
import { IChatToolInvocation, type IChatTerminalToolInvocationData } from '../../../../common/chatService/chatService.js';
import { ILanguageModelToolsService } from '../../../../common/tools/languageModelToolsService.js';
import { AcceptToolConfirmationActionId, SkipToolConfirmationActionId } from '../../../actions/chatToolActions.js';
import { IChatCodeBlockInfo, IChatWidgetService } from '../../../chat.js';
import { IChatContentPartRenderContext } from '../chatContentParts.js';
import { AbstractToolConfirmationSubPart } from './abstractToolConfirmationSubPart.js';
export class ChatMissingSandboxDepsConfirmationSubPart extends AbstractToolConfirmationSubPart {
public readonly codeblocks: IChatCodeBlockInfo[] = [];
constructor(
toolInvocation: IChatToolInvocation,
_terminalData: IChatTerminalToolInvocationData,
context: IChatContentPartRenderContext,
private readonly renderer: IMarkdownRenderer,
@IInstantiationService instantiationService: IInstantiationService,
@IKeybindingService keybindingService: IKeybindingService,
@IContextKeyService contextKeyService: IContextKeyService,
@IChatWidgetService chatWidgetService: IChatWidgetService,
@ILanguageModelToolsService languageModelToolsService: ILanguageModelToolsService,
) {
super(toolInvocation, context, instantiationService, keybindingService, contextKeyService, chatWidgetService, languageModelToolsService);
this.render({
allowActionId: AcceptToolConfirmationActionId,
skipActionId: SkipToolConfirmationActionId,
allowLabel: localize('missingDeps.install', "Install"),
skipLabel: localize('missingDeps.cancel', "Cancel"),
partType: 'chatMissingSandboxDepsConfirmation',
});
}
protected override createContentElement(): HTMLElement {
const state = this.toolInvocation.state.get();
const message = state.type === IChatToolInvocation.StateKind.WaitingForConfirmation
? state.confirmationMessages?.message
: undefined;
const container = dom.$('.chat-missing-sandbox-deps-confirmation');
if (message) {
const mdMessage = typeof message === 'string' ? new MarkdownString(message) : message;
const rendered = this.renderer.render(mdMessage);
this._register(rendered);
container.appendChild(rendered.element);
}
return container;
}
protected override getTitle(): string {
const state = this.toolInvocation.state.get();
if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation && state.confirmationMessages?.title) {
return typeof state.confirmationMessages.title === 'string'
? state.confirmationMessages.title
: state.confirmationMessages.title.value;
}
return '';
}
}

View File

@@ -9,7 +9,7 @@ import { Disposable, DisposableStore, IDisposable } from '../../../../../../../b
import { autorun, derived } from '../../../../../../../base/common/observable.js';
import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js';
import { IMarkdownRenderer } from '../../../../../../../platform/markdown/browser/markdownRenderer.js';
import { IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind } from '../../../../common/chatService/chatService.js';
import { IChatToolInvocation, IChatToolInvocationSerialized, isLegacyChatTerminalToolInvocationData, ToolConfirmKind } from '../../../../common/chatService/chatService.js';
import { IChatRendererContent } from '../../../../common/model/chatViewModel.js';
import { IChatTodoListService } from '../../../../common/tools/chatTodoListService.js';
import { CodeBlockModelCollection } from '../../../../common/widget/codeBlockModelCollection.js';
@@ -23,6 +23,7 @@ import { ChatInputOutputMarkdownProgressPart } from './chatInputOutputMarkdownPr
import { ChatMcpAppSubPart, IMcpAppRenderData } from './chatMcpAppSubPart.js';
import { ChatResultListSubPart } from './chatResultListSubPart.js';
import { ChatSimpleToolProgressPart } from './chatSimpleToolProgressPart.js';
import { ChatMissingSandboxDepsConfirmationSubPart } from './chatMissingSandboxDepsConfirmationSubPart.js';
import { ChatModifiedFilesConfirmationSubPart } from './chatModifiedFilesConfirmationSubPart.js';
import { ChatTerminalToolConfirmationSubPart } from './chatTerminalToolConfirmationSubPart.js';
import { ChatTerminalToolProgressPart } from './chatTerminalToolProgressPart.js';
@@ -122,6 +123,7 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa
const isConfirmation = this.subPart instanceof ToolConfirmationSubPart ||
this.subPart instanceof ChatTerminalToolConfirmationSubPart ||
this.subPart instanceof ChatModifiedFilesConfirmationSubPart ||
this.subPart instanceof ChatMissingSandboxDepsConfirmationSubPart ||
this.subPart instanceof ExtensionsInstallConfirmationWidgetSubPart ||
this.subPart instanceof ChatToolPostExecuteConfirmationPart;
this.domNode.classList.toggle('has-confirmation', isConfirmation);
@@ -173,7 +175,9 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa
}
if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation) {
if (this.toolInvocation.toolSpecificData?.kind === 'terminal') {
if (this.toolInvocation.toolSpecificData?.kind === 'terminal' && !isLegacyChatTerminalToolInvocationData(this.toolInvocation.toolSpecificData) && this.toolInvocation.toolSpecificData.missingSandboxDependencies?.length) {
return this.instantiationService.createInstance(ChatMissingSandboxDepsConfirmationSubPart, this.toolInvocation, this.toolInvocation.toolSpecificData, this.context, this.renderer);
} else if (this.toolInvocation.toolSpecificData?.kind === 'terminal') {
return this.instantiationService.createInstance(ChatTerminalToolConfirmationSubPart, this.toolInvocation, this.toolInvocation.toolSpecificData, this.context, this.renderer, this.editorPool, this.currentWidthDelegate, this.codeBlockModelCollection, this.codeBlockStartIndex);
} else if (this.toolInvocation.toolSpecificData?.kind === 'modifiedFilesConfirmation') {
return this.instantiationService.createInstance(ChatModifiedFilesConfirmationSubPart, this.toolInvocation, this.context, this.listPool);

View File

@@ -559,6 +559,8 @@ export interface IChatTerminalToolInvocationData {
/** Whether the user chose to continue in background for this tool invocation */
didContinueInBackground?: boolean;
autoApproveInfo?: IMarkdownString;
/** Names of missing sandbox dependencies that the user may choose to install */
missingSandboxDependencies?: string[];
}
/**

View File

@@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import { Disposable } from '../../../../../../../base/common/lifecycle.js';
import { ITerminalSandboxService } from '../../../common/terminalSandboxService.js';
import { ITerminalSandboxService, TerminalSandboxPrerequisiteCheck } from '../../../common/terminalSandboxService.js';
import type { ICommandLineRewriter, ICommandLineRewriterOptions, ICommandLineRewriterResult } from './commandLineRewriter.js';
export class CommandLineSandboxRewriter extends Disposable implements ICommandLineRewriter {
@@ -15,15 +15,8 @@ export class CommandLineSandboxRewriter extends Disposable implements ICommandLi
}
async rewrite(options: ICommandLineRewriterOptions): Promise<ICommandLineRewriterResult | undefined> {
if (!(await this._sandboxService.isEnabled())) {
return undefined;
}
// Ensure sandbox config is initialized before wrapping
const sandboxConfigPath = await this._sandboxService.getSandboxConfigPath();
if (!sandboxConfigPath) {
// If no sandbox config is available, run without sandboxing
const sandboxPrereqs = await this._sandboxService.checkForSandboxingPrereqs();
if (!sandboxPrereqs.enabled || sandboxPrereqs.failedCheck === TerminalSandboxPrerequisiteCheck.Config) {
return undefined;
}

View File

@@ -28,7 +28,7 @@ import { ITerminalLogService, ITerminalProfile } from '../../../../../../platfor
import { IRemoteAgentService } from '../../../../../services/remote/common/remoteAgentService.js';
import { TerminalToolConfirmationStorageKeys } from '../../../../chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.js';
import { IChatService, type IChatTerminalToolInvocationData } from '../../../../chat/common/chatService/chatService.js';
import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IStreamedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolInvocationStreamContext, IToolResult, ToolDataSource, ToolInvocationPresentation, ToolProgress } from '../../../../chat/common/tools/languageModelToolsService.js';
import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolConfirmationMessages, IStreamedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolInvocationStreamContext, IToolResult, ToolDataSource, ToolInvocationPresentation, ToolProgress } from '../../../../chat/common/tools/languageModelToolsService.js';
import { ITerminalChatService, ITerminalService, type ITerminalInstance } from '../../../../terminal/browser/terminal.js';
import { ITerminalProfileResolverService } from '../../../../terminal/common/terminal.js';
import { TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js';
@@ -72,7 +72,7 @@ import { clamp } from '../../../../../../base/common/numbers.js';
import { IOutputAnalyzer } from './outputAnalyzer.js';
import { SandboxOutputAnalyzer } from './sandboxOutputAnalyzer.js';
import { IAgentSessionsService } from '../../../../chat/browser/agentSessions/agentSessionsService.js';
import { ITerminalSandboxService, type ITerminalSandboxResolvedNetworkDomains } from '../../common/terminalSandboxService.js';
import { ITerminalSandboxService, TerminalSandboxPrerequisiteCheck, type ITerminalSandboxResolvedNetworkDomains } from '../../common/terminalSandboxService.js';
import { LanguageModelPartAudience } from '../../../../chat/common/languageModels.js';
// #region Tool data
@@ -546,7 +546,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
instance = toolTerminal.instance;
}
}
const [os, shell, cwd, isTerminalSandboxEnabled] = await Promise.all([
const [os, shell, cwd, sandboxPrereqs] = await Promise.all([
this._osBackend,
this._profileFetcher.getCopilotShell(),
(async () => {
@@ -558,11 +558,16 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
}
return cwd;
})(),
this._terminalSandboxService.isEnabled()
this._terminalSandboxService.checkForSandboxingPrereqs()
]);
const language = os === OperatingSystem.Windows ? 'pwsh' : 'sh';
const isTerminalSandboxEnabled = sandboxPrereqs.enabled;
const requiresUnsandboxConfirmation = isTerminalSandboxEnabled && args.requestUnsandboxedExecution === true;
const missingDependencies = sandboxPrereqs.failedCheck === TerminalSandboxPrerequisiteCheck.Dependencies && sandboxPrereqs.missingDependencies?.length
? sandboxPrereqs.missingDependencies
: undefined;
const terminalToolSessionId = generateUuid();
// Generate a custom command ID to link the command between renderer and pty host
const terminalCommandId = `tool-${generateUuid()}`;
@@ -603,8 +608,28 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
isBackground: args.isBackground,
requestUnsandboxedExecution: requiresUnsandboxConfirmation,
requestUnsandboxedExecutionReason: args.requestUnsandboxedExecutionReason,
missingSandboxDependencies: missingDependencies,
};
let sandboxConfirmationMessageForMissingDeps: IToolConfirmationMessages | undefined = undefined;
// If sandbox dependencies are missing, show a confirmation asking the user to install them.
// This is handled before the tool is invoked so the model never sees the dependency error.
if (missingDependencies) {
const depsList = missingDependencies.join(', ');
sandboxConfirmationMessageForMissingDeps = {
title: localize('runInTerminal.missingDeps.title', "Missing Sandbox Dependencies"),
message: new MarkdownString(localize(
'runInTerminal.missingDeps.message',
"The following dependencies required for sandboxed execution are not installed: {0}. Would you like to install them?",
depsList
)),
customButtons: [
localize('runInTerminal.missingDeps.install', "Install"),
localize('runInTerminal.missingDeps.cancel', "Cancel"),
],
};
}
// HACK: Exit early if there's an alternative recommendation, this is a little hacky but
// it's the current mechanism for re-routing terminal tool calls to something else.
const alternativeRecommendation = getRecommendedToolsOverRunInTerminal(args.command, this._languageModelToolsService);
@@ -818,7 +843,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
return {
invocationMessage,
icon: toolSpecificData.commandLine.isSandboxWrapped ? Codicon.terminalSecure : Codicon.terminal,
confirmationMessages,
confirmationMessages: sandboxConfirmationMessageForMissingDeps ?? confirmationMessages,
toolSpecificData,
};
}
@@ -866,6 +891,61 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
};
}
// Handle missing sandbox dependencies install flow.
// The user was shown a confirmation window in prepareToolInvocation.
if (toolSpecificData.missingSandboxDependencies?.length) {
const installButton = localize('runInTerminal.missingDeps.install', "Install");
if (invocation.selectedCustomButton === installButton) {
// Install dependencies, focus terminal for sudo password, wait for completion
const sessionResource = invocation.context.sessionResource;
const { exitCode } = await this._terminalSandboxService.installMissingSandboxDependencies(toolSpecificData.missingSandboxDependencies, sessionResource, token, {
createTerminal: async () => this._terminalService.createTerminal({}),
focusTerminal: async (terminal) => {
this._terminalService.setActiveInstance(terminal as ITerminalInstance);
await this._terminalService.revealTerminal(terminal as ITerminalInstance, true);
terminal.focus();
},
});
if (exitCode !== undefined && exitCode !== 0) {
return {
content: [{
kind: 'text',
value: localize(
'runInTerminal.missingDeps.failed',
"Sandbox dependency installation failed (exit code {0}). The command was not executed.",
exitCode
),
}],
};
}
if (exitCode === undefined) {
return {
content: [{
kind: 'text',
value: localize(
'runInTerminal.missingDeps.unknown',
"Could not determine whether sandbox dependency installation succeeded. The command was not executed."
),
}],
};
}
// Installation succeeded — fall through to execute the original command
this._logService.info('RunInTerminalTool: Sandbox dependency installation succeeded, proceeding with command execution');
} else {
// User chose to cancel — do not run the command
this._logService.info('RunInTerminalTool: User cancelled sandbox dependency installation');
return {
content: [{
kind: 'text',
value: localize(
'runInTerminal.missingDeps.cancelled',
"Sandbox dependency installation was cancelled by the user."
),
}],
};
}
}
const args = invocation.parameters as IRunInTerminalInputParams;
this._logService.debug(`RunInTerminalTool: Invoking with options ${JSON.stringify(args)}`);
let toolResultMessage: string | IMarkdownString | undefined;

View File

@@ -3,9 +3,11 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { timeout } from '../../../../../base/common/async.js';
import { VSBuffer } from '../../../../../base/common/buffer.js';
import { CancellationToken } from '../../../../../base/common/cancellation.js';
import { Event } from '../../../../../base/common/event.js';
import { Disposable } from '../../../../../base/common/lifecycle.js';
import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js';
import { FileAccess } from '../../../../../base/common/network.js';
import { dirname, posix, win32 } from '../../../../../base/common/path.js';
import { OperatingSystem, OS } from '../../../../../base/common/platform.js';
@@ -24,6 +26,13 @@ import { ITrustedDomainService } from '../../../url/common/trustedDomainService.
import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';
import { IProductService } from '../../../../../platform/product/common/productService.js';
import { ILifecycleService, WillShutdownJoinerOrder } from '../../../../services/lifecycle/common/lifecycle.js';
import { ISandboxDependencyStatus, ISandboxHelperService } from '../../../../../platform/sandbox/common/sandboxHelperService.js';
import { TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js';
import { MarkdownString } from '../../../../../base/common/htmlContent.js';
import { ChatElicitationRequestPart } from '../../../chat/common/model/chatProgressTypes/chatElicitationRequestPart.js';
import { ChatModel } from '../../../chat/common/model/chatModel.js';
import { ElicitationState, IChatService } from '../../../chat/common/chatService/chatService.js';
import { SANDBOX_HELPER_CHANNEL_NAME, SandboxHelperChannelClient } from '../../../../../platform/sandbox/common/sandboxHelperIpc.js';
export const ITerminalSandboxService = createDecorator<ITerminalSandboxService>('terminalSandboxService');
@@ -32,15 +41,71 @@ export interface ITerminalSandboxResolvedNetworkDomains {
deniedDomains: string[];
}
export const enum TerminalSandboxPrerequisiteCheck {
Config = 'config',
Dependencies = 'dependencies',
}
export interface ITerminalSandboxPrerequisiteCheckResult {
enabled: boolean;
sandboxConfigPath: string | undefined;
failedCheck: TerminalSandboxPrerequisiteCheck | undefined;
missingDependencies?: string[];
}
/**
* Abstraction over terminal operations needed by the install flow.
* Provided by the browser-layer caller so the common-layer service
* does not import browser types directly.
*/
export interface ISandboxDependencyInstallTerminal {
sendText(text: string, addNewLine?: boolean): Promise<void>;
focus(): void;
capabilities: {
get(id: TerminalCapability.CommandDetection): { onCommandFinished: Event<{ exitCode: number | undefined }> } | undefined;
onDidAddCapability: Event<{ id: TerminalCapability }>;
};
onDidInputData: Event<string>;
onDisposed: Event<unknown>;
}
/**
* Context passed to the password prompt during dependency installation.
*/
interface ISandboxDependencyInstallTerminalContext {
focusTerminal(): Promise<void>;
onDidInputData: Event<string>;
onDisposed: Event<unknown>;
didSendInstallCommand(): boolean;
}
export interface ISandboxDependencyInstallOptions {
/**
* Creates or obtains a terminal for running the install command.
*/
createTerminal(): Promise<ISandboxDependencyInstallTerminal>;
/**
* Focuses the terminal for password entry.
*/
focusTerminal(terminal: ISandboxDependencyInstallTerminal): Promise<void>;
}
export interface ISandboxDependencyInstallResult {
exitCode: number | undefined;
}
export interface ITerminalSandboxService {
readonly _serviceBrand: undefined;
isEnabled(): Promise<boolean>;
getOS(): Promise<OperatingSystem>;
checkForSandboxingPrereqs(forceRefresh?: boolean): Promise<ITerminalSandboxPrerequisiteCheckResult>;
wrapCommand(command: string, requestUnsandboxedExecution?: boolean): string;
getSandboxConfigPath(forceRefresh?: boolean): Promise<string | undefined>;
getTempDir(): URI | undefined;
setNeedsForceUpdateConfigFile(): void;
getResolvedNetworkDomains(): ITerminalSandboxResolvedNetworkDomains;
getMissingSandboxDependencies(): Promise<string[]>;
installMissingSandboxDependencies(missingDependencies: string[], sessionResource: URI | undefined, token: CancellationToken, options: ISandboxDependencyInstallOptions): Promise<ISandboxDependencyInstallResult>;
}
export class TerminalSandboxService extends Disposable implements ITerminalSandboxService {
@@ -50,6 +115,7 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb
private _srtPathResolved = false;
private _execPath?: string;
private _sandboxConfigPath: string | undefined;
private _sandboxDependencyStatus: ISandboxDependencyStatus | undefined;
private _needsForceUpdateConfigFile = true;
private _tempDir: URI | undefined;
private _sandboxSettingsId: string | undefined;
@@ -70,6 +136,8 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb
@IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService,
@IProductService private readonly _productService: IProductService,
@ILifecycleService private readonly _lifecycleService: ILifecycleService,
@ISandboxHelperService private readonly _sandboxHelperService: ISandboxHelperService,
@IChatService private readonly _chatService: IChatService,
) {
super();
this._appRoot = dirname(FileAccess.asFileUri('').path);
@@ -114,11 +182,7 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb
}
public async isEnabled(): Promise<boolean> {
const os = await this.getOS();
if (os === OperatingSystem.Windows) {
return false;
}
return this._configurationService.getValue<boolean>(TerminalChatAgentToolsSettingId.TerminalSandboxEnabled);
return await this._isSandboxConfiguredEnabled();
}
public async getOS(): Promise<OperatingSystem> {
@@ -163,7 +227,44 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb
this._needsForceUpdateConfigFile = true;
}
public async checkForSandboxingPrereqs(forceRefresh: boolean = false): Promise<ITerminalSandboxPrerequisiteCheckResult> {
if (!(await this._isSandboxConfiguredEnabled())) {
return {
enabled: false,
sandboxConfigPath: undefined,
failedCheck: undefined,
};
}
const sandboxConfigPath = await this.getSandboxConfigPath(forceRefresh);
if (!sandboxConfigPath) {
return {
enabled: true,
sandboxConfigPath,
failedCheck: TerminalSandboxPrerequisiteCheck.Config,
};
}
if (!(await this._checkSandboxDependencies(forceRefresh))) {
return {
enabled: true,
sandboxConfigPath,
failedCheck: TerminalSandboxPrerequisiteCheck.Dependencies,
missingDependencies: await this.getMissingSandboxDependencies(),
};
}
return {
enabled: true,
sandboxConfigPath,
failedCheck: undefined,
};
}
public async getSandboxConfigPath(forceRefresh: boolean = false): Promise<string | undefined> {
if (!(await this._isSandboxConfiguredEnabled())) {
return undefined;
}
await this._resolveSrtPath();
if (!this._sandboxConfigPath || forceRefresh || this._needsForceUpdateConfigFile) {
this._sandboxConfigPath = await this._createSandboxConfig();
@@ -172,10 +273,164 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb
return this._sandboxConfigPath;
}
private async _checkSandboxDependencies(forceRefresh = false): Promise<boolean> {
const os = await this.getOS();
if (os === OperatingSystem.Windows) {
return false;
}
const sandboxDependencyStatus = await this._resolveSandboxDependencyStatus(forceRefresh);
this._sandboxDependencyStatus = sandboxDependencyStatus;
if (sandboxDependencyStatus && !sandboxDependencyStatus.bubblewrapInstalled) {
this._logService.warn('TerminalSandboxService: bubblewrap (bwrap) is not installed');
}
if (sandboxDependencyStatus && !sandboxDependencyStatus.socatInstalled) {
this._logService.warn('TerminalSandboxService: socat is not installed');
}
return sandboxDependencyStatus ? sandboxDependencyStatus.bubblewrapInstalled && sandboxDependencyStatus.socatInstalled : true;
}
public async getMissingSandboxDependencies(): Promise<string[]> {
const os = await this.getOS();
if (os === OperatingSystem.Windows) {
return [];
}
if (!this._sandboxDependencyStatus || !this._sandboxDependencyStatus.bubblewrapInstalled || !this._sandboxDependencyStatus.socatInstalled) {
this._sandboxDependencyStatus = await this._resolveSandboxDependencyStatus(true);
}
const missing: string[] = [];
if (this._sandboxDependencyStatus && !this._sandboxDependencyStatus.bubblewrapInstalled) {
missing.push('bubblewrap');
}
if (this._sandboxDependencyStatus && !this._sandboxDependencyStatus.socatInstalled) {
missing.push('socat');
}
return missing;
}
public async installMissingSandboxDependencies(missingDependencies: string[], sessionResource: URI | undefined, token: CancellationToken, options: ISandboxDependencyInstallOptions): Promise<ISandboxDependencyInstallResult> {
const depsList = missingDependencies.join(' ');
const installCommand = `sudo apt install -y ${depsList}`;
const instance = await options.createTerminal();
// Wait for the install command to finish so the chat can proceed automatically.
let installCommandSent = false;
const completionPromise = new Promise<number | undefined>(resolve => {
const store = new DisposableStore();
let resolved = false;
const resolveOnce = (code: number | undefined) => {
if (resolved) {
return;
}
resolved = true;
store.dispose();
resolve(code);
};
const attachListener = () => {
const detection = instance.capabilities.get(TerminalCapability.CommandDetection);
if (detection) {
store.add(detection.onCommandFinished(cmd => resolveOnce(cmd.exitCode)));
}
};
attachListener();
store.add(instance.capabilities.onDidAddCapability(e => {
if (e.id === TerminalCapability.CommandDetection) {
attachListener();
}
}));
// Handle terminal disposal
store.add(instance.onDisposed(() => resolveOnce(undefined)));
// Handle cancellation
store.add(token.onCancellationRequested(() => resolveOnce(undefined)));
// Safety timeout — 5 minutes should be more than enough for apt install
const safetyTimeout = timeout(5 * 60 * 1000);
store.add({ dispose: () => safetyTimeout.cancel() });
safetyTimeout.then(() => resolveOnce(undefined));
const passwordPrompt = this._createMissingDependencyPasswordPrompt(sessionResource, {
focusTerminal: () => options.focusTerminal(instance),
onDidInputData: instance.onDidInputData,
onDisposed: instance.onDisposed,
didSendInstallCommand: () => installCommandSent,
}, token);
store.add(passwordPrompt);
});
// Send the command after listeners are attached so we never miss the event.
// Set installCommandSent only after sendText completes because sendText
// fires onDidInputData internally, and the password-prompt listener would
// dismiss the elicitation prematurely if the flag were already true.
await instance.sendText(installCommand, true);
installCommandSent = true;
return { exitCode: await completionPromise };
}
/**
* Shows a chat elicitation that keeps the "Install" flow grounded in chat while
* the user focuses the terminal and types a sudo password.
*/
private _createMissingDependencyPasswordPrompt(sessionResource: URI | undefined, promptContext: ISandboxDependencyInstallTerminalContext, token: CancellationToken): DisposableStore {
const chatModel = sessionResource && this._chatService.getSession(sessionResource);
if (!(chatModel instanceof ChatModel)) {
return new DisposableStore();
}
const request = chatModel.getRequests().at(-1);
if (!request) {
return new DisposableStore();
}
const part = new ChatElicitationRequestPart(
localize('runInTerminal.missingDeps.passwordPromptTitle', "The terminal is awaiting input."),
new MarkdownString(localize(
'runInTerminal.missingDeps.passwordPromptMessage',
"Installing missing sandbox dependencies may prompt for your sudo password. Select Focus Terminal to type it in the terminal."
)),
'',
localize('runInTerminal.missingDeps.focusTerminal', 'Focus Terminal'),
undefined,
async () => {
await promptContext.focusTerminal();
return ElicitationState.Pending;
}
);
chatModel.acceptResponseProgress(request, part);
const store = new DisposableStore();
const disposePrompt = () => store.dispose();
store.add({ dispose: () => part.hide() });
store.add(token.onCancellationRequested(disposePrompt));
store.add(promptContext.onDisposed(disposePrompt));
store.add(promptContext.onDidInputData(data => {
if (promptContext.didSendInstallCommand() && data.length > 0) {
disposePrompt();
}
}));
return store;
}
private _quoteShellArgument(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
private async _isSandboxConfiguredEnabled(): Promise<boolean> {
const os = await this.getOS();
if (os === OperatingSystem.Windows) {
return false;
}
return this._configurationService.getValue<boolean>(TerminalChatAgentToolsSettingId.TerminalSandboxEnabled);
}
private async _resolveSrtPath(): Promise<void> {
if (this._srtPathResolved) {
return;
@@ -318,4 +573,21 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb
const workspaceFolderPaths = this._workspaceContextService.getWorkspace().folders.map(folder => folder.uri.path);
return [...new Set([...workspaceFolderPaths, ...this._defaultWritePaths, ...(configuredAllowWrite ?? [])])];
}
private async _resolveSandboxDependencyStatus(forceRefresh = false): Promise<ISandboxDependencyStatus | undefined> {
if (!forceRefresh && this._sandboxDependencyStatus) {
return this._sandboxDependencyStatus;
}
const connection = this._remoteAgentService.getConnection();
if (connection) {
return connection.withChannel(SANDBOX_HELPER_CHANNEL_NAME, channel => {
const sandboxHelper = new SandboxHelperChannelClient(channel);
return sandboxHelper.checkSandboxDependencies();
});
}
return this._sandboxHelperService.checkSandboxDependencies();
}
}

View File

@@ -8,7 +8,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/
import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
import { TestLifecycleService, workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js';
import { TestProductService } from '../../../../../test/common/workbenchTestServices.js';
import { TerminalSandboxService } from '../../common/terminalSandboxService.js';
import { TerminalSandboxPrerequisiteCheck, TerminalSandboxService } from '../../common/terminalSandboxService.js';
import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
import { IFileService } from '../../../../../../platform/files/common/files.js';
import { IEnvironmentService } from '../../../../../../platform/environment/common/environment.js';
@@ -26,6 +26,7 @@ import { IRemoteAgentEnvironment } from '../../../../../../platform/remote/commo
import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder, IWorkspaceFoldersChangeEvent, IWorkspaceIdentifier, ISingleFolderWorkspaceIdentifier, WorkbenchState } from '../../../../../../platform/workspace/common/workspace.js';
import { testWorkspace } from '../../../../../../platform/workspace/test/common/testWorkspace.js';
import { ILifecycleService } from '../../../../../services/lifecycle/common/lifecycle.js';
import { ISandboxDependencyStatus, ISandboxHelperService } from '../../../../../../platform/sandbox/common/sandboxHelperService.js';
suite('TerminalSandboxService - allowTrustedDomains', () => {
const store = ensureNoDisposablesAreLeakedInTestSuite();
@@ -37,6 +38,7 @@ suite('TerminalSandboxService - allowTrustedDomains', () => {
let lifecycleService: TestLifecycleService;
let workspaceContextService: MockWorkspaceContextService;
let productService: IProductService;
let sandboxHelperService: MockSandboxHelperService;
let createdFiles: Map<string, string>;
let createdFolders: string[];
let deletedFolders: string[];
@@ -70,6 +72,10 @@ suite('TerminalSandboxService - allowTrustedDomains', () => {
}
class MockRemoteAgentService {
getConnection() {
return null;
}
async getEnvironment(): Promise<IRemoteAgentEnvironment> {
// Return a Linux environment to ensure tests pass on Windows
// (sandbox is not supported on Windows)
@@ -148,6 +154,20 @@ suite('TerminalSandboxService - allowTrustedDomains', () => {
}
}
class MockSandboxHelperService implements ISandboxHelperService {
_serviceBrand: undefined;
callCount = 0;
status: ISandboxDependencyStatus = {
bubblewrapInstalled: true,
socatInstalled: true,
};
checkSandboxDependencies(): Promise<ISandboxDependencyStatus> {
this.callCount++;
return Promise.resolve(this.status);
}
}
setup(() => {
createdFiles = new Map();
createdFolders = [];
@@ -158,6 +178,7 @@ suite('TerminalSandboxService - allowTrustedDomains', () => {
fileService = new MockFileService();
lifecycleService = store.add(new TestLifecycleService());
workspaceContextService = new MockWorkspaceContextService();
sandboxHelperService = new MockSandboxHelperService();
productService = {
...TestProductService,
dataFolderName: '.test-data',
@@ -185,6 +206,41 @@ suite('TerminalSandboxService - allowTrustedDomains', () => {
instantiationService.stub(ITrustedDomainService, trustedDomainService);
instantiationService.stub(IWorkspaceContextService, workspaceContextService);
instantiationService.stub(ILifecycleService, lifecycleService);
instantiationService.stub(ISandboxHelperService, sandboxHelperService);
});
test('dependency checks should not be called for isEnabled', async () => {
const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService));
strictEqual(await sandboxService.isEnabled(), true, 'Sandbox should be enabled when dependencies are present');
strictEqual(await sandboxService.isEnabled(), true, 'Sandbox should stay enabled on subsequent checks');
strictEqual(sandboxHelperService.callCount, 0, 'Dependency checks should not be called for isEnabled');
});
test('should report dependency prereq failures', async () => {
sandboxHelperService.status = {
bubblewrapInstalled: false,
socatInstalled: true,
};
const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService));
const result = await sandboxService.checkForSandboxingPrereqs();
strictEqual(result.enabled, true, 'Sandbox should be enabled even when dependencies are missing');
strictEqual(result.failedCheck, TerminalSandboxPrerequisiteCheck.Dependencies, 'Missing dependencies should be reported as the failed prereq');
strictEqual(result.missingDependencies?.length, 1, 'Missing dependency list should be included');
strictEqual(result.missingDependencies?.[0], 'bubblewrap', 'The missing dependency should be reported');
ok(result.sandboxConfigPath, 'Sandbox config path should still be returned when config creation succeeds');
});
test('should report successful sandbox prereq checks', async () => {
const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService));
const result = await sandboxService.checkForSandboxingPrereqs();
strictEqual(result.enabled, true, 'Sandbox should be enabled when prereqs pass');
strictEqual(result.failedCheck, undefined, 'No failed check should be reported when prereqs pass');
strictEqual(result.missingDependencies, undefined, 'Missing dependencies should be omitted when prereqs pass');
ok(result.sandboxConfigPath, 'Sandbox config path should be returned when prereqs pass');
});
test('should filter out sole wildcard (*) from trusted domains', async () => {

View File

@@ -10,7 +10,7 @@ import type { TestInstantiationService } from '../../../../../../platform/instan
import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js';
import { CommandLineSandboxRewriter } from '../../browser/tools/commandLineRewriter/commandLineSandboxRewriter.js';
import type { ICommandLineRewriterOptions } from '../../browser/tools/commandLineRewriter/commandLineRewriter.js';
import { ITerminalSandboxService } from '../../common/terminalSandboxService.js';
import { ITerminalSandboxService, TerminalSandboxPrerequisiteCheck } from '../../common/terminalSandboxService.js';
suite('CommandLineSandboxRewriter', () => {
const store = ensureNoDisposablesAreLeakedInTestSuite();
@@ -24,6 +24,7 @@ suite('CommandLineSandboxRewriter', () => {
isEnabled: async () => false,
wrapCommand: (command, _requestUnsandboxedExecution) => command,
getSandboxConfigPath: async () => '/tmp/sandbox.json',
checkForSandboxingPrereqs: async () => ({ enabled: false, sandboxConfigPath: undefined, failedCheck: TerminalSandboxPrerequisiteCheck.Config }),
getTempDir: () => undefined,
setNeedsForceUpdateConfigFile: () => { },
...overrides
@@ -48,9 +49,23 @@ suite('CommandLineSandboxRewriter', () => {
test('returns undefined when sandbox config is unavailable', async () => {
stubSandboxService({
isEnabled: async () => true,
wrapCommand: command => `wrapped:${command}`,
getSandboxConfigPath: async () => undefined,
checkForSandboxingPrereqs: async () => ({ enabled: false, sandboxConfigPath: undefined, failedCheck: TerminalSandboxPrerequisiteCheck.Config }),
});
const rewriter = store.add(instantiationService.createInstance(CommandLineSandboxRewriter));
const result = await rewriter.rewrite(createRewriteOptions('echo hello'));
strictEqual(result, undefined);
});
test('returns undefined when sandbox dependencies are unavailable', async () => {
stubSandboxService({
checkForSandboxingPrereqs: async () => ({
enabled: false,
sandboxConfigPath: '/tmp/sandbox.json',
failedCheck: TerminalSandboxPrerequisiteCheck.Dependencies,
missingDependencies: ['bubblewrap'],
}),
});
const rewriter = store.add(instantiationService.createInstance(CommandLineSandboxRewriter));
@@ -61,14 +76,13 @@ suite('CommandLineSandboxRewriter', () => {
test('wraps command when sandbox is enabled and config exists', async () => {
const calls: string[] = [];
stubSandboxService({
isEnabled: async () => true,
wrapCommand: (command, _requestUnsandboxedExecution) => {
calls.push('wrapCommand');
return `wrapped:${command}`;
},
getSandboxConfigPath: async () => {
calls.push('getSandboxConfigPath');
return '/tmp/sandbox.json';
checkForSandboxingPrereqs: async () => {
calls.push('checkForSandboxingPrereqs');
return { enabled: true, sandboxConfigPath: '/tmp/sandbox.json', failedCheck: undefined };
},
});
@@ -76,20 +90,19 @@ suite('CommandLineSandboxRewriter', () => {
const result = await rewriter.rewrite(createRewriteOptions('echo hello'));
strictEqual(result?.rewritten, 'wrapped:echo hello');
strictEqual(result?.reasoning, 'Wrapped command for sandbox execution');
deepStrictEqual(calls, ['getSandboxConfigPath', 'wrapCommand']);
deepStrictEqual(calls, ['checkForSandboxingPrereqs', 'wrapCommand']);
});
test('wraps command and forwards sandbox bypass flag when explicitly requested', async () => {
const calls: string[] = [];
stubSandboxService({
isEnabled: async () => true,
wrapCommand: (command, requestUnsandboxedExecution) => {
calls.push(`wrap:${command}:${String(requestUnsandboxedExecution)}`);
return `wrapped:${command}`;
},
getSandboxConfigPath: async () => {
calls.push('config');
return '/tmp/sandbox.json';
checkForSandboxingPrereqs: async () => {
calls.push('prereqs');
return { enabled: true, sandboxConfigPath: '/tmp/sandbox.json', failedCheck: undefined };
},
});
@@ -101,6 +114,6 @@ suite('CommandLineSandboxRewriter', () => {
strictEqual(result?.rewritten, 'wrapped:echo hello');
strictEqual(result?.reasoning, 'Wrapped command for sandbox execution');
deepStrictEqual(calls, ['config', 'wrap:echo hello:true']);
deepStrictEqual(calls, ['prereqs', 'wrap:echo hello:true']);
});
});

View File

@@ -21,6 +21,7 @@ import { FileService } from '../../../../../../platform/files/common/fileService
import type { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
import { NullLogService } from '../../../../../../platform/log/common/log.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js';
import { TerminalCapability } from '../../../../../../platform/terminal/common/capabilities/capabilities.js';
import { ITerminalProfile } from '../../../../../../platform/terminal/common/terminal.js';
import { IWorkspaceContextService, toWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js';
import { Workspace } from '../../../../../../platform/workspace/test/common/testWorkspace.js';
@@ -34,7 +35,7 @@ import { IChatService, type IChatTerminalToolInvocationData } from '../../../../
import { IChatWidgetService } from '../../../../chat/browser/chat.js';
import { ChatPermissionLevel } from '../../../../chat/common/constants.js';
import { LocalChatSessionUri } from '../../../../chat/common/model/chatUri.js';
import { ITerminalSandboxService } from '../../common/terminalSandboxService.js';
import { ITerminalSandboxService, TerminalSandboxPrerequisiteCheck, type ITerminalSandboxPrerequisiteCheckResult } from '../../common/terminalSandboxService.js';
import { ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocationPreparationContext, ToolDataSource, ToolSet, type ToolConfirmationAction } from '../../../../chat/common/tools/languageModelToolsService.js';
import { ITerminalChatService, ITerminalService, type ITerminalInstance } from '../../../../terminal/browser/terminal.js';
import { ITerminalProfileResolverService } from '../../../../terminal/common/terminal.js';
@@ -78,7 +79,9 @@ suite('RunInTerminalTool', () => {
let chatServiceDisposeEmitter: Emitter<{ sessionResources: URI[]; reason: 'cleared' }>;
let chatSessionArchivedEmitter: Emitter<IAgentSession>;
let sandboxEnabled: boolean;
let sandboxPrereqResult: ITerminalSandboxPrerequisiteCheckResult;
let terminalSandboxService: ITerminalSandboxService;
let createdTerminalInstance: ITerminalInstance;
let runInTerminalTool: TestRunInTerminalTool;
@@ -94,6 +97,36 @@ suite('RunInTerminalTool', () => {
setConfig(TerminalChatAgentToolsSettingId.EnableAutoApprove, true);
setConfig(TerminalChatAgentToolsSettingId.BlockDetectedFileWrites, 'outsideWorkspace');
sandboxEnabled = false;
sandboxPrereqResult = {
enabled: false,
sandboxConfigPath: undefined,
failedCheck: undefined,
};
const commandFinishedEmitter = new Emitter<{ exitCode: number | undefined }>();
const onDisposedEmitter = new Emitter<ITerminalInstance>();
const onDidAddCapabilityEmitter = new Emitter<{ id: TerminalCapability }>();
const onDidInputDataEmitter = new Emitter<string>();
createdTerminalInstance = {
sendText: async (_text: string) => {
// Simulate successful command completion after sendText
queueMicrotask(() => commandFinishedEmitter.fire({ exitCode: 0 }));
},
focus: () => { },
capabilities: {
get: (cap: TerminalCapability) => {
if (cap === TerminalCapability.CommandDetection) {
return {
onCommandFinished: commandFinishedEmitter.event,
};
}
return undefined;
},
onDidAddCapability: onDidAddCapabilityEmitter.event,
},
onDidInputData: onDidInputDataEmitter.event,
onDisposed: onDisposedEmitter.event,
} as unknown as ITerminalInstance;
terminalServiceDisposeEmitter = new Emitter<ITerminalInstance>();
chatServiceDisposeEmitter = new Emitter<{ sessionResources: URI[]; reason: 'cleared' }>();
chatSessionArchivedEmitter = new Emitter<IAgentSession>();
@@ -123,10 +156,18 @@ suite('RunInTerminalTool', () => {
isEnabled: async () => sandboxEnabled,
wrapCommand: (command: string, requestUnsandboxedExecution?: boolean) => requestUnsandboxedExecution ? `unsandboxed:${command}` : `sandbox:${command}`,
getSandboxConfigPath: async () => sandboxEnabled ? '/tmp/sandbox.json' : undefined,
checkForSandboxingPrereqs: async () => sandboxPrereqResult,
getTempDir: () => undefined,
setNeedsForceUpdateConfigFile: () => { },
getOS: async () => OperatingSystem.Linux,
getResolvedNetworkDomains: () => ({ allowedDomains: [], deniedDomains: [] }),
getMissingSandboxDependencies: async () => [],
installMissingSandboxDependencies: async (missingDependencies, _sessionResource, _token, options) => {
const terminal = await options.createTerminal();
await options.focusTerminal(terminal);
await terminal.sendText(`sudo apt install -y ${missingDependencies.join(' ')}`, true);
return { exitCode: 0 };
},
};
instantiationService.stub(ITerminalSandboxService, terminalSandboxService);
@@ -140,7 +181,10 @@ suite('RunInTerminalTool', () => {
},
});
instantiationService.stub(ITerminalService, {
createTerminal: async () => createdTerminalInstance,
onDidDisposeInstance: terminalServiceDisposeEmitter.event,
revealTerminal: async () => { },
setActiveInstance: () => { },
setNextCommandId: async () => { }
});
instantiationService.stub(ITerminalProfileResolverService, {
@@ -252,6 +296,11 @@ suite('RunInTerminalTool', () => {
ok(!propertiesBefore?.['requestUnsandboxedExecution'], 'Expected no requestUnsandboxedExecution before enabling sandbox');
sandboxEnabled = true;
sandboxPrereqResult = {
enabled: true,
sandboxConfigPath: '/tmp/sandbox.json',
failedCheck: undefined,
};
const toolDataAfter = await instantiationService.invokeFunction(createRunInTerminalToolData);
const propertiesAfter = toolDataAfter.inputSchema?.properties as Record<string, object> | undefined;
@@ -259,6 +308,29 @@ suite('RunInTerminalTool', () => {
ok(toolDataAfter.modelDescription?.includes('Sandboxing:'), 'Expected sandbox instructions in description after enabling sandbox');
});
test('should show confirmation to install missing sandbox dependencies when prereq check fails', async () => {
sandboxEnabled = false;
sandboxPrereqResult = {
enabled: false,
sandboxConfigPath: '/tmp/sandbox.json',
failedCheck: TerminalSandboxPrerequisiteCheck.Dependencies,
missingDependencies: ['bubblewrap'],
};
const result = await executeToolTest({
command: 'echo hello',
explanation: 'Print hello',
goal: 'Print hello'
});
// The tool should return confirmation messages for the user
ok(result, 'Expected prepared invocation to be defined');
ok(result?.confirmationMessages, 'Expected confirmationMessages when deps are missing');
ok(result?.confirmationMessages?.customButtons?.length === 2, 'Expected two custom buttons');
// missingDependencies should be in toolSpecificData so invoke can handle it
strictEqual((result?.toolSpecificData as IChatTerminalToolInvocationData | undefined)?.missingSandboxDependencies?.length, 1);
});
test('should include allowed and denied network domains in model description', async () => {
sandboxEnabled = true;
terminalSandboxService.getResolvedNetworkDomains = () => ({
@@ -286,8 +358,12 @@ suite('RunInTerminalTool', () => {
});
test('should use sandbox labels when command is sandbox wrapped', async () => {
terminalSandboxService.isEnabled = async () => true;
terminalSandboxService.getSandboxConfigPath = async () => '/tmp/vscode-sandbox-settings.json';
sandboxEnabled = true;
sandboxPrereqResult = {
enabled: true,
sandboxConfigPath: '/tmp/vscode-sandbox-settings.json',
failedCheck: undefined,
};
terminalSandboxService.wrapCommand = (command: string) => `sandbox-runtime ${command}`;
const preparedInvocation = await executeToolTest({ command: 'echo hello' });
@@ -540,6 +616,11 @@ suite('RunInTerminalTool', () => {
suite('sandbox bypass requests', () => {
test('should force confirmation for explicit unsandboxed execution requests', async () => {
sandboxEnabled = true;
sandboxPrereqResult = {
enabled: true,
sandboxConfigPath: '/tmp/sandbox.json',
failedCheck: undefined,
};
runInTerminalTool.setBackendOs(OperatingSystem.Linux);
const result = await executeToolTest({
@@ -1949,10 +2030,13 @@ suite('ChatAgentToolsContribution - tool registration refresh', () => {
isEnabled: async () => sandboxEnabled,
wrapCommand: (command: string) => `sandbox:${command}`,
getSandboxConfigPath: async () => sandboxEnabled ? '/tmp/sandbox.json' : undefined,
checkForSandboxingPrereqs: async () => ({ enabled: sandboxEnabled, sandboxConfigPath: sandboxEnabled ? '/tmp/sandbox.json' : undefined, failedCheck: undefined }),
getTempDir: () => undefined,
setNeedsForceUpdateConfigFile: () => { },
getOS: async () => OperatingSystem.Linux,
getResolvedNetworkDomains: () => ({ allowedDomains: [], deniedDomains: [] }),
getMissingSandboxDependencies: async () => [],
installMissingSandboxDependencies: async () => ({ exitCode: 0 }),
};
instantiationService.stub(ITerminalSandboxService, terminalSandboxService);

View File

@@ -90,6 +90,7 @@ import './services/extensions/electron-browser/nativeExtensionService.js';
import '../platform/userDataProfile/electron-browser/userDataProfileStorageService.js';
import './services/auxiliaryWindow/electron-browser/auxiliaryWindowService.js';
import '../platform/extensionManagement/electron-browser/extensionsProfileScannerService.js';
import '../platform/sandbox/electron-browser/sandboxHelperService.js';
import '../platform/webContentExtractor/electron-browser/webContentExtractorService.js';
import '../platform/agentHost/electron-browser/agentHostService.js';
import './services/browserView/electron-browser/playwrightWorkbenchService.js';

View File

@@ -72,6 +72,7 @@ import '../platform/extensionResourceLoader/browser/extensionResourceLoaderServi
import './services/auxiliaryWindow/browser/auxiliaryWindowService.js';
import './services/browserElements/browser/webBrowserElementsService.js';
import './services/power/browser/powerService.js';
import '../platform/sandbox/browser/sandboxHelperService.js';
import { InstantiationType, registerSingleton } from '../platform/instantiation/common/extensions.js';
import { IAccessibilityService } from '../platform/accessibility/common/accessibility.js';