diff --git a/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts b/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts index 21f16d4c6a510..70292356e034b 100644 --- a/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts +++ b/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts @@ -325,7 +325,7 @@ export function modelCanUseImageURL(model: LanguageModelChat | IChatEndpoint): b * The model supports native PDF document processing via document content parts. */ export function modelSupportsPDFDocuments(model: LanguageModelChat | IChatEndpoint): boolean { - return isAnthropicFamily(model); + return isAnthropicFamily(model) || isGpt5PlusFamily(model) || isHiddenModelM(model); } /** diff --git a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts index 1a7431adc19e1..11015d800a86f 100644 --- a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts @@ -446,12 +446,19 @@ function rawMessagesToResponseAPI(modelId: string, messages: readonly Raw.ChatMe detail: c.imageUrl.detail || 'auto', image_url: c.imageUrl.url, })); + const asFiles = message.content + .filter((c): c is RawDocumentContentPart => c.type === Raw.ChatCompletionContentPartKind.Document) + .map(rawDocumentToResponsesInputFile) + .filter(isDefined); // todod@connor4312: hack while responses API only supports text output from tools input.push({ type: 'function_call_output', call_id: message.toolCallId, output: asText }); if (asImages.length) { input.push({ role: 'user', content: [{ type: 'input_text', text: 'Image associated with the above tool call:' }, ...asImages] }); } + if (asFiles.length) { + input.push({ role: 'user', content: [{ type: 'input_text', text: 'PDF associated with the above tool call:' }, ...asFiles] }); + } } } break; @@ -520,12 +527,28 @@ function getLatestCompactionMessageIndex(messages: readonly Raw.ChatMessage[]): return undefined; } +type RawDocumentContentPart = Extract; + +function rawDocumentToResponsesInputFile(part: RawDocumentContentPart): OpenAI.Responses.ResponseInputFile | undefined { + if (part.documentData.mediaType !== 'application/pdf') { + return undefined; + } + + return { + type: 'input_file', + filename: 'document.pdf', + file_data: `data:${part.documentData.mediaType};base64,${part.documentData.data}`, + }; +} + function rawContentToResponsesContent(part: Raw.ChatCompletionContentPart): OpenAI.Responses.ResponseInputContent | undefined { switch (part.type) { case Raw.ChatCompletionContentPartKind.Text: return { type: 'input_text', text: part.text }; case Raw.ChatCompletionContentPartKind.Image: return { type: 'input_image', detail: part.imageUrl.detail || 'auto', image_url: part.imageUrl.url }; + case Raw.ChatCompletionContentPartKind.Document: + return rawDocumentToResponsesInputFile(part); case Raw.ChatCompletionContentPartKind.Opaque: { const maybeCast = part.value as OpenAI.Responses.ResponseInputContent; if (maybeCast.type === 'input_text' || maybeCast.type === 'input_image' || maybeCast.type === 'input_file') { diff --git a/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts index f1c1c7e0b9b28..6eeb7cc687fed 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts @@ -12,7 +12,7 @@ import { ChatLocation } from '../../../chat/common/commonTypes'; import { ILogService } from '../../../log/common/logService'; import { isOpenAIContextManagementResponse } from '../../../networking/common/fetch'; import { IChatEndpoint, ICreateEndpointBodyOptions } from '../../../networking/common/networking'; -import { ChatCompletion, openAIContextManagementCompactionType, OpenAIContextManagementResponse, FilterReason, FinishedCompletionReason } from '../../../networking/common/openai'; +import { ChatCompletion, FilterReason, FinishedCompletionReason, openAIContextManagementCompactionType, OpenAIContextManagementResponse } from '../../../networking/common/openai'; import { IToolDeferralService } from '../../../networking/common/toolDeferralService'; import { IChatWebSocketManager, NullChatWebSocketManager } from '../../../networking/node/chatWebSocketManager'; import { TelemetryData } from '../../../telemetry/common/telemetryData'; @@ -365,6 +365,68 @@ describe('createResponsesRequestBody', () => { })).toBe(1234); }); + it('converts PDF document content parts to Responses input_file', () => { + const services = createPlatformServices(); + const accessor = services.createTestingAccessor(); + const instantiationService = accessor.get(IInstantiationService); + const endpoint = { ...testEndpoint, family: 'gpt-5.4', supportsVision: true }; + const base64Data = 'JVBERi0xLjQKMSAwIG9iago8PC9UeXBlIC9DYXRhbG9n'; + const messages: Raw.ChatMessage[] = [{ + role: Raw.ChatRole.User, + content: [{ + type: Raw.ChatCompletionContentPartKind.Document, + documentData: { data: base64Data, mediaType: 'application/pdf' }, + }], + }]; + + const body = instantiationService.invokeFunction(servicesAccessor => createResponsesRequestBody(servicesAccessor, createRequestOptions(messages, false), endpoint.model, endpoint)); + + expect(body.input?.[0]).toMatchObject({ + role: 'user', + content: [{ + type: 'input_file', + filename: 'document.pdf', + file_data: `data:application/pdf;base64,${base64Data}`, + }], + }); + + accessor.dispose(); + services.dispose(); + }); + + it('preserves PDF document tool results as Responses input_file follow-up content', () => { + const services = createPlatformServices(); + const accessor = services.createTestingAccessor(); + const instantiationService = accessor.get(IInstantiationService); + const base64Data = 'JVBERi0xLjQK'; + const messages: Raw.ChatMessage[] = [ + { + role: Raw.ChatRole.Assistant, + content: [], + toolCalls: [{ id: 'call_pdf', type: 'function', function: { name: 'read_file', arguments: '{"path":"doc.pdf"}' } }], + }, + { + role: Raw.ChatRole.Tool, + toolCallId: 'call_pdf', + content: [{ type: Raw.ChatCompletionContentPartKind.Document, documentData: { data: base64Data, mediaType: 'application/pdf' } }], + }, + ]; + + const body = instantiationService.invokeFunction(servicesAccessor => createResponsesRequestBody(servicesAccessor, createRequestOptions(messages, false), testEndpoint.model, testEndpoint)); + + expect(body.input?.[1]).toMatchObject({ type: 'function_call_output', call_id: 'call_pdf', output: '' }); + expect(body.input?.[2]).toMatchObject({ + role: 'user', + content: [ + { type: 'input_text', text: 'PDF associated with the above tool call:' }, + { type: 'input_file', filename: 'document.pdf', file_data: `data:application/pdf;base64,${base64Data}` }, + ], + }); + + accessor.dispose(); + services.dispose(); + }); + it('still slices websocket requests by stateful marker index when compaction is disabled', () => { const services = createPlatformServices(); const wsManager = new NullChatWebSocketManager(); diff --git a/extensions/copilot/src/platform/endpoint/test/node/chatModelCapabilities.spec.ts b/extensions/copilot/src/platform/endpoint/test/node/chatModelCapabilities.spec.ts index faa836756c8fb..8da59d5628e26 100644 --- a/extensions/copilot/src/platform/endpoint/test/node/chatModelCapabilities.spec.ts +++ b/extensions/copilot/src/platform/endpoint/test/node/chatModelCapabilities.spec.ts @@ -26,9 +26,16 @@ describe('modelSupportsPDFDocuments', () => { expect(modelSupportsPDFDocuments(fakeModel('Anthropic-custom'))).toBe(true); }); - test('returns false for non-Anthropic families', () => { + test('returns true for gpt-5 plus families', () => { + expect(modelSupportsPDFDocuments(fakeModel('gpt-5.4'))).toBe(true); + expect(modelSupportsPDFDocuments(fakeModel('gpt-5.4-mini'))).toBe(true); + expect(modelSupportsPDFDocuments(fakeModel('gpt-5.5'))).toBe(true); + expect(modelSupportsPDFDocuments(fakeModel('gpt-5.5-mini'))).toBe(true); expect(modelSupportsPDFDocuments(fakeModel('gpt-4'))).toBe(false); - expect(modelSupportsPDFDocuments(fakeModel('gpt-5.1'))).toBe(false); + expect(modelSupportsPDFDocuments(fakeModel('gpt-5.1'))).toBe(true); + }); + + test('returns false for other families', () => { expect(modelSupportsPDFDocuments(fakeModel('gemini-2.0-flash'))).toBe(false); expect(modelSupportsPDFDocuments(fakeModel('o4-mini'))).toBe(false); }); diff --git a/package-lock.json b/package-lock.json index 5f916fa8db113..39bb567cc5c95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1165,9 +1165,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1184,9 +1181,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1203,9 +1197,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1222,9 +1213,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1309,9 +1297,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1328,9 +1313,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1347,9 +1329,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1366,9 +1345,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ diff --git a/src/vs/platform/sandbox/browser/sandboxHelperService.ts b/src/vs/platform/sandbox/browser/sandboxHelperService.ts index 3ce552fee34ee..00394e4405269 100644 --- a/src/vs/platform/sandbox/browser/sandboxHelperService.ts +++ b/src/vs/platform/sandbox/browser/sandboxHelperService.ts @@ -15,6 +15,7 @@ class NullSandboxHelperService implements ISandboxHelperService { // or block sandbox flows on an unavailable host-side capability. return { bubblewrapInstalled: true, + bubblewrapUsable: true, socatInstalled: true, }; } diff --git a/src/vs/platform/sandbox/common/sandboxHelperService.ts b/src/vs/platform/sandbox/common/sandboxHelperService.ts index 581c78f33564a..6eed3ba2b51a0 100644 --- a/src/vs/platform/sandbox/common/sandboxHelperService.ts +++ b/src/vs/platform/sandbox/common/sandboxHelperService.ts @@ -9,7 +9,10 @@ export const ISandboxHelperService = createDecorator('san export interface ISandboxDependencyStatus { readonly bubblewrapInstalled: boolean; + readonly bubblewrapUsable: boolean; readonly socatInstalled: boolean; + readonly bubblewrapError?: string; + readonly supportsUbuntuAppArmorRemediation?: boolean; } export interface IWindowsMxcFilesystemPolicy { diff --git a/src/vs/platform/sandbox/common/settings.ts b/src/vs/platform/sandbox/common/settings.ts index bcbbbe24a20ab..0d36906a960d2 100644 --- a/src/vs/platform/sandbox/common/settings.ts +++ b/src/vs/platform/sandbox/common/settings.ts @@ -10,6 +10,7 @@ export const enum AgentSandboxSettingId { AgentSandboxEnabled = 'chat.agent.sandbox.enabled', AgentSandboxWindowsEnabled = 'chat.agent.sandbox.enabledWindows', AgentSandboxAllowUnsandboxedCommands = 'chat.agent.sandbox.allowUnsandboxedCommands', + AgentSandboxRetryWithAllowNetworkRequests = 'chat.agent.sandbox.retryWithAllowNetworkRequests', AgentSandboxAutoApproveUnsandboxedCommands = 'chat.agent.sandbox.autoApproveUnsandboxedCommands', AgentSandboxAllowAutoApprove = 'chat.agent.sandbox.allowAutoApprove', AgentSandboxLinuxFileSystem = 'chat.agent.sandbox.fileSystem.linux', diff --git a/src/vs/platform/sandbox/common/terminalSandboxEngine.ts b/src/vs/platform/sandbox/common/terminalSandboxEngine.ts index de281f063d738..5d21a9e42f2ae 100644 --- a/src/vs/platform/sandbox/common/terminalSandboxEngine.ts +++ b/src/vs/platform/sandbox/common/terminalSandboxEngine.ts @@ -20,7 +20,7 @@ import { AgentSandboxEnabledValue, AgentSandboxSettingId } from './settings.js'; import { IWindowsMxcTerminalSandboxRuntime } from './terminalSandboxMxcRuntime.js'; import { getTerminalSandboxReadAllowListForCommands } from './terminalSandboxReadAllowList.js'; import { getTerminalSandboxRuntimeConfigurationForCommands } from './terminalSandboxRuntimeConfigurationPerOperation.js'; -import { ITerminalSandboxCommand, ITerminalSandboxPrecheckInputs, ITerminalSandboxPrerequisiteCheckResult, ITerminalSandboxResolvedNetworkDomains, ITerminalSandboxWrapResult, TerminalSandboxPrerequisiteCheck } from './terminalSandboxService.js'; +import { ITerminalSandboxCommand, ITerminalSandboxPrecheckInputs, ITerminalSandboxPrerequisiteCheckResult, ITerminalSandboxResolvedNetworkDomains, ITerminalSandboxWrapResult, TerminalSandboxPrerequisiteCheck, TerminalSandboxPreCheckRemediation } from './terminalSandboxService.js'; interface ITerminalSandboxFileSystemSetting { denyRead?: string[]; @@ -130,6 +130,7 @@ export class TerminalSandboxEngine extends Disposable { private _commandCwd: URI | undefined; private _commandLine: string | undefined; private _commandShell: string | undefined; + private _commandAllowNetwork = false; private _os: OperatingSystem = OS; private readonly _defaultWritePaths: string[] = []; @@ -161,6 +162,10 @@ export class TerminalSandboxEngine extends Disposable { return this._areUnsandboxedCommandsAllowed(); } + areRetryWithAllowNetworkRequestsAllowed(): boolean { + return this._areRetryWithAllowNetworkRequestsAllowed(); + } + isAutoApproveUnsandboxedCommands(): boolean { return this._areUnsandboxedCommandsAllowed() && this._getSettingValue(AgentSandboxSettingId.AgentSandboxAutoApproveUnsandboxedCommands) === true; @@ -185,7 +190,13 @@ export class TerminalSandboxEngine extends Disposable { return { allowedDomains, deniedDomains }; } - async wrapCommand(command: string, requestUnsandboxedExecution?: boolean, shell?: string, cwd?: URI, commandDetails?: readonly ITerminalSandboxCommand[]): Promise { + async wrapCommand(command: string, requestUnsandboxedExecution?: boolean, shell?: string, cwd?: URI, commandDetails?: readonly ITerminalSandboxCommand[], requestAllowNetwork?: boolean): Promise { + const allowUnsandboxedCommands = this._areUnsandboxedCommandsAllowed(); + const retryWithAllowNetworkRequests = this._areRetryWithAllowNetworkRequestsAllowed(); + const shouldInspectBlockedDomains = requestUnsandboxedExecution !== true && requestAllowNetwork !== true && (retryWithAllowNetworkRequests || allowUnsandboxedCommands); + const blockedDomainResult = shouldInspectBlockedDomains ? this._getBlockedDomains(command) : { blockedDomains: [], deniedDomains: [] }; + const requiresPreflightAllowNetwork = retryWithAllowNetworkRequests && blockedDomainResult.blockedDomains.length > 0; + const allowNetworkForCommand = requestUnsandboxedExecution !== true && ((requestAllowNetwork === true && retryWithAllowNetworkRequests) || requiresPreflightAllowNetwork); const normalizedCommandDetails = this._normalizeCommandDetails(commandDetails ?? []); const normalizedCommandKeywords = this._normalizeCommandKeywords(normalizedCommandDetails.map(c => c.keyword)); const currentReadAllowListPaths = getTerminalSandboxReadAllowListForCommands(this._os, this._commandAllowListKeywords, this._commandAllowListCommandDetails); @@ -198,6 +209,7 @@ export class TerminalSandboxEngine extends Disposable { || !this._areStringArraysEqual(currentReadAllowListPaths, nextReadAllowListPaths) || !this._areObjectsEqual(currentRuntimeConfiguration, nextRuntimeConfiguration) || this._commandCwd?.toString() !== cwd?.toString() + || this._commandAllowNetwork !== allowNetworkForCommand || (this._os === OperatingSystem.Windows && (this._commandLine !== command || this._commandShell !== shell)); if (shouldRefreshConfig) { this._commandAllowListKeywords = normalizedCommandKeywords; @@ -205,6 +217,7 @@ export class TerminalSandboxEngine extends Disposable { this._commandCwd = cwd; this._commandLine = command; this._commandShell = shell; + this._commandAllowNetwork = allowNetworkForCommand; await this.getSandboxConfigPath(true); } @@ -212,11 +225,9 @@ export class TerminalSandboxEngine extends Disposable { throw new Error('Sandbox config path or temp dir not initialized'); } - const allowUnsandboxedCommands = this._areUnsandboxedCommandsAllowed(); - - // Check if the command would attempt to access any blocked network domains before wrapping it in the sandbox. - const blockedDomainResult = requestUnsandboxedExecution || !allowUnsandboxedCommands ? { blockedDomains: [], deniedDomains: [] } : this._getBlockedDomains(command); - if (!requestUnsandboxedExecution && allowUnsandboxedCommands && blockedDomainResult.blockedDomains.length > 0) { + // If per-command network relaxation is disabled, preserve the existing + // unsandbox fallback for commands with statically-detected blocked domains. + if (!requestUnsandboxedExecution && !retryWithAllowNetworkRequests && allowUnsandboxedCommands && blockedDomainResult.blockedDomains.length > 0) { return { command: this._wrapUnsandboxedCommand(command, shell), isSandboxWrapped: false, @@ -234,6 +245,11 @@ export class TerminalSandboxEngine extends Disposable { }; } + const allowNetworkConfirmationMetadata = requiresPreflightAllowNetwork ? { + blockedDomains: blockedDomainResult.blockedDomains, + deniedDomains: blockedDomainResult.deniedDomains, + } : undefined; + if (this._os === OperatingSystem.Windows) { if (!this._mxcPath) { throw new Error('MXC executable path not resolved'); @@ -241,6 +257,8 @@ export class TerminalSandboxEngine extends Disposable { return { command: this._windowsMxcRuntime.wrapCommand(this._mxcPath, this._sandboxConfigPath), isSandboxWrapped: true, + requiresAllowNetworkConfirmation: allowNetworkForCommand && !this._isSandboxAllowNetworkConfigured() ? true : undefined, + ...allowNetworkConfirmationMetadata, }; } @@ -268,11 +286,15 @@ export class TerminalSandboxEngine extends Disposable { return { command: `ELECTRON_RUN_AS_NODE=1 ${wrappedCommand}`, isSandboxWrapped: true, + requiresAllowNetworkConfirmation: allowNetworkForCommand && !this._isSandboxAllowNetworkConfigured() ? true : undefined, + ...allowNetworkConfirmationMetadata, }; } return { command: wrappedCommand, isSandboxWrapped: true, + requiresAllowNetworkConfirmation: allowNetworkForCommand && !this._isSandboxAllowNetworkConfigured() ? true : undefined, + ...allowNetworkConfirmationMetadata, }; } @@ -295,11 +317,21 @@ export class TerminalSandboxEngine extends Disposable { } if (!(await this._checkSandboxDependencies(forceRefresh))) { + const missingDependencies = await this.getMissingSandboxDependencies(); + if (missingDependencies.length === 0 && this._sandboxDependencyStatus?.bubblewrapInstalled && !this._sandboxDependencyStatus.bubblewrapUsable) { + return { + enabled: true, + sandboxConfigPath, + failedCheck: TerminalSandboxPrerequisiteCheck.Bubblewrap, + remediations: this._getBubblewrapRemediations(), + detail: this._sandboxDependencyStatus.bubblewrapError, + }; + } return { enabled: true, sandboxConfigPath, failedCheck: TerminalSandboxPrerequisiteCheck.Dependencies, - missingDependencies: await this.getMissingSandboxDependencies(), + missingDependencies, }; } @@ -368,7 +400,7 @@ export class TerminalSandboxEngine extends Disposable { } if (!forceRefresh && this._sandboxDependencyStatus) { - return this._sandboxDependencyStatus.bubblewrapInstalled && this._sandboxDependencyStatus.socatInstalled; + return this._sandboxDependencyStatus.bubblewrapInstalled && this._sandboxDependencyStatus.bubblewrapUsable && this._sandboxDependencyStatus.socatInstalled; } const status = await this._host.checkSandboxDependencies(); @@ -376,12 +408,20 @@ export class TerminalSandboxEngine extends Disposable { if (status && !status.bubblewrapInstalled) { this._logService.warn('TerminalSandboxEngine: bubblewrap (bwrap) is not installed'); + } else if (status && !status.bubblewrapUsable) { + this._logService.warn('TerminalSandboxEngine: bubblewrap (bwrap) is installed but failed its capability check', status.bubblewrapError); } if (status && !status.socatInstalled) { this._logService.warn('TerminalSandboxEngine: socat is not installed'); } - return status ? status.bubblewrapInstalled && status.socatInstalled : true; + return status ? status.bubblewrapInstalled && status.bubblewrapUsable && status.socatInstalled : true; + } + + private _getBubblewrapRemediations(): readonly TerminalSandboxPreCheckRemediation[] | undefined { + return this._sandboxDependencyStatus?.supportsUbuntuAppArmorRemediation + ? [TerminalSandboxPreCheckRemediation.InstallUbuntuAppArmorProfile, TerminalSandboxPreCheckRemediation.DisableUbuntuUserNamespaceRestriction] + : undefined; } private _quoteShellArgument(value: string): string { @@ -544,7 +584,7 @@ export class TerminalSandboxEngine extends Disposable { return undefined; } - const allowNetwork = await this.isSandboxAllowNetworkEnabled(); + const allowNetwork = this._commandAllowNetwork || await this.isSandboxAllowNetworkEnabled(); const linuxFileSystemSetting = this._os === OperatingSystem.Linux ? this._getSettingValue(AgentSandboxSettingId.AgentSandboxLinuxFileSystem) ?? {} : {}; @@ -838,6 +878,10 @@ export class TerminalSandboxEngine extends Disposable { return this._getSettingValue(AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands) === true; } + private _areRetryWithAllowNetworkRequestsAllowed(): boolean { + return this._getSettingValue(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests) === true; + } + private _getSettingValue(settingId: AgentSandboxSettingId | AgentNetworkDomainSettingId): T | undefined { return this._host.getSandboxSetting(settingId); } diff --git a/src/vs/platform/sandbox/common/terminalSandboxService.ts b/src/vs/platform/sandbox/common/terminalSandboxService.ts index a959eb2a8a1cf..5425a9d458a60 100644 --- a/src/vs/platform/sandbox/common/terminalSandboxService.ts +++ b/src/vs/platform/sandbox/common/terminalSandboxService.ts @@ -20,6 +20,12 @@ export interface ITerminalSandboxResolvedNetworkDomains { export const enum TerminalSandboxPrerequisiteCheck { Config = 'config', Dependencies = 'dependencies', + Bubblewrap = 'bubblewrap', +} + +export const enum TerminalSandboxPreCheckRemediation { + InstallUbuntuAppArmorProfile = 'installUbuntuAppArmorProfile', + DisableUbuntuUserNamespaceRestriction = 'disableUbuntuUserNamespaceRestriction', } export interface ITerminalSandboxPrerequisiteCheckResult { @@ -27,6 +33,8 @@ export interface ITerminalSandboxPrerequisiteCheckResult { sandboxConfigPath: string | undefined; failedCheck: TerminalSandboxPrerequisiteCheck | undefined; missingDependencies?: string[]; + remediations?: readonly TerminalSandboxPreCheckRemediation[]; + detail?: string; } export interface ITerminalSandboxWrapResult { @@ -35,6 +43,14 @@ export interface ITerminalSandboxWrapResult { blockedDomains?: string[]; deniedDomains?: string[]; requiresUnsandboxConfirmation?: boolean; + requiresAllowNetworkConfirmation?: boolean; +} + +export interface ITerminalSandboxPrecheckInputs { + /** + * Whether the current caller is using the default approval permission flow. + */ + readonly isDefaultApprovalPermissionEnabled?: boolean; } export interface ITerminalSandboxPrecheckInputs { @@ -98,15 +114,17 @@ export interface ITerminalSandboxService { /** * Wraps a command line for sandbox execution. Command details are optional, * but when provided they are used to derive command-specific read/write - * allow-list entries. + * allow-list entries. When explicitly requested, `requestAllowNetwork` + * retains sandbox execution while using a network-unrestricted config. */ - wrapCommand(command: string, requestUnsandboxedExecution?: boolean, shell?: string, cwd?: URI, commandDetails?: readonly ITerminalSandboxCommand[]): Promise; + wrapCommand(command: string, requestUnsandboxedExecution?: boolean, shell?: string, cwd?: URI, commandDetails?: readonly ITerminalSandboxCommand[], requestAllowNetwork?: boolean): Promise; getSandboxConfigPath(forceRefresh?: boolean, precheckInputs?: ITerminalSandboxPrecheckInputs): Promise; getTempDir(): URI | undefined; setNeedsForceUpdateConfigFile(): void; getResolvedNetworkDomains(): ITerminalSandboxResolvedNetworkDomains; getMissingSandboxDependencies(): Promise; installMissingSandboxDependencies(missingDependencies: string[], sessionResource: URI | undefined, token: CancellationToken, options: ISandboxDependencyInstallOptions): Promise; + runSandboxRemediation(remediation: TerminalSandboxPreCheckRemediation, sessionResource: URI | undefined, token: CancellationToken, options: ISandboxDependencyInstallOptions): Promise; } export class NullTerminalSandboxService implements ITerminalSandboxService { @@ -155,4 +173,8 @@ export class NullTerminalSandboxService implements ITerminalSandboxService { async installMissingSandboxDependencies(): Promise { return { exitCode: undefined }; } + + async runSandboxRemediation(): Promise { + return { exitCode: undefined }; + } } diff --git a/src/vs/platform/sandbox/node/sandboxHelper.ts b/src/vs/platform/sandbox/node/sandboxHelper.ts index d15f3a3c1bd8a..12a79a5d6b077 100644 --- a/src/vs/platform/sandbox/node/sandboxHelper.ts +++ b/src/vs/platform/sandbox/node/sandboxHelper.ts @@ -3,18 +3,22 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { execFile } from 'child_process'; import { getCaseInsensitive } from '../../../base/common/objects.js'; import { win32 } from '../../../base/common/path.js'; import { isLinux, isWindows } from '../../../base/common/platform.js'; +import { getOSReleaseInfo } from '../../../base/node/osReleaseInfo.js'; import { findExecutable } from '../../../base/node/processes.js'; import { ISandboxDependencyStatus, ISandboxHelperService, type IWindowsMxcConfig, IWindowsMxcFilesystemPolicy, type IWindowsMxcPolicyContainment, type IWindowsMxcSandboxPolicy } from '../common/sandboxHelperService.js'; type FindCommand = (command: string) => Promise; +type BubblewrapProbe = (command: string) => Promise<{ usable: boolean; error?: string }>; +type GetLinuxReleaseInfo = () => Promise<{ id: string; version_id?: string } | undefined>; export class SandboxHelperService implements ISandboxHelperService { declare readonly _serviceBrand: undefined; - static async checkSandboxDependenciesWith(findCommand: FindCommand, linux: boolean = isLinux): Promise { + static async checkSandboxDependenciesWith(findCommand: FindCommand, linux: boolean = isLinux, probeBubblewrap: BubblewrapProbe = command => SandboxHelperService._probeBubblewrap(command), getLinuxReleaseInfo: GetLinuxReleaseInfo = () => getOSReleaseInfo(() => undefined)): Promise { if (!linux) { return undefined; } @@ -23,9 +27,14 @@ export class SandboxHelperService implements ISandboxHelperService { findCommand('bwrap'), findCommand('socat'), ]); + const bubblewrapProbe = bubblewrapPath ? await probeBubblewrap(bubblewrapPath) : { usable: false }; + const releaseInfo = bubblewrapPath && !bubblewrapProbe.usable ? await getLinuxReleaseInfo() : undefined; return { bubblewrapInstalled: !!bubblewrapPath, + bubblewrapUsable: bubblewrapProbe.usable, + bubblewrapError: bubblewrapProbe.error, + supportsUbuntuAppArmorRemediation: releaseInfo?.id === 'ubuntu' && releaseInfo.version_id === '24.04', socatInstalled: !!socatPath, }; } @@ -34,6 +43,20 @@ export class SandboxHelperService implements ISandboxHelperService { return SandboxHelperService.checkSandboxDependenciesWith(findExecutable); } + private static _probeBubblewrap(command: string): Promise<{ usable: boolean; error?: string }> { + return new Promise(resolve => { + execFile(command, ['--unshare-net', '--dev-bind', '/', '/', 'echo', 'ok'], { encoding: 'utf8', timeout: 5000 }, (error, stdout, stderr) => { + if (!error && stdout.trim() === 'ok') { + resolve({ usable: true }); + return; + } + + const detail = stderr.trim() || error?.message || `Unexpected output: ${stdout.trim()}`; + resolve({ usable: false, error: detail.slice(0, 1000) }); + }); + }); + } + async getWindowsMxcFilesystemPolicy(): Promise { if (!isWindows) { return undefined; diff --git a/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts b/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts index 3f1bece0d0534..ed21351a3e5d2 100644 --- a/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts +++ b/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts @@ -13,10 +13,12 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { IFileService } from '../../../files/common/files.js'; import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; +import { AgentNetworkDomainSettingId } from '../../../networkFilter/common/settings.js'; import type { ISandboxDependencyStatus, IWindowsMxcConfig, IWindowsMxcFilesystemPolicy, IWindowsMxcPolicyContainment, IWindowsMxcSandboxPolicy } from '../../common/sandboxHelperService.js'; import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../common/settings.js'; import { ITerminalSandboxEngineHost, ITerminalSandboxRuntimeInfo, TerminalSandboxEngine } from '../../common/terminalSandboxEngine.js'; import { IWindowsMxcTerminalSandboxRuntime, WindowsMxcTerminalSandboxRuntime } from '../../common/terminalSandboxMxcRuntime.js'; +import { TerminalSandboxPrerequisiteCheck, TerminalSandboxPreCheckRemediation } from '../../common/terminalSandboxService.js'; suite('TerminalSandboxEngine', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -125,7 +127,7 @@ suite('TerminalSandboxEngine', () => { getWorkspaceStorageReadRoot: () => Promise.resolve(undefined), getWriteRoots: () => [URI.file('/workspace')], onDidChangeRoots: rootsEmitter.event, - checkSandboxDependencies: (): Promise => Promise.resolve({ bubblewrapInstalled: true, socatInstalled: true }), + checkSandboxDependencies: (): Promise => Promise.resolve({ bubblewrapInstalled: true, bubblewrapUsable: true, socatInstalled: true }), getWindowsMxcFilesystemPolicy: (): Promise => Promise.resolve(undefined), getWindowsMxcEnvironment: (): Promise => Promise.resolve(undefined), buildWindowsMxcSandboxPayload: (commandLine, policy, workingDirectory, containerName, containment): Promise => Promise.resolve(buildMockWindowsMxcSandboxPayload(commandLine, policy, workingDirectory, containerName, containment)), @@ -178,6 +180,7 @@ suite('TerminalSandboxEngine', () => { fileService = new MockFileService(); sandboxSettings.set(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.On); + sandboxSettings.set(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, true); instantiationService.stub(IFileService, fileService); instantiationService.stub(ILogService, new NullLogService()); @@ -220,6 +223,55 @@ suite('TerminalSandboxEngine', () => { ok(wrapped.command.includes(`/app/node_modules/@vscode/ripgrep-universal/bin/linux-${arch}`), `Expected ripgrep-universal platform-arch path in command. Actual: ${wrapped.command}`); }); + test('requestAllowNetwork keeps the command sandboxed and refreshes its network config', async () => { + setSandboxSetting(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, true); + const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, createHost())); + + const wrapped = await engine.wrapCommand('curl https://example.com', false, 'bash', undefined, undefined, true); + const configPath = await engine.getSandboxConfigPath(); + ok(configPath, 'Config path should be defined'); + const unrestrictedConfig = JSON.parse(createdFiles.get(configPath)!); + + strictEqual(wrapped.isSandboxWrapped, true); + strictEqual(wrapped.requiresAllowNetworkConfirmation, true); + deepStrictEqual(unrestrictedConfig.network, { allowedDomains: [], deniedDomains: [], enabled: false }); + + await engine.wrapCommand('echo restricted again'); + const restrictedConfig = JSON.parse(createdFiles.get(configPath)!); + deepStrictEqual(restrictedConfig.network, { allowedDomains: [], deniedDomains: [] }); + }); + + test('requestAllowNetwork does not relax network access when per-command requests are disabled', async () => { + setSandboxSetting(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, false); + const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, createHost())); + + const wrapped = await engine.wrapCommand('curl https://example.com', false, 'bash', undefined, undefined, true); + const configPath = await engine.getSandboxConfigPath(); + ok(configPath, 'Config path should be defined'); + const config = JSON.parse(createdFiles.get(configPath)!); + + strictEqual(wrapped.isSandboxWrapped, true); + strictEqual(wrapped.requiresAllowNetworkConfirmation, undefined); + deepStrictEqual(config.network, { allowedDomains: [], deniedDomains: [] }); + }); + + test('blocked domains request sandboxed network access before execution when enabled', async () => { + setSandboxSetting(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, true); + setSandboxSetting(AgentNetworkDomainSettingId.DeniedNetworkDomains, ['example.com']); + const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, createHost())); + + const wrapped = await engine.wrapCommand('curl https://example.com', false, 'bash'); + const configPath = await engine.getSandboxConfigPath(); + ok(configPath, 'Config path should be defined'); + const config = JSON.parse(createdFiles.get(configPath)!); + + strictEqual(wrapped.isSandboxWrapped, true); + strictEqual(wrapped.requiresAllowNetworkConfirmation, true); + deepStrictEqual(wrapped.blockedDomains, ['example.com']); + deepStrictEqual(wrapped.deniedDomains, ['example.com']); + deepStrictEqual(config.network, { allowedDomains: [], deniedDomains: [], enabled: false }); + }); + test('onDidChangeRoots triggers a sandbox config rewrite on the next wrap', async () => { let writeRoots: URI[] = [URI.file('/workspace-a')]; const host = createHost({ @@ -520,7 +572,7 @@ suite('TerminalSandboxEngine', () => { }); test('checkForSandboxingPrereqs reports missing dependencies', async () => { - let status: ISandboxDependencyStatus = { bubblewrapInstalled: false, socatInstalled: true }; + let status: ISandboxDependencyStatus = { bubblewrapInstalled: false, bubblewrapUsable: false, socatInstalled: true }; const host = createHost({ checkSandboxDependencies: () => Promise.resolve(status), }); @@ -531,8 +583,44 @@ suite('TerminalSandboxEngine', () => { strictEqual(result.failedCheck, 'dependencies'); strictEqual(result.missingDependencies?.[0], 'bubblewrap'); - status = { bubblewrapInstalled: true, socatInstalled: true }; + status = { bubblewrapInstalled: true, bubblewrapUsable: true, socatInstalled: true }; const result2 = await engine.checkForSandboxingPrereqs(true); strictEqual(result2.failedCheck, undefined); }); + + test('checkForSandboxingPrereqs reports remediation when Ubuntu AppArmor remediation is supported', async () => { + const host = createHost({ + checkSandboxDependencies: () => Promise.resolve({ + bubblewrapInstalled: true, + bubblewrapUsable: false, + bubblewrapError: 'Creating new namespace failed', + supportsUbuntuAppArmorRemediation: true, + socatInstalled: true, + }), + }); + const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, host)); + + const result = await engine.checkForSandboxingPrereqs(); + + strictEqual(result.failedCheck, TerminalSandboxPrerequisiteCheck.Bubblewrap); + deepStrictEqual(result.remediations, [TerminalSandboxPreCheckRemediation.InstallUbuntuAppArmorProfile, TerminalSandboxPreCheckRemediation.DisableUbuntuUserNamespaceRestriction]); + strictEqual(result.detail, 'Creating new namespace failed'); + strictEqual(result.missingDependencies, undefined); + }); + + test('checkForSandboxingPrereqs omits the AppArmor remediation on unsupported Linux releases', async () => { + const host = createHost({ + checkSandboxDependencies: () => Promise.resolve({ + bubblewrapInstalled: true, + bubblewrapUsable: false, + supportsUbuntuAppArmorRemediation: false, + socatInstalled: true, + }), + }); + const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, host)); + + const result = await engine.checkForSandboxingPrereqs(); + + strictEqual(result.remediations, undefined); + }); }); diff --git a/src/vs/platform/sandbox/test/node/sandboxHelper.test.ts b/src/vs/platform/sandbox/test/node/sandboxHelper.test.ts new file mode 100644 index 0000000000000..7e738d39f63b2 --- /dev/null +++ b/src/vs/platform/sandbox/test/node/sandboxHelper.test.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { deepStrictEqual, strictEqual } from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { SandboxHelperService } from '../../node/sandboxHelper.js'; + +suite('SandboxHelperService', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('does not inspect sandbox dependencies on non-Linux platforms', async () => { + let findCalled = false; + const result = await SandboxHelperService.checkSandboxDependenciesWith(async () => { + findCalled = true; + return undefined; + }, false); + + strictEqual(result, undefined); + strictEqual(findCalled, false); + }); + + test('reports missing bubblewrap without running its capability probe', async () => { + let probeCalled = false; + const result = await SandboxHelperService.checkSandboxDependenciesWith( + async command => command === 'socat' ? '/usr/bin/socat' : undefined, + true, + async () => { + probeCalled = true; + return { usable: true }; + }, + ); + + strictEqual(probeCalled, false); + strictEqual(result?.bubblewrapInstalled, false); + strictEqual(result?.bubblewrapUsable, false); + strictEqual(result?.socatInstalled, true); + }); + + test('reports bubblewrap usable when its capability probe succeeds', async () => { + let probedCommand: string | undefined; + let releaseInfoRead = false; + const result = await SandboxHelperService.checkSandboxDependenciesWith( + async command => `/usr/bin/${command}`, + true, + async command => { + probedCommand = command; + return { usable: true }; + }, + async () => { + releaseInfoRead = true; + return { id: 'ubuntu', version_id: '24.04' }; + }, + ); + + strictEqual(probedCommand, '/usr/bin/bwrap'); + strictEqual(releaseInfoRead, false); + deepStrictEqual(result, { + bubblewrapInstalled: true, + bubblewrapUsable: true, + bubblewrapError: undefined, + supportsUbuntuAppArmorRemediation: false, + socatInstalled: true, + }); + }); + + test('reports AppArmor remediation support when bubblewrap fails on Ubuntu 24.04', async () => { + const result = await SandboxHelperService.checkSandboxDependenciesWith( + async command => `/usr/bin/${command}`, + true, + async () => ({ usable: false, error: 'No permissions to create namespace' }), + async () => ({ id: 'ubuntu', version_id: '24.04' }), + ); + + deepStrictEqual(result, { + bubblewrapInstalled: true, + bubblewrapUsable: false, + bubblewrapError: 'No permissions to create namespace', + supportsUbuntuAppArmorRemediation: true, + socatInstalled: true, + }); + }); + + test('does not report AppArmor remediation support when bubblewrap fails on Ubuntu 22.04', async () => { + const result = await SandboxHelperService.checkSandboxDependenciesWith( + async command => `/usr/bin/${command}`, + true, + async () => ({ usable: false, error: 'No permissions to create namespace' }), + async () => ({ id: 'ubuntu', version_id: '22.04' }), + ); + + strictEqual(result?.bubblewrapUsable, false); + strictEqual(result?.supportsUbuntuAppArmorRemediation, false); + }); +}); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAgentPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAgentPicker.ts index 6177f18a79231..5754f3096be21 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAgentPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAgentPicker.ts @@ -19,6 +19,7 @@ import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browse import { ChatContextKeyExprs } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { ChatMode, IChatMode } from '../../../../../workbench/contrib/chat/common/chatModes.js'; import { IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { logChangesToStateModel } from '../../../../../workbench/contrib/chat/common/model/chatModel.js'; import { ChatModeKind } from '../../../../../workbench/contrib/chat/common/constants.js'; import { Menus } from '../../../../browser/menus.js'; import { IAgentHostSessionsProvider, isAgentHostProvider, LOCAL_AGENT_HOST_PROVIDER_ID, REMOTE_AGENT_HOST_PROVIDER_RE } from '../../../../common/agentHostSessionsProvider.js'; @@ -193,6 +194,8 @@ class AgentHostAgentPickerContribution extends Disposable implements IWorkbenchC return; } + const chatModel = this.chatService.getSession(session.resource); + logChangesToStateModel(chatModel?.inputModel, `[AGPK] _syncVisibleChatInputMode -> widget.input.setChatMode(${modeId}) for ${session.resource.toString()}`, undefined, chatModel?.inputModel.state.get(), this.logService); widget.input.setChatMode(modeId, false); }; diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 05e5f0598afd5..eadc4738fa35a 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -12,22 +12,25 @@ import { IMarkdownString, MarkdownString } from '../../../../../base/common/html import { Disposable, DisposableMap, DisposableStore, IDisposable, IReference, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { equals } from '../../../../../base/common/objects.js'; import { constObservable, derived, derivedObservableWithCache, derivedOpts, IObservable, ISettableObservable, observableFromEvent, observableFromPromise, observableValue, observableValueOpts, transaction } from '../../../../../base/common/observable.js'; +import { isEqual } from '../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { localize } from '../../../../../nls.js'; import { AgentSession, IAgentConnection, IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js'; import { buildSessionChangesetUri } from '../../../../../platform/agentHost/common/changesetUri.js'; +import { getEffectiveAgents } from '../../../../../platform/agentHost/common/customAgents.js'; import { KNOWN_AUTO_APPROVE_VALUES, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; +import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { AgentSelection, AgentCustomization, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, SessionActiveClient, SessionState, SessionSummary, type ChangesetSummary, Customization } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import { AgentCustomization, AgentSelection, Customization, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, SessionActiveClient, SessionState, SessionSummary, type ChangesetSummary } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, isSessionAction, NotificationType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import { readSessionGitState, ROOT_STATE_URI, SessionMeta, StateComponents, type ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; -import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { IChatSendRequestOptions, IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionFileChange, IChatSessionFileChange2, IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; @@ -39,13 +42,10 @@ import { isSessionConfigComplete } from '../../../../common/sessionConfig.js'; import { IChat, IGitHubInfo, ISession, ISessionAgentRef, ISessionChangeset, ISessionChangesSummary, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, sessionFileChangesEqual, SessionStatus, toSessionId } from '../../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISendRequestOptions, ISessionChangeEvent } from '../../../../services/sessions/common/sessionsProvider.js'; -import { computePullRequestIcon } from '../../../github/common/types.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; -import { changesetFilesToChanges, mapProtocolStatus } from './agentHostDiffs.js'; -import { getEffectiveAgents } from '../../../../../platform/agentHost/common/customAgents.js'; +import { computePullRequestIcon } from '../../../github/common/types.js'; import { createChangesets } from '../../copilotChatSessions/browser/copilotChatSessionsChangesets.js'; -import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; -import { isEqual } from '../../../../../base/common/resources.js'; +import { changesetFilesToChanges, mapProtocolStatus } from './agentHostDiffs.js'; const STORAGE_KEY_REMEMBERED_SESSION_CONFIG_VALUES = 'sessions.agentHost.sessionConfigPicker.selectedValues'; const UNSAFE_SESSION_CONFIG_KEYS = new Set(['__proto__', 'constructor', 'prototype']); @@ -95,7 +95,6 @@ export const CopilotCLISessionType: ISessionType = { */ export interface IAgentHostAdapterOptions { readonly icon: ThemeIcon; - readonly description: IMarkdownString | undefined; /** Loading observable wired to the provider's authentication-pending state. */ readonly loading: IObservable; /** Builds the session workspace from session metadata; provider-specific (icon, providerLabel, requiresWorkspaceTrust). */ @@ -300,7 +299,7 @@ export class AgentHostSessionAdapter implements ISession { } } - return this._options.description; + return undefined; }); if (metadata.isArchived) { @@ -1109,6 +1108,25 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement protected _cacheInitialized = false; + private static readonly SESSION_REFRESH_RETRY_MIN_MS = 1_000; + private static readonly SESSION_REFRESH_RETRY_MAX_MS = 30_000; + + /** + * Backoff timer that retries {@link _refreshSessions} after a failed + * attempt. A failed initial list (e.g. the agent threw + * `AHP_AUTH_REQUIRED` because its token wasn't yet effective server-side, + * or a transient offline/network error) must not leave the session list + * permanently empty. The timer is armed only on failure and cancelled on + * the next successful refresh. + */ + private readonly _sessionRefreshRetry = this._register(new MutableDisposable()); + + /** Current backoff delay (ms) for the session-refresh retry. */ + private _sessionRefreshRetryDelay = BaseAgentHostSessionsProvider.SESSION_REFRESH_RETRY_MIN_MS; + + /** True while a {@link _refreshSessions} call is awaiting `listSessions()`. */ + private _sessionRefreshInFlight = false; + constructor( @IChatSessionsService protected readonly _chatSessionsService: IChatSessionsService, @IChatService protected readonly _chatService: IChatService, @@ -1138,7 +1156,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement * the bits that are uniform across hosts (`icon`, `loading`, * `mapDiffUri`) from the corresponding hooks. */ - protected abstract _adapterOptions(): Pick; + protected abstract _adapterOptions(): Pick; /** Build an adapter for the given metadata. */ protected createAdapter(meta: IAgentSessionMetadata): AgentHostSessionAdapter { @@ -2119,7 +2137,17 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (this._cacheInitialized) { return; } - this._cacheInitialized = true; + // `_refreshSessions` owns `_cacheInitialized` — it flips it to `true` + // only once `listSessions()` actually returns. A call that races + // before the connection/auth is ready will fail and arm a retry + // rather than permanently pinning an empty cache. Don't launch a new + // refresh while one is already in flight or a backoff retry is already + // scheduled — otherwise every synchronous `getSessions()` during the + // failure window would hammer the agent/auth path and bypass the + // backoff. + if (this._sessionRefreshInFlight || this._sessionRefreshRetry.value) { + return; + } this._refreshSessions(); } @@ -2128,8 +2156,15 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!connection) { return; } + // Cancel any pending retry; this attempt supersedes it. + this._sessionRefreshRetry.clear(); + this._sessionRefreshInFlight = true; try { const sessions = await connection.listSessions(); + // A successful return (even an empty list) means the cache is + // authoritative. Mark it initialized and reset the backoff. + this._cacheInitialized = true; + this._sessionRefreshRetryDelay = BaseAgentHostSessionsProvider.SESSION_REFRESH_RETRY_MIN_MS; const currentKeys = new Set(); const added: ISession[] = []; const changed: ISession[] = []; @@ -2165,11 +2200,46 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (added.length > 0 || removed.length > 0 || changed.length > 0) { this._onDidChangeSessions.fire({ added, removed, changed }); } - } catch { - // Connection may not be ready yet + } catch (err) { + // The connection / agent may not be ready yet — e.g. the agent + // throws `AHP_AUTH_REQUIRED` until its token is effective + // server-side, or there's a transient offline/network error. We + // must NOT mark the cache initialized (that would conflate a + // failure with a genuinely-empty success and never recover), and + // we deliberately do NOT pop a sign-in dialog just to render the + // list. Instead, retry silently in the background with backoff. + this._logService.trace(`[AgentHostSessionsProvider] listSessions failed; scheduling retry: ${err}`); + this._scheduleSessionRefreshRetry(announceExistingAsAdded); + } finally { + this._sessionRefreshInFlight = false; } } + /** + * Arm a backoff retry of {@link _refreshSessions}. Used after a failed + * refresh so a transient startup failure self-heals without requiring an + * unrelated AHP event (a turn completing, a session being added) to force + * a re-fetch. Cancelled on the next successful refresh. + */ + private _scheduleSessionRefreshRetry(announceExistingAsAdded: boolean): void { + const delay = this._sessionRefreshRetryDelay; + this._sessionRefreshRetryDelay = Math.min(delay * 2, BaseAgentHostSessionsProvider.SESSION_REFRESH_RETRY_MAX_MS); + this._sessionRefreshRetry.value = disposableTimeout(() => { + this._refreshSessions(announceExistingAsAdded); + }, delay); + } + + /** + * Cancel any pending session-refresh retry and reset the backoff. Called + * by subclasses when the connection goes away (the stale timer would + * otherwise fire against a dead connection and no-op). + */ + protected _cancelSessionRefreshRetry(): void { + this._sessionRefreshRetry.clear(); + this._sessionRefreshRetryDelay = BaseAgentHostSessionsProvider.SESSION_REFRESH_RETRY_MIN_MS; + } + + private async _waitForNewSession(existingKeys: Set): Promise { await this._refreshSessions(); for (const [key, cached] of this._sessionCache) { diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index 413996e8a51c8..9a954e2e7b1c4 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -4,13 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../../base/common/codicons.js'; -import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { Schemas } from '../../../../../base/common/network.js'; import { autorun, constObservable, IObservable } from '../../../../../base/common/observable.js'; import { basename, dirname } from '../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; +import { toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; import { IAgentConnection, IAgentHostService, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js'; import type { ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -18,18 +18,17 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; -import { BaseAgentHostSessionsProvider } from './baseAgentHostSessionsProvider.js'; +import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; import { buildAgentHostSessionWorkspace, readBranchProtectionPatterns } from '../../../../common/agentHostSessionWorkspace.js'; import { IGitHubInfo, ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_LOCAL } from '../../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; -import { toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; -import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; -import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; +import { BaseAgentHostSessionsProvider } from './baseAgentHostSessionsProvider.js'; const LOCAL_RESOURCE_SCHEME_PREFIX = 'agent-host-'; @@ -49,10 +48,6 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; readonly supportsLocalWorkspaces = true; - - private readonly _localLabel = localize('localAgentHostSessionTypeLocation', "Local"); - private readonly _localDescription = new MarkdownString(this._localLabel); - constructor( @IAgentHostService private readonly _agentHostService: IAgentHostService, @IChatSessionsService chatSessionsService: IChatSessionsService, @@ -99,7 +94,6 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide if (this._agentHostService.authenticationPending.read(reader)) { return; } - this._cacheInitialized = true; this._refreshSessions(); })); } @@ -123,7 +117,6 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide protected _adapterOptions() { return { - description: this._localDescription, buildWorkspace: (project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined, gitHubInfo: IObservable, gitState: ISessionGitState | undefined) => { const uriForDescription = project?.uri ?? workingDirectory; const description = uriForDescription ? this._labelService.getUriLabel(dirname(uriForDescription), { relative: false }) : undefined; diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 4a9e93cc0b458..cd09757b14bf0 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -86,7 +86,19 @@ class MockAgentHostService extends mock() { return this._nextSeq++; } + /** + * Number of upcoming `listSessions()` calls that should reject, used to + * simulate the agent throwing `AHP_AUTH_REQUIRED` (or a transient offline + * error) before its token is effective server-side. Decremented per call. + */ + public failListSessionsCount = 0; + public listSessionsCallCount = 0; override async listSessions(): Promise { + this.listSessionsCallCount++; + if (this.failListSessionsCount > 0) { + this.failListSessionsCount--; + throw new Error('AHP_AUTH_REQUIRED'); + } return [...this._sessions.values()]; } @@ -606,6 +618,87 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + test('recovers an empty list when the initial listSessions fails, without needing a new session', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + // Fresh launch: the agent throws on the first listSessions() (e.g. + // AHP_AUTH_REQUIRED before its token is effective, or a transient + // offline error). The sessions really exist on the host. + agentHost.failListSessionsCount = 1; + agentHost.addSession(createSession('heal-1', { summary: 'First' })); + agentHost.addSession(createSession('heal-2', { summary: 'Second' })); + + const provider = createProvider(disposables, agentHost); + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions(e => changes.push(e))); + + // The eager refresh fires and fails; nothing is cached yet. + await timeout(0); + assert.strictEqual(changes.length, 0, 'no event should fire after a failed initial refresh'); + assert.strictEqual(provider.getSessions().length, 0, 'cache stays empty after a failed initial refresh'); + + // The backoff retry (min 1s) fires on its own — no SessionTurnComplete + // or sessionAdded needed — and the list self-heals. + await timeout(1_100); + + assert.deepStrictEqual({ + eventCount: changes.length, + added: changes[0]?.added.map(s => s.title.get()).sort(), + cachedTitles: provider.getSessions().map(s => s.title.get()).sort(), + }, { + eventCount: 1, + added: ['First', 'Second'], + cachedTitles: ['First', 'Second'], + }); + })); + + test('a successful empty listSessions arms no retry', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + // No sessions on the host: listSessions() succeeds with []. This is a + // valid result, not a failure — the cache should be marked initialized + // and no background retry should be scheduled. + const provider = createProvider(disposables, agentHost); + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions(e => changes.push(e))); + + await timeout(0); + const callsAfterEagerLoad = agentHost.listSessionsCallCount; + assert.strictEqual(callsAfterEagerLoad, 1, 'exactly one eager listSessions call'); + + // Advance well past the max backoff window; no retry should fire. + await timeout(60_000); + + assert.strictEqual(agentHost.listSessionsCallCount, callsAfterEagerLoad, 'no retry should be scheduled after a successful empty list'); + assert.strictEqual(changes.length, 0, 'no change event for an empty list'); + assert.strictEqual(provider.getSessions().length, 0); + })); + + test('retries with backoff until listSessions succeeds', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + // First two attempts fail, third succeeds. Verifies the retry keeps + // re-arming rather than giving up after a single failed attempt. + agentHost.failListSessionsCount = 2; + agentHost.addSession(createSession('backoff-1', { summary: 'Only' })); + + const provider = createProvider(disposables, agentHost); + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions(e => changes.push(e))); + + await timeout(0); + assert.strictEqual(provider.getSessions().length, 0, 'empty after first failure'); + + // First retry (~1s) — still failing. + await timeout(1_100); + assert.strictEqual(provider.getSessions().length, 0, 'empty after second failure'); + + // Second retry (~2s backoff) — now succeeds. + await timeout(2_200); + + assert.deepStrictEqual({ + eventCount: changes.length, + cachedTitles: provider.getSessions().map(s => s.title.get()).sort(), + }, { + eventCount: 1, + cachedTitles: ['Only'], + }); + })); + test('uses project metadata as workspace group source', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const projectUri = URI.file('/home/user/vscode'); const workingDirectory = URI.file('/tmp/copilot-worktrees/vscode-feature'); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index d88635632fb9c..d66f65fd56575 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -3,40 +3,39 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; -import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; -import { basename, dirname } from '../../../../../base/common/resources.js'; import { constObservable, IObservable, observableValue } from '../../../../../base/common/observable.js'; import { isWeb } from '../../../../../base/common/platform.js'; +import { basename, dirname } from '../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; -import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { localize } from '../../../../../nls.js'; import { agentHostUri } from '../../../../../platform/agentHost/common/agentHostFileSystemProvider.js'; import { AGENT_HOST_SCHEME, agentHostAuthority, fromAgentHostUri, toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; import { AgentSession, type IAgentConnection, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js'; -import type { ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import type { ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; -import { AgentHostSessionAdapter, BaseAgentHostSessionsProvider } from '../../agentHost/browser/baseAgentHostSessionsProvider.js'; -import { IGitHubService } from '../../../github/browser/githubService.js'; import { buildAgentHostSessionWorkspace, readBranchProtectionPatterns } from '../../../../common/agentHostSessionWorkspace.js'; import { IGitHubInfo, ISession, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; -import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; +import { IGitHubService } from '../../../github/browser/githubService.js'; +import { AgentHostSessionAdapter, BaseAgentHostSessionsProvider } from '../../agentHost/browser/baseAgentHostSessionsProvider.js'; import { remoteAgentHostSessionTypeId } from '../common/remoteAgentHostSessionType.js'; /** Storage key prefix for cached session summaries, per remote address. */ @@ -273,7 +272,6 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid protected _adapterOptions() { const web = this.isWebPlatform; return { - description: web ? undefined : new MarkdownString().appendText(this.label), buildWorkspace: (project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined, gitHubInfo: IObservable, gitState: ISessionGitState | undefined) => { const uriForDescription = project?.uri ?? workingDirectory; const description = uriForDescription ? this._labelService.getUriLabel(dirname(uriForDescription), { relative: false }) : undefined; @@ -398,8 +396,9 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid this._attachConnectionListeners(connection, this._connectionListeners); - // Always refresh sessions when a connection is (re)established - this._cacheInitialized = true; + // Always refresh sessions when a connection is (re)established. + // `_refreshSessions` owns `_cacheInitialized` (set on a successful + // list) and arms a backoff retry if the first attempt fails. this._refreshSessions(wasUnpublished); } @@ -438,6 +437,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid // triggers a full list refresh (which will reconcile against the // persisted entries we keep on disk). this._cacheInitialized = false; + this._cancelSessionRefreshRetry(); } /** diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 1b0cdf2909456..a1a3cb3407ba2 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -1130,7 +1130,7 @@ suite('RemoteAgentHostSessionsProvider', () => { assert.strictEqual(session.workspace.get()?.label, 'project [Test Host]'); }); - test('non-web: session description is the host label', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + test('non-web: idle session description is undefined', () => runWithFakedTimers({ useFakeTimers: true }, async () => { connection.addSession(createSession('desc-sess', { summary: 'Desc Test' })); const provider = createProvider(disposables, connection, { isWebPlatform: false }); @@ -1138,11 +1138,7 @@ suite('RemoteAgentHostSessionsProvider', () => { await timeout(0); const session = provider.getSessions().find(s => s.title.get() === 'Desc Test'); - const description = session?.description.get(); - assert.ok(description, 'description should be defined on non-web'); - // MarkdownString.appendText escapes spaces as   — verify the - // host label is present rather than the exact serialized form. - assert.ok(description!.value.includes('Test') && description!.value.includes('Host')); + assert.strictEqual(session?.description.get(), undefined); })); test('web: session description is undefined (host filter dropdown replaces it)', () => runWithFakedTimers({ useFakeTimers: true }, async () => { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts index 53c8f64115950..518aa7c2bd35a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts @@ -229,6 +229,19 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS isTrusted: undefined, }); } + if (terminalData.requestAllowNetwork) { + const reasonText = (terminalData.requestAllowNetworkReason && terminalData.requestAllowNetworkReason.trim()) + || localize('chat.terminal.allowNetwork.defaultReason', "The model did not provide a reason for requesting unrestricted network access in the sandbox."); + const inline = new MarkdownString(undefined, { supportThemeIcons: true }); + inline.appendMarkdown(`$(${Codicon.info.id}) `); + inline.appendText(reasonText); + detailParts.push({ + inline, + hoverLabel: localize('chat.terminal.detail.unrestrictedNetwork', "Unrestricted network access:"), + hoverBody: escapeMarkdownSyntaxTokens(reasonText), + isTrusted: undefined, + }); + } if (disclaimer) { const inline = typeof disclaimer === 'string' ? new MarkdownString(disclaimer) : disclaimer; // For the hover, drop the leading `$(info) ` icon prefix that the diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index fdedf3735adff..cdcbb03b367a2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -354,6 +354,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // Reference to the input model for syncing input state private _inputModel: IInputModel | undefined; + // Session resource of the currently bound _inputModel. Used for diagnostic + // logging so we can detect writes that target a different session than the + // one the widget viewModel is currently showing (cyclic-ref window). + private _inputModelSessionResource: URI | undefined; // Disposables for model observation private readonly _modelSyncDisposables = this._register(new DisposableStore()); @@ -576,7 +580,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge super(); // Initialize debounced text sync scheduler - this._syncTextDebounced = this._register(new RunOnceScheduler(() => this._syncInputStateToModel(), 150)); + this._syncTextDebounced = this._register(new RunOnceScheduler(() => { + logChangesToStateModel(this._inputModel, `[DEBOUNCE] _syncTextDebounced fired -> _syncInputStateToModel in ${this._currentSessionKey}`, undefined, this._inputModel?.state.get(), this.logService); + this._syncInputStateToModel(); + }, 150)); this._emptyInputState = this._register(emptyInputState(StorageScope.WORKSPACE, StorageTarget.USER, this.storageService)); this._contextResourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this._onDidChangeVisibility.event })); @@ -701,40 +708,40 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const resetCurrentLanguageModelIfUnavailable = () => { const modelIdentifier = this._currentLanguageModel.get()?.identifier; const models = this.getModels(); - if (this._currentSessionType === SessionType.CopilotCLI) { - if (shouldResetOnModelListChange(modelIdentifier, models) && !models.some(m => m.metadata.id === modelIdentifier)) { - if (canLog(this.logService.getLevel(), LogLevel.Debug)) { - const mergedModels = this.getAllMergedModels(); - const filteredModels = filterModelsForSession(models, this.getCurrentSessionType(), this.currentModeKind, this.location); - const messageparts: string[] = [ - `resetting current language model due to model list change from ${modelIdentifier}`, - `this._widget?.viewModel?.model.sessionResource = ${this._widget?.viewModel?.model.sessionResource?.toString()}`, - `this.getCurrentSessionType = ${this.getCurrentSessionType()}`, - `this._currentSessionType = ${this._currentSessionType}`, - `model identifiers: ${models.map(m => m.identifier).join(', ')}`, - `model target Session Types: ${models.map(m => m.metadata.targetChatSessionType || '').join(', ')}`, - `model metadataid: ${models.map(m => m.metadata.id).join(', ')}`, - `merged.model identifiers: ${mergedModels.map(m => m.identifier).join(', ')}`, - `merged.model target Session Types: ${mergedModels.map(m => m.metadata.targetChatSessionType || '').join(', ')}`, - `merged.model metadataid: ${mergedModels.map(m => m.metadata.id).join(', ')}`, - `filtered.model identifiers: ${filteredModels.map(m => m.identifier).join(', ')}`, - `filtered.model target Session Types: ${filteredModels.map(m => m.metadata.targetChatSessionType || '').join(', ')}`, - `filtered.model metadataid: ${filteredModels.map(m => m.metadata.id).join(', ')}`, - ]; - if (this.getCurrentSessionType() !== SessionType.CopilotCLI) { - const delegateSessionType = this.options.sessionTypePickerDelegate?.getActiveSessionProvider?.(); - if (delegateSessionType) { - messageparts.push(`delegateSessionType = ${delegateSessionType}`); - } - const sessionResource = this._widget?.viewModel?.model.sessionResource; - messageparts.push(`current session resource = ${sessionResource}`); - } - - logChangesToStateModel(this._inputModel, messageparts.join(', '), undefined, undefined, this.logService); + if (canLog(this.logService.getLevel(), LogLevel.Debug)) { + const mergedModels = this.getAllMergedModels(); + const filteredModels = filterModelsForSession(models, this.getCurrentSessionType(), this.currentModeKind, this.location); + const messageparts: string[] = [ + `resetting current language model due to model list change from ${modelIdentifier}`, + `this._widget?.viewModel?.model.sessionResource = ${this._widget?.viewModel?.model.sessionResource?.toString()}`, + `this.currentModeKind = ${this.currentModeKind}`, + `this.getCurrentSessionType = ${this.getCurrentSessionType()}`, + `this._currentSessionType = ${this._currentSessionType}`, + `shouldResetOnModelListChange(modelIdentifier, models) = ${shouldResetOnModelListChange(modelIdentifier, models)}`, + `vendors: ${this.languageModelsService.getVendors().map(v => v.vendor).join(', ')}`, + `hiddenModelIds: ${this.languageModelsService.getHiddenModelIds().join(', ')}`, + `model identifiers: ${models.map(m => m.identifier).join(', ')}`, + `model target Session Types: ${models.map(m => m.metadata.targetChatSessionType || '').join(', ')}`, + `model metadataid: ${models.map(m => m.metadata.id).join(', ')}`, + `merged.model identifiers: ${mergedModels.map(m => m.identifier).join(', ')}`, + `merged.model target Session Types: ${mergedModels.map(m => m.metadata.targetChatSessionType || '').join(', ')}`, + `merged.model metadataid: ${mergedModels.map(m => m.metadata.id).join(', ')}`, + `filtered.model identifiers: ${filteredModels.map(m => m.identifier).join(', ')}`, + `filtered.model target Session Types: ${filteredModels.map(m => m.metadata.targetChatSessionType || '').join(', ')}`, + `filtered.model metadataid: ${filteredModels.map(m => m.metadata.id).join(', ')}`, + ]; + if (this.getCurrentSessionType() !== SessionType.CopilotCLI) { + const delegateSessionType = this.options.sessionTypePickerDelegate?.getActiveSessionProvider?.(); + if (delegateSessionType) { + messageparts.push(`delegateSessionType = ${delegateSessionType}`); } - this.setCurrentLanguageModelToDefault(); + const sessionResource = this._widget?.viewModel?.model.sessionResource; + messageparts.push(`current session resource = ${sessionResource}`); } - } else if (shouldResetOnModelListChange(modelIdentifier, models)) { + + logChangesToStateModel(this._inputModel, messageparts.join(', '), undefined, undefined, this.logService); + } + if (shouldResetOnModelListChange(modelIdentifier, models)) { this.setCurrentLanguageModelToDefault(); } }; @@ -758,7 +765,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge })); this._register(autorun(reader => { const modes = this._currentChatModesObservable.read(reader); - reader.store.add(modes.onDidChange(() => this.validateCurrentChatMode())); + reader.store.add(modes.onDidChange(() => { + logChangesToStateModel(this._inputModel, `[MODES] _currentChatModesObservable.onDidChange -> validateCurrentChatMode in ${this._currentSessionKey}`, undefined, this._inputModel?.state.read(undefined), this.logService); + this.validateCurrentChatMode(); + })); })); this._register(autorun(r => { const mode = this._currentModeObservable.read(r); @@ -766,6 +776,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.chatModeNameKey.set(mode.name.read(r)); const models = mode.model?.read(r); if (models) { + logChangesToStateModel(this._inputModel, `mode autorun forcing model via switchModelByQualifiedName (mode=${mode.id}, qualifiedNames=[${models.join(', ')}]) in ${this._currentSessionKey}`, undefined, undefined, this.logService); this.switchModelByQualifiedName(models); } })); @@ -806,11 +817,15 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // Do not let a delayed restore from a previous session type apply later. this._waitForPersistedLanguageModel.clear(); - const persistedSelection = this.storageService.get(this.getSelectedModelStorageKey(), StorageScope.APPLICATION); - const persistedAsDefault = this.storageService.getBoolean(this.getSelectedModelIsDefaultStorageKey(), StorageScope.APPLICATION, true); + const selectedModelStorageKey = this.getSelectedModelStorageKey(); + const selectedModelIsDefaultStorageKey = this.getSelectedModelIsDefaultStorageKey(); + const persistedSelection = this.storageService.get(selectedModelStorageKey, StorageScope.APPLICATION); + const persistedAsDefault = this.storageService.getBoolean(selectedModelIsDefaultStorageKey, StorageScope.APPLICATION, true); + logChangesToStateModel(this._inputModel, `[INIT-SELECTED-MODEL] storageKey=${selectedModelStorageKey}, isDefaultKey=${selectedModelIsDefaultStorageKey}, persistedSelection=${persistedSelection}, persistedAsDefault=${persistedAsDefault}, currentSessionType=${this._currentSessionType}, getCurrentSessionType=${this.getCurrentSessionType()}, widgetSession=${this._currentSessionKey}, boundInputModelSession=${this._inputModelSessionResource?.toString()}, currentLanguageModel=${this._currentLanguageModel.get()?.identifier}`, this._inputModel?.state.get(), undefined, this.logService); if (persistedSelection) { const result = shouldRestorePersistedModel(persistedSelection, persistedAsDefault, this.getModels(), this.location); + logChangesToStateModel(this._inputModel, `[INIT-SELECTED-MODEL] restore decision persistedSelection=${persistedSelection}, shouldRestore=${result.shouldRestore}, resultModel=${result.model?.identifier}, storageKey=${selectedModelStorageKey}, currentSessionType=${this._currentSessionType}, getCurrentSessionType=${this.getCurrentSessionType()}`, this._inputModel?.state.get(), undefined, this.logService); if (result.shouldRestore && result.model) { this.setCurrentLanguageModel(result.model); this.checkModelSupported(); @@ -985,6 +1000,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (sessionResource) { this.chatSessionsService.setSessionOption(sessionResource, PERMISSION_LEVEL_OPTION_ID, level); } + // Log first so the upcoming _syncInputStateToModel write can be attributed + // to a permission-level change (which also indirectly writes selectedModel). + logChangesToStateModel(this._inputModel, `setPermissionLevel -> _syncInputStateToModel (level=${level}, currentLanguageModel=${this._currentLanguageModel.get()?.identifier}) in ${this._currentSessionKey}`, undefined, undefined, this.logService); this._syncInputStateToModel(); } @@ -1085,14 +1103,18 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge * Solution is to pass the SessionResource as an argument to this method. */ setInputModel(model: IInputModel, chatSessionIsEmpty: boolean, forSessionResource: URI): void { - logChangesToStateModel(this._inputModel, `setInputModel for ${forSessionResource.toString()}`, model.state.get(), undefined, this.logService); + // Pass the OUTGOING session's input state as oldState so we can see what + // model the previous session was holding right before we swap it out. + logChangesToStateModel(this._inputModel, `setInputModel for ${forSessionResource.toString()} (chatSessionIsEmpty=${chatSessionIsEmpty}, outgoing._inputModel=${this._inputModel ? 'present' : 'undefined'})`, model.state.get(), this._inputModel?.state.get(), this.logService); // Flush current state to the outgoing model before switching, // so it preserves the latest permission level and other picker state. if (this._inputModel) { + logChangesToStateModel(this._inputModel, `[FLUSH-PRE] setInputModel pre-flush boundInputModelSession=${this._inputModelSessionResource?.toString()} widgetSession=${this._currentSessionKey} incoming=${forSessionResource.toString()}`, undefined, this._inputModel.state.get(), this.logService); this._syncInputStateToModel(); } this._inputModel = model; + this._inputModelSessionResource = forSessionResource; this._modelSyncDisposables.clear(); const chatModes = this.chatModeService.createModes(forSessionResource); this._currentChatModes.value = chatModes; @@ -1129,7 +1151,17 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge state = this._emptyInputState.read(undefined); message = `syncing from empty input state for ${forSessionResource.toString()}`; } - logChangesToStateModel(this._inputModel, message, state, undefined, this.logService); + // Detect autorun firing for a session that is no longer the widget's + // active session - indicates a late/stale model.state.read() landed for + // the outgoing session. + const widgetSessionResource = this._widget?.viewModel?.model.sessionResource; + if (widgetSessionResource && !isEqual(widgetSessionResource, forSessionResource)) { + message = `[STALE-SESSION-AUTORUN] ${message} (widget now on ${widgetSessionResource.toString()})`; + } + // Untracked read: we only want a snapshot for the log, not a dependency + // that would re-trigger this autorun. + const prevState = this._inputModel?.state.read(undefined); + logChangesToStateModel(this._inputModel, message, state, prevState, this.logService); this._syncFromModel(state, forSessionResource); })); } @@ -1197,12 +1229,19 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge sessionType, }); if (syncResult.action === 'apply') { - logChangesToStateModel(this._inputModel, `applying selected model from input state for ${forSessionResource.toString()} in ${this._currentSessionKey}`, state, undefined, this.logService); + logChangesToStateModel(this._inputModel, `_syncFromModel: applying selected model from input state for ${forSessionResource.toString()} in ${this._currentSessionKey}`, state, undefined, this.logService); this.setCurrentLanguageModel(state.selectedModel); } else if (syncResult.action === 'default') { - logChangesToStateModel(this._inputModel, `applying default model from input state for ${forSessionResource.toString()} in ${this._currentSessionKey}`, state, undefined, this.logService); + logChangesToStateModel(this._inputModel, `_syncFromModel: state.selectedModel rejected by resolveModelFromSyncState, falling back to default for ${forSessionResource.toString()} in ${this._currentSessionKey} (rejected=${state.selectedModel.identifier}, sessionType=${sessionType}, currentModeKind=${this.currentModeKind})`, state, undefined, this.logService); this.setCurrentLanguageModelToDefault(); + } else { + // 'keep' - the existing _currentLanguageModel matches state.selectedModel. + logChangesToStateModel(this._inputModel, `_syncFromModel: keep (state.selectedModel matches current) for ${forSessionResource.toString()} in ${this._currentSessionKey}`, state, undefined, this.logService); } + } else if (state) { + // state exists but state.selectedModel is undefined - sync is a NO-OP, + // but record it so we can see when a session's persisted state lost its model. + logChangesToStateModel(this._inputModel, `_syncFromModel: state has no selectedModel (no-op for model picker) for ${forSessionResource.toString()} in ${this._currentSessionKey} (current=${this._currentLanguageModel.get()?.identifier})`, state, undefined, this.logService); } // Sync attachments @@ -1254,7 +1293,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (this._chatSessionIsEmpty) { this._emptyInputState.set(state, undefined); } - logChangesToStateModel(this._inputModel, `_syncInputStateToModel for ${this._currentSessionKey}`, undefined, undefined, this.logService); + // Pass the actual newState and the previous state so model-identifier + // transitions (including transitions to/from undefined) are visible. + const prevState = this._inputModel?.state.get(); + logChangesToStateModel(this._inputModel, `_syncInputStateToModel boundInputModelSession=${this._inputModelSessionResource?.toString()} widgetSession=${this._currentSessionKey} mismatch=${this._inputModelSessionResource?.toString() !== this._currentSessionKey}`, state, prevState, this.logService); this._inputModel?.setState(state); this._isSyncingToOrFromInputModel = false; @@ -1275,7 +1317,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge public setCurrentLanguageModel(model: ILanguageModelChatMetadataAndIdentifier) { const modelDetails = this.getModels().map(m => `${m.identifier} (${m.metadata.id})`).join(', '); - logChangesToStateModel(this._inputModel, `setCurrentLanguageModel to ${model.identifier} in ${this._currentSessionKey}, modelDetials = ${modelDetails}`, undefined, undefined, this.logService); + const selectedModelStorageKey = this.getSelectedModelStorageKey(); + const selectedModelIsDefaultStorageKey = this.getSelectedModelIsDefaultStorageKey(); + logChangesToStateModel(this._inputModel, `setCurrentLanguageModel to ${model.identifier} in ${this._currentSessionKey}, storageKey=${selectedModelStorageKey}, isDefaultKey=${selectedModelIsDefaultStorageKey}, currentSessionType=${this._currentSessionType}, getCurrentSessionType=${this.getCurrentSessionType()}, boundInputModelSession=${this._inputModelSessionResource?.toString()}, modelDetials = ${modelDetails}`, undefined, undefined, this.logService); this._currentLanguageModel.set(model, undefined); if (this.cachedWidth) { @@ -1294,6 +1338,12 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private checkModelSupported(): void { const lm = this._currentLanguageModel.get(); const allModels = this.getAllMergedModels(); + const willReset = shouldResetModelToDefault(lm, this.getModels(), { + location: this.location, + currentModeKind: this.currentModeKind, + sessionType: this.getCurrentSessionType(), + }, allModels); + logChangesToStateModel(this._inputModel, `[CMS] checkModelSupported lm=${lm?.identifier} mode=${this.currentModeKind} sessionType=${this.getCurrentSessionType()} willReset=${willReset} in ${this._currentSessionKey}`, undefined, this._inputModel?.state.get(), this.logService); if (shouldResetModelToDefault(lm, this.getModels(), { location: this.location, currentModeKind: this.currentModeKind, @@ -1328,6 +1378,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._onDidChangeCurrentChatMode.fire(); // Sync to model (mode is now persisted in the model's input state) + // Log first so the upcoming _syncInputStateToModel write can be attributed + // to a mode change. + logChangesToStateModel(this._inputModel, `setChatMode2 -> _syncInputStateToModel (mode=${mode.id}, storeSelection=${storeSelection}, currentLanguageModel=${this._currentLanguageModel.get()?.identifier}) in ${this._currentSessionKey}`, undefined, undefined, this.logService); this._syncInputStateToModel(); } @@ -1510,8 +1563,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private setCurrentLanguageModelToDefault() { const allModels = this.getModels(); const defaultModel = findDefaultModel(allModels, this.location); + logChangesToStateModel(this._inputModel, `[DEFAULT] setCurrentLanguageModelToDefault called (defaultModel=${defaultModel?.identifier}, currentLm=${this._currentLanguageModel.get()?.identifier}) in ${this._currentSessionKey}`, undefined, this._inputModel?.state.get(), this.logService); if (defaultModel) { - logChangesToStateModel(this._inputModel, `setCurrentLanguageModelToDefault to ${defaultModel.identifier} in ${this._currentSessionKey}`, undefined, undefined, this.logService); this.setCurrentLanguageModel(defaultModel); } } @@ -1585,10 +1638,12 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const validMode = this._currentChatModesObservable.get().findModeById(currentMode.id); const isAgentModeEnabled = this.configurationService.getValue(ChatConfiguration.AgentEnabled); if (!validMode) { + logChangesToStateModel(this._inputModel, `[VCM] validateCurrentChatMode: ${currentMode.id} not in modes set -> setChatMode(${isAgentModeEnabled ? 'Agent' : 'Ask'}) in ${this._currentSessionKey}`, undefined, this._inputModel?.state.get(), this.logService); this.setChatMode(isAgentModeEnabled ? ChatModeKind.Agent : ChatModeKind.Ask); return; } if (currentMode.kind === ChatModeKind.Agent && !isAgentModeEnabled) { + logChangesToStateModel(this._inputModel, `[VCM] validateCurrentChatMode: Agent mode disabled by policy -> setChatMode(Ask) in ${this._currentSessionKey}`, undefined, this._inputModel?.state.get(), this.logService); this.setChatMode(ChatModeKind.Ask); return; } @@ -1763,6 +1818,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } // Clear attached context, fire event to clear input state, and clear the input editor + logChangesToStateModel(this._inputModel, `[ACCEPT] acceptInput -> attachmentModel.clear() in ${this._currentSessionKey}`, undefined, this._inputModel?.state.get(), this.logService); this.attachmentModel.clear(); this._onDidLoadInputState.fire(); if (this.accessibilityService.isScreenReaderOptimized() && isMacintosh) { @@ -2550,6 +2606,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (this.chatPhoneInputPresenter.enabled.get()) { if (action.id === OpenModelPickerAction.ID && action instanceof MenuItemAction) { if (!this._currentLanguageModel.get()) { + logChangesToStateModel(this._inputModel, `actionViewItemProvider[phone]: _currentLanguageModel is undefined at toolbar build, forcing default for ${this._currentSessionKey}`, undefined, undefined, this.logService); this.setCurrentLanguageModelToDefault(); } const modelDelegate = this._createModelPickerDelegate(); @@ -2562,6 +2619,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (action.id === OpenModelPickerAction.ID && action instanceof MenuItemAction) { if (!this._currentLanguageModel.get()) { + logChangesToStateModel(this._inputModel, `actionViewItemProvider[desktop]: _currentLanguageModel is undefined at toolbar build, forcing default for ${this._currentSessionKey}`, undefined, undefined, this.logService); this.setCurrentLanguageModelToDefault(); } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 7427dd45c1332..edad95f36dffb 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -586,6 +586,10 @@ export interface IChatTerminalToolInvocationData { requestUnsandboxedExecution?: boolean; /** The model-provided reason for requesting sandbox bypass */ requestUnsandboxedExecutionReason?: string; + /** Whether the terminal command was approved to run sandboxed with unrestricted network access */ + requestAllowNetwork?: boolean; + /** The model-provided reason for requesting unrestricted network access within the sandbox */ + requestAllowNetworkReason?: string; /** Serialized URI for the command that was executed in the terminal */ terminalCommandUri?: UriComponents; /** Serialized output of the executed command */ @@ -610,6 +614,10 @@ export interface IChatTerminalToolInvocationData { autoApproveInfo?: IMarkdownString; /** Names of missing sandbox dependencies that the user may choose to install */ missingSandboxDependencies?: string[]; + /** Approved repair actions that may make an installed but unusable sandbox dependency work. */ + sandboxRemediations?: string[]; + /** User-visible reason a sandbox prerequisite cannot be repaired automatically. */ + sandboxPrerequisiteFailure?: string; } /** diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 3a7236e7dca31..9120bab0609f2 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -35,7 +35,7 @@ import { awaitStatsForSession } from '../chat.js'; import { ChatPerfMark, clearChatMarks, markChat } from '../chatPerf.js'; import { IChatAgentAttachmentCapabilities, IChatAgentCommand, IChatAgentData, IChatAgentHistoryEntry, IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../participants/chatAgents.js'; import { chatEditingSessionIsReady } from '../editing/chatEditingService.js'; -import { ChatModel, ChatRequestModel, ChatRequestRemovalReason, IChatModel, IChatRequestModel, IChatRequestModeInfo, IChatRequestVariableData, IChatResponseModel, IExportableChatData, ISerializableChatData, ISerializableChatDataIn, ISerializableChatsData, ISerializedChatDataReference, normalizeSerializableChatData, toChatHistoryContent, updateRanges, ISerializableChatModelInputState } from '../model/chatModel.js'; +import { ChatModel, ChatRequestModel, ChatRequestRemovalReason, IChatModel, IChatRequestModel, IChatRequestModeInfo, IChatRequestVariableData, IChatResponseModel, IExportableChatData, ISerializableChatData, ISerializableChatDataIn, ISerializableChatsData, ISerializedChatDataReference, normalizeSerializableChatData, toChatHistoryContent, updateRanges, ISerializableChatModelInputState, logChangesToStateModel } from '../model/chatModel.js'; import { ChatModelStore, IStartSessionProps } from '../model/chatModelStore.js'; import { chatAgentLeader, ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, ChatRequestTextPart, chatSubcommandLeader, getPromptText, IParsedChatRequest } from '../requestParser/chatParserTypes.js'; import { ChatRequestParser } from '../requestParser/chatRequestParser.js'; @@ -618,10 +618,12 @@ export class ChatService extends Disposable implements IChatService { const storedPermissionLevel = storedMetadata?.permissionLevel; const storedInputState = storedMetadata?.inputState; let initialData: ISerializedChatDataReference | undefined = undefined; + let historySelectedModel: string | undefined = undefined; if ((modelId || agentUri)) { const mode: ISerializableChatModelInputState['mode'] = agentUri ? { kind: ChatModeKind.Agent, id: agentUri.toString() } : { kind: ChatModeKind.Agent, id: ChatMode.Agent.id }; const modelMetadata = modelId ? this.languageModelsService.lookupLanguageModel(modelId) : undefined; const selectedModel: ISerializableChatModelInputState['selectedModel'] = modelId && modelMetadata ? { identifier: modelId, metadata: modelMetadata } : undefined; + historySelectedModel = selectedModel?.identifier; // This is used to initialize the state of the chat input box, with the selected model, mode, etc initialData = { serializer: new ChatSessionOperationLog(), @@ -660,6 +662,8 @@ export class ChatService extends Disposable implements IChatService { inputState: providedSession.transferredState?.inputState ?? storedInputState, }, debugOwner ?? 'ChatService#loadRemoteSession'); + logChangesToStateModel(modelRef.object.inputModel, `loadRemoteSession inputState source: session=${sessionResource.toString()}, chatSessionType=${chatSessionType}, historyModelId=${modelId}, agentUri=${agentUri?.toString()}, historySelectedModel=${historySelectedModel}, transferredSelectedModel=${providedSession.transferredState?.inputState?.selectedModel?.identifier}, storedSelectedModel=${storedInputState?.selectedModel?.identifier}, finalSelectedModel=${modelRef.object.inputModel.state.get()?.selectedModel?.identifier}, hasTransferredInputState=${!!providedSession.transferredState?.inputState}, hasStoredInputState=${!!storedInputState}, hasInitialData=${!!initialData}`, modelRef.object.inputModel.state.get(), undefined, this.logService); + // Restore permission level from metadata even when initialData was not constructed // and no inputState carried it through. if (storedPermissionLevel && !initialData && !storedInputState) { diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 4d1ff243ee19f..fa78a3dcb76b5 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -3102,16 +3102,16 @@ export namespace ChatResponseResource { } function _logChangesToStateModel(newState: Partial | undefined, oldState: Partial | undefined, logger: ILogService) { - if (!canLog(logger.getLevel(), LogLevel.Debug) || newState?.selectedModel?.identifier === oldState?.selectedModel?.identifier) { + if (!canLog(logger.getLevel(), LogLevel.Info) || newState?.selectedModel?.identifier === oldState?.selectedModel?.identifier) { return; } const stack = new Error().stack; const message = `[ChatModelChanged] ChatModel Input State model changed: ${newState?.selectedModel?.identifier} (was: ${oldState?.selectedModel?.identifier}) ${stack}`; - logger.debug(message); + logger.info(message); } export function logChangesToStateModel(model: IInputModel | undefined, message: string, newState: Partial | undefined, oldState: Partial | undefined, logger: ILogService) { - if (!canLog(logger.getLevel(), LogLevel.Debug)) { + if (!canLog(logger.getLevel(), LogLevel.Info)) { return; } message = [message, @@ -3121,5 +3121,5 @@ export function logChangesToStateModel(model: IInputModel | undefined, message: new Error().stack ].join(', '); - logger.debug(`[ChatModelChanged] Chat Model Changed,${message}`); + logger.info(`[ChatModelChanged] Chat Model Changed,${message}`); } diff --git a/src/vs/workbench/contrib/chat/common/model/objectMutationLog.ts b/src/vs/workbench/contrib/chat/common/model/objectMutationLog.ts index 66ec1c4b226eb..701c6052680ff 100644 --- a/src/vs/workbench/contrib/chat/common/model/objectMutationLog.ts +++ b/src/vs/workbench/contrib/chat/common/model/objectMutationLog.ts @@ -216,6 +216,79 @@ type Entry = const LF = VSBuffer.fromString('\n'); +/** + * Per-string cap (in UTF-16 code units, matching `string.length`) applied when + * {@link stringifyEntryWithFallback} retries after `JSON.stringify` throws + * `RangeError: Invalid string length` (V8's max string length is ~512 MiB on + * 64-bit). Any single string longer than this is replaced with a marker on + * retry. Generous so it triggers only on outliers. + */ +export const PERSIST_ENTRY_MAX_STRING_CHARS = 1 * 1024 * 1024; + +/** + * Total-size budget (sum of `string.length` for tracked strings, in UTF-16 + * code units) for the retry of {@link stringifyEntryWithFallback}. Once the + * cumulative tracked size during serialization exceeds this, remaining values + * are replaced with a marker. + * + * This is an approximation: JSON escaping, property keys, and non-string + * payload are not counted, so the actual output may be moderately larger. + * The cap is sized well under V8's max string length to leave ample headroom + * for that overhead. + */ +export const PERSIST_ENTRY_MAX_TOTAL_CHARS = 100 * 1024 * 1024; + +const TRUNCATION_MARKER_PREFIX = '[VS Code: value truncated for persistence'; +const TRUNCATION_MARKER_TOTAL = `${TRUNCATION_MARKER_PREFIX}; entry exceeded size budget]`; + +/** + * Wraps `JSON.stringify(entry)` with a safety net for the V8 max-string-length + * limit. The common path is a single `JSON.stringify` with zero overhead. If + * stringification throws `RangeError` (the resulting JSON would exceed V8's + * ~512 MiB max string length — see microsoft/vscode#308843), retry with a + * replacer that truncates oversized strings. Extensions sometimes put very + * large content (browser dumps, command output, …) into chat result metadata; + * losing the tail of one such value is dramatically better than losing the + * entire chat session. + */ +export function stringifyEntryWithFallback(entry: unknown): string { + try { + return JSON.stringify(entry); + } catch (e) { + if (!(e instanceof RangeError)) { + throw e; + } + return JSON.stringify(entry, makeTruncatingReplacer(PERSIST_ENTRY_MAX_STRING_CHARS, PERSIST_ENTRY_MAX_TOTAL_CHARS)); + } +} + +/** + * Exported for testing only. Builds the stateful `JSON.stringify` replacer + * used by {@link stringifyEntryWithFallback} on its retry path. + * + * Sizes are tracked in UTF-16 code units (`string.length`); JSON escaping, + * property keys, and non-string payload are not counted. + */ +export function makeTruncatingReplacer(maxStringChars: number, maxTotalChars: number): (key: string, value: unknown) => unknown { + let total = 0; + return (_key, val) => { + if (typeof val === 'string') { + let emitted: string; + if (val.length > maxStringChars) { + emitted = `${TRUNCATION_MARKER_PREFIX}; original ${val.length} chars]`; + } else if (total + val.length + 2 > maxTotalChars) { + emitted = TRUNCATION_MARKER_TOTAL; + } else { + total += val.length + 2; + return val; + } + total += emitted.length + 2; + return emitted; + } + return val; + }; +} + /** * An implementation of an append-based mutation logger. Given a `Transform` * definition of an object, it can recreate it from a file on disk. It is @@ -255,7 +328,7 @@ export class ObjectMutationLog { this._entryCount = 1; this._clearPending(); const entry: Entry = { kind: EntryKind.Initial, v: value }; - return VSBuffer.fromString(JSON.stringify(entry) + '\n'); + return VSBuffer.fromString(stringifyEntryWithFallback(entry) + '\n'); } /** @@ -335,7 +408,7 @@ export class ObjectMutationLog { this._pendingPrevious = currentValue; this._pendingEntryCount = 1; const entry: Entry = { kind: EntryKind.Initial, v: currentValue }; - return { op: 'replace', data: VSBuffer.fromString(JSON.stringify(entry) + '\n') }; + return { op: 'replace', data: VSBuffer.fromString(stringifyEntryWithFallback(entry) + '\n') }; } // Generate diff entries @@ -364,7 +437,7 @@ export class ObjectMutationLog { // Append entries - build string directly let data = ''; for (const e of entries) { - data += JSON.stringify(e) + '\n'; + data += stringifyEntryWithFallback(e) + '\n'; } return { op: 'append', data: VSBuffer.fromString(data) }; } diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatSessionOperationLog.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatSessionOperationLog.test.ts index a3f9637ea6867..fd53bd9fdb235 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatSessionOperationLog.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatSessionOperationLog.test.ts @@ -673,4 +673,87 @@ suite('ChatSessionOperationLog', () => { assert.strictEqual(result.count, 2); }); }); + + suite('persistence size safety net', () => { + test('makeTruncatingReplacer truncates an oversized string', () => { + const big = 'x'.repeat(2 * 1024 * 1024); + const obj = { content: big, label: 'ok' }; + const json = JSON.stringify(obj, Adapt.makeTruncatingReplacer(1024 * 1024, 10 * 1024 * 1024)); + const parsed = JSON.parse(json); + assert.notStrictEqual(parsed.content, big); + assert.ok(parsed.content.startsWith('[VS Code:')); + assert.strictEqual(parsed.label, 'ok'); + }); + + test('makeTruncatingReplacer respects total budget without overshooting', () => { + const STRING_CAP = 1024 * 1024; + const TOTAL_CAP = 1024 * 1024; + const medium = 'y'.repeat(200 * 1024); // under per-string cap + const obj: any = {}; + for (let i = 0; i < 20; i++) { + obj[`k${i}`] = medium; + } + const json = JSON.stringify(obj, Adapt.makeTruncatingReplacer(STRING_CAP, TOTAL_CAP)); + const parsed = JSON.parse(json); + // Sum of preserved strings must not exceed the total budget. + const preservedChars = Object.values(parsed) + .filter((v): v is string => typeof v === 'string' && v === medium) + .reduce((sum, v) => sum + v.length, 0); + assert.ok(preservedChars <= TOTAL_CAP, `preserved ${preservedChars} chars exceeded budget ${TOTAL_CAP}`); + // Leading keys intact, later replaced with total-budget marker + assert.strictEqual(parsed.k0, medium); + assert.ok(Object.values(parsed).some(v => typeof v === 'string' && (v as string).includes('entry exceeded size budget'))); + }); + + test('stringifyEntryWithFallback succeeds with no overhead on small entries', () => { + const entry = { kind: 0, v: { foo: 'bar', n: 42 } }; + const out = Adapt.stringifyEntryWithFallback(entry); + assert.strictEqual(out, JSON.stringify(entry)); + }); + + test('stringifyEntryWithFallback rethrows non-RangeError', () => { + const circular: any = {}; + circular.self = circular; // JSON.stringify throws TypeError on circulars + assert.throws(() => Adapt.stringifyEntryWithFallback(circular), TypeError); + }); + + test('stringifyEntryWithFallback recovers when JSON.stringify throws RangeError', () => { + // Use toJSON to force a RangeError on the first stringify pass, + // then succeed on the retry. Avoids needing 500+ MiB of allocations. + let calls = 0; + const entry = { + toJSON() { + calls++; + if (calls === 1) { + throw new RangeError('Invalid string length'); + } + return { content: 'recovered' }; + }, + }; + const out = Adapt.stringifyEntryWithFallback(entry); + assert.strictEqual(calls, 2, 'should have been called twice (initial + retry)'); + assert.deepStrictEqual(JSON.parse(out), { content: 'recovered' }); + }); + + test('stringifyEntryWithFallback applies truncating replacer on RangeError retry', () => { + // Same trick, but the recovered payload contains an oversized + // string that must be truncated by the replacer on the retry. + const big = 'x'.repeat(2 * 1024 * 1024); // 2 MiB, over the 1 MiB per-string cap + let calls = 0; + const entry = { + toJSON() { + calls++; + if (calls === 1) { + throw new RangeError('Invalid string length'); + } + return { content: big, label: 'ok' }; + }, + }; + const out = Adapt.stringifyEntryWithFallback(entry); + const parsed = JSON.parse(out); + assert.notStrictEqual(parsed.content, big); + assert.ok(parsed.content.startsWith('[VS Code:'), `unexpected: ${parsed.content.slice(0, 80)}`); + assert.strictEqual(parsed.label, 'ok'); + }); + }); }); diff --git a/src/vs/workbench/contrib/terminal/terminalContribExports.ts b/src/vs/workbench/contrib/terminal/terminalContribExports.ts index bf4eb058cca5c..d739f8cf9221a 100644 --- a/src/vs/workbench/contrib/terminal/terminalContribExports.ts +++ b/src/vs/workbench/contrib/terminal/terminalContribExports.ts @@ -50,6 +50,7 @@ export const enum TerminalContribSettingId { AgentSandboxEnabled = AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxWindowsEnabled = AgentSandboxSettingId.AgentSandboxWindowsEnabled, AgentSandboxAllowUnsandboxedCommands = AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands, + AgentSandboxRetryWithAllowNetworkRequests = AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, AgentSandboxAutoApproveUnsandboxedCommands = AgentSandboxSettingId.AgentSandboxAutoApproveUnsandboxedCommands, AgentSandboxAllowAutoApprove = AgentSandboxSettingId.AgentSandboxAllowAutoApprove, DeprecatedAgentSandboxEnabled = AgentSandboxSettingId.DeprecatedAgentSandboxEnabled, diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineAnalyzer.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineAnalyzer.ts index 20522dec1c1ae..e03288f9700bd 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineAnalyzer.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineAnalyzer.ts @@ -46,6 +46,7 @@ export interface ICommandLineAnalyzerOptions { terminalToolSessionId: string; chatSessionResource: URI | undefined; requiresUnsandboxConfirmation?: boolean; + requiresAllowNetworkConfirmation?: boolean; // User has opted into "Allow All Commands in this Session" hasSessionAutoApproval?: boolean; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineSandboxAnalyzer.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineSandboxAnalyzer.ts index 3a9de735bf3ab..7c2c68839e624 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineSandboxAnalyzer.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineSandboxAnalyzer.ts @@ -30,7 +30,7 @@ export class CommandLineSandboxAnalyzer extends Disposable implements ICommandLi } return { isAutoApproveAllowed: isAutoApproveEnabled, - forceAutoApproval: !_options.requiresUnsandboxConfirmation && isAutoApproveEnabled, + forceAutoApproval: !_options.requiresUnsandboxConfirmation && !_options.requiresAllowNetworkConfirmation && isAutoApproveEnabled, }; } } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineRewriter.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineRewriter.ts index 56b4ee7c15530..07624d70ac4bf 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineRewriter.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineRewriter.ts @@ -21,6 +21,7 @@ export interface ICommandLineRewriterOptions { isBackground?: boolean; requestUnsandboxedExecution?: boolean; sandboxPrecheckInputs?: ITerminalSandboxPrecheckInputs; + requestAllowNetwork?: boolean; } export interface ICommandLineRewriterResult { @@ -30,6 +31,7 @@ export interface ICommandLineRewriterResult { forDisplay?: string; isSandboxWrapped?: boolean; requiresUnsandboxConfirmation?: boolean; + requiresAllowNetworkConfirmation?: boolean; blockedDomains?: string[]; deniedDomains?: string[]; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts index 6b350e7d01132..a02c65563a108 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts @@ -24,13 +24,14 @@ export class CommandLineSandboxRewriter extends Disposable implements ICommandLi } const commandDetails = await this._parseCommandDetails(options); - const wrappedCommand = await this._sandboxService.wrapCommand(options.commandLine, options.requestUnsandboxedExecution, options.shell, options.cwd, commandDetails); + const wrappedCommand = await this._sandboxService.wrapCommand(options.commandLine, options.requestUnsandboxedExecution, options.shell, options.cwd, commandDetails, options.requestAllowNetwork); return { rewritten: wrappedCommand.command, - reasoning: wrappedCommand.requiresUnsandboxConfirmation ? 'Switched command to unsandboxed execution because the command includes a domain that is not in the sandbox allowlist' : 'Wrapped command for sandbox execution', + reasoning: wrappedCommand.requiresAllowNetworkConfirmation ? 'Wrapped command for sandbox execution with unrestricted network access' : wrappedCommand.requiresUnsandboxConfirmation ? 'Switched command to unsandboxed execution because the command includes a domain that is not in the sandbox allowlist' : 'Wrapped command for sandbox execution', forDisplay: options.commandLine, // show the command that is passed as input (after prior rewrites like cd prefix stripping) isSandboxWrapped: wrappedCommand.isSandboxWrapped, requiresUnsandboxConfirmation: wrappedCommand.requiresUnsandboxConfirmation, + requiresAllowNetworkConfirmation: wrappedCommand.requiresAllowNetworkConfirmation, blockedDomains: wrappedCommand.blockedDomains, deniedDomains: wrappedCommand.deniedDomains, }; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 361faaa378729..7e5209dfcfe98 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -77,9 +77,9 @@ import { IChatWidgetService } from '../../../../chat/browser/chat.js'; import { TerminalChatCommandId } from '../../../chat/browser/terminalChat.js'; import { clamp } from '../../../../../../base/common/numbers.js'; import { IOutputAnalyzer } from './outputAnalyzer.js'; -import { SandboxOutputAnalyzer, outputLooksSandboxBlocked } from './sandboxOutputAnalyzer.js'; +import { SandboxOutputAnalyzer, outputLooksSandboxBlocked, outputLooksSandboxNetworkBlocked } from './sandboxOutputAnalyzer.js'; import { IAgentSessionsService } from '../../../../chat/browser/agentSessions/agentSessionsService.js'; -import { ITerminalSandboxService, TerminalSandboxPrerequisiteCheck, type ITerminalSandboxPrecheckInputs, type ITerminalSandboxResolvedNetworkDomains } from '../../common/terminalSandboxService.js'; +import { ITerminalSandboxService, TerminalSandboxPrerequisiteCheck, TerminalSandboxPreCheckRemediation, type ITerminalSandboxPrecheckInputs, type ITerminalSandboxResolvedNetworkDomains } from '../../common/terminalSandboxService.js'; import { LanguageModelPartAudience } from '../../../../chat/common/languageModels.js'; import { isSessionAutoApproveLevel, isTerminalAutoApproveAllowed, isToolEligibleForTerminalAutoApproval } from './terminalToolAutoApprove.js'; import type { IJSONSchemaMap } from '../../../../../../base/common/jsonSchema.js'; @@ -93,7 +93,7 @@ const TOOL_REFERENCE_NAME = 'runInTerminal'; const LEGACY_TOOL_REFERENCE_FULL_NAMES = ['runCommands/runInTerminal']; const INPUT_NEEDED_NOTIFICATION_THROTTLE_MS = 5000; -function createPowerShellModelDescription(shell: string, isSandboxEnabled: boolean, allowToRunUnsandboxedCommands: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { +function createPowerShellModelDescription(shell: string, isSandboxEnabled: boolean, allowToRunUnsandboxedCommands: boolean, retryWithAllowNetworkRequests: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { const isWinPwsh = isWindowsPowerShell(shell); const parts = [ `This tool allows you to execute ${isWinPwsh ? 'Windows PowerShell 5.1' : 'PowerShell'} commands in a persistent terminal session, preserving environment variables, working directory, and other context across multiple commands.`, @@ -126,7 +126,7 @@ function createPowerShellModelDescription(shell: string, isSandboxEnabled: boole ]; if (isSandboxEnabled) { - parts.push(...createSandboxLines(allowToRunUnsandboxedCommands, networkDomains)); + parts.push(...createSandboxLines(allowToRunUnsandboxedCommands, retryWithAllowNetworkRequests, networkDomains)); } parts.push( @@ -158,7 +158,7 @@ function createPowerShellModelDescription(shell: string, isSandboxEnabled: boole return parts.join('\n'); } -function createSandboxLines(allowToRunUnsandboxedCommands: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string[] { +function createSandboxLines(allowToRunUnsandboxedCommands: boolean, retryWithAllowNetworkRequests: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string[] { const lines = [ '', 'Sandboxing:', @@ -166,6 +166,13 @@ function createSandboxLines(allowToRunUnsandboxedCommands: boolean, networkDomai '- When executing commands within the sandboxed environment, all operations requiring a temporary directory must utilize the $TMPDIR environment variable. The /tmp directory is not guaranteed to be accessible or writable and must be avoided', '- Tools and scripts should respect the TMPDIR environment variable, which is automatically set to an appropriate path within the sandbox', ]; + if (retryWithAllowNetworkRequests) { + lines.push( + '- When a command fails due to network access being blocked by the sandbox, immediately re-run it with requestAllowNetwork=true and provide requestAllowNetworkReason. This keeps the command in the sandbox with unrestricted network access and automatically shows a confirmation prompt to the user', + '- Only set requestAllowNetwork=true when there is evidence of network failures caused by the sandbox, e.g. \'Network request failed\' errors, API call failures, or other indications of blocked network access in the command output', + '- When setting requestAllowNetwork=true, also provide requestAllowNetworkReason explaining why the command needs network access', + ); + } if (allowToRunUnsandboxedCommands) { lines.push( '- When a command fails due to sandbox restrictions, immediately re-run it with requestUnsandboxedExecution=true. Do NOT ask the user for permission — setting this flag automatically shows a confirmation prompt to the user', @@ -190,7 +197,7 @@ function createSandboxLines(allowToRunUnsandboxedCommands: boolean, networkDomai return lines; } -function createGenericDescription(isSandboxEnabled: boolean, allowToRunUnsandboxedCommands: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { +function createGenericDescription(isSandboxEnabled: boolean, allowToRunUnsandboxedCommands: boolean, retryWithAllowNetworkRequests: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { const parts = [` Command Execution: - Use && to chain simple commands on one line @@ -217,7 +224,7 @@ Async Mode: Use ${TerminalToolId.SendToTerminal} to send commands or input to a terminal session.`]; if (isSandboxEnabled) { - parts.push(createSandboxLines(allowToRunUnsandboxedCommands, networkDomains).join('\n')); + parts.push(createSandboxLines(allowToRunUnsandboxedCommands, retryWithAllowNetworkRequests, networkDomains).join('\n')); } parts.push(` @@ -245,19 +252,19 @@ Interactive Input Handling: return parts.join(''); } -function createBashModelDescription(isSandboxEnabled: boolean, allowToRunUnsandboxedCommands: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { +function createBashModelDescription(isSandboxEnabled: boolean, allowToRunUnsandboxedCommands: boolean, retryWithAllowNetworkRequests: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { return [ 'This tool allows you to execute shell commands in a persistent bash terminal session, preserving environment variables, working directory, and other context across multiple commands.', - createGenericDescription(isSandboxEnabled, allowToRunUnsandboxedCommands, networkDomains), + createGenericDescription(isSandboxEnabled, allowToRunUnsandboxedCommands, retryWithAllowNetworkRequests, networkDomains), '- Use [[ ]] for conditional tests instead of [ ]', '- Prefer $() over backticks for command substitution' ].join('\n'); } -function createZshModelDescription(isSandboxEnabled: boolean, allowToRunUnsandboxedCommands: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { +function createZshModelDescription(isSandboxEnabled: boolean, allowToRunUnsandboxedCommands: boolean, retryWithAllowNetworkRequests: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { return [ 'This tool allows you to execute shell commands in a persistent zsh terminal session, preserving environment variables, working directory, and other context across multiple commands.', - createGenericDescription(isSandboxEnabled, allowToRunUnsandboxedCommands, networkDomains), + createGenericDescription(isSandboxEnabled, allowToRunUnsandboxedCommands, retryWithAllowNetworkRequests, networkDomains), '- Use type to check command type (builtin, function, alias)', '- Use jobs, fg, bg for job control', '- Use [[ ]] for conditional tests instead of [ ]', @@ -270,10 +277,10 @@ function createZshModelDescription(isSandboxEnabled: boolean, allowToRunUnsandbo ].join('\n'); } -function createFishModelDescription(isSandboxEnabled: boolean, allowToRunUnsandboxedCommands: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { +function createFishModelDescription(isSandboxEnabled: boolean, allowToRunUnsandboxedCommands: boolean, retryWithAllowNetworkRequests: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { return [ 'This tool allows you to execute shell commands in a persistent fish terminal session, preserving environment variables, working directory, and other context across multiple commands.', - createGenericDescription(isSandboxEnabled, allowToRunUnsandboxedCommands, networkDomains), + createGenericDescription(isSandboxEnabled, allowToRunUnsandboxedCommands, retryWithAllowNetworkRequests, networkDomains), '- Use type to check command type (builtin, function, alias)', '- Use jobs, fg, bg for job control', '- Use test expressions for conditionals (no [[ ]] syntax)', @@ -290,6 +297,7 @@ export async function createRunInTerminalToolData( const terminalSandboxService = accessor.get(ITerminalSandboxService); const configurationService = accessor.get(IConfigurationService); const allowToRunUnsandboxedCommands = configurationService.getValue(AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands) === true; + const retryWithAllowNetworkRequestsSetting = configurationService.getValue(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests) === true; const profileFetcher = instantiationService.createInstance(TerminalProfileFetcher); const [shell, os, isSandboxEnabled, isSandboxAllowNetworkEnabled] = await Promise.all([ @@ -298,18 +306,19 @@ export async function createRunInTerminalToolData( terminalSandboxService.isEnabled(), terminalSandboxService.isSandboxAllowNetworkEnabled(), ]); + const retryWithAllowNetworkRequests = isSandboxEnabled && !isSandboxAllowNetworkEnabled && retryWithAllowNetworkRequestsSetting; const networkDomains = isSandboxEnabled && !isSandboxAllowNetworkEnabled ? terminalSandboxService.getResolvedNetworkDomains() : undefined; let modelDescription: string; if (shell && os && isPowerShell(shell, os)) { - modelDescription = createPowerShellModelDescription(shell, isSandboxEnabled, allowToRunUnsandboxedCommands, networkDomains); + modelDescription = createPowerShellModelDescription(shell, isSandboxEnabled, allowToRunUnsandboxedCommands, retryWithAllowNetworkRequests, networkDomains); } else if (shell && os && isZsh(shell, os)) { - modelDescription = createZshModelDescription(isSandboxEnabled, allowToRunUnsandboxedCommands, networkDomains); + modelDescription = createZshModelDescription(isSandboxEnabled, allowToRunUnsandboxedCommands, retryWithAllowNetworkRequests, networkDomains); } else if (shell && os && isFish(shell, os)) { - modelDescription = createFishModelDescription(isSandboxEnabled, allowToRunUnsandboxedCommands, networkDomains); + modelDescription = createFishModelDescription(isSandboxEnabled, allowToRunUnsandboxedCommands, retryWithAllowNetworkRequests, networkDomains); } else { - modelDescription = createBashModelDescription(isSandboxEnabled, allowToRunUnsandboxedCommands, networkDomains); + modelDescription = createBashModelDescription(isSandboxEnabled, allowToRunUnsandboxedCommands, retryWithAllowNetworkRequests, networkDomains); } const sharedProperties: IJSONSchemaMap = { @@ -341,6 +350,14 @@ export async function createRunInTerminalToolData( type: 'string', description: 'A short explanation of why this command must run outside the terminal sandbox. Only provide this when requestUnsandboxedExecution is true.' }, + requestAllowNetwork: { + type: 'boolean', + description: 'Request that this command remain in the terminal sandbox but run with unrestricted network access. Only set this when the command clearly needs network access but the required network access was blocked. The user will be prompted before network restrictions are relaxed.' + }, + requestAllowNetworkReason: { + type: 'string', + description: 'A short explanation of why this sandboxed command needs unrestricted network access. Only provide this when requestAllowNetwork is true.' + } } : {}; return { @@ -407,6 +424,8 @@ export interface IRunInTerminalInputParams { timeout?: number; requestUnsandboxedExecution?: boolean; requestUnsandboxedExecutionReason?: string; + requestAllowNetwork?: boolean; + requestAllowNetworkReason?: string; allowToRunUnsandboxedCommands?: boolean; } @@ -416,6 +435,20 @@ interface IResolvedExecutionOptions { mode: 'sync' | 'async'; } +type AutomaticSandboxRetryKind = 'unsandboxed' | 'allowNetwork'; + +interface IAutomaticSandboxRetryPredicateOptions { + readonly retryAllowed: boolean; + readonly retryAlreadyRequested: boolean; + readonly didSandboxWrapCommand: boolean; + readonly isPersistentSession: boolean; + readonly isBackgroundExecution: boolean; + readonly didTimeout: boolean; + readonly exitCode: number | undefined; + readonly output: string; + readonly outputLooksRetryable: (output: string) => boolean; +} + export interface IAutomaticUnsandboxRetryOptions { readonly allowUnsandboxedCommands: boolean; readonly didSandboxWrapCommand: boolean; @@ -427,15 +460,56 @@ export interface IAutomaticUnsandboxRetryOptions { readonly output: string; } -export function shouldAutomaticallyRetryUnsandboxed(options: IAutomaticUnsandboxRetryOptions): boolean { - return options.allowUnsandboxedCommands +export interface IAutomaticAllowNetworkRetryOptions { + readonly retryWithAllowNetworkRequests: boolean; + readonly didSandboxWrapCommand: boolean; + readonly requestUnsandboxedExecution: boolean; + readonly requestAllowNetwork: boolean; + readonly isPersistentSession: boolean; + readonly isBackgroundExecution: boolean; + readonly didTimeout: boolean; + readonly exitCode: number | undefined; + readonly output: string; +} + +function shouldAutomaticallyRetrySandbox(options: IAutomaticSandboxRetryPredicateOptions): boolean { + return options.retryAllowed && options.didSandboxWrapCommand - && options.requestUnsandboxedExecution !== true + && options.retryAlreadyRequested !== true && !options.isPersistentSession && !options.isBackgroundExecution && !options.didTimeout && options.exitCode !== 0 - && outputLooksSandboxBlocked(options.output); + && options.outputLooksRetryable(options.output); +} + +export function shouldAutomaticallyRetryUnsandboxed(options: IAutomaticUnsandboxRetryOptions): boolean { + return shouldAutomaticallyRetrySandbox({ + retryAllowed: options.allowUnsandboxedCommands, + retryAlreadyRequested: options.requestUnsandboxedExecution, + didSandboxWrapCommand: options.didSandboxWrapCommand, + isPersistentSession: options.isPersistentSession, + isBackgroundExecution: options.isBackgroundExecution, + didTimeout: options.didTimeout, + exitCode: options.exitCode, + output: options.output, + // Network failures are handled by shouldAutomaticallyRetryAllowNetworkInSandboxed; do not automatically leave the sandbox for them. + outputLooksRetryable: output => outputLooksSandboxBlocked(output) && !outputLooksSandboxNetworkBlocked(output), + }); +} + +export function shouldAutomaticallyRetryAllowNetworkInSandboxed(options: IAutomaticAllowNetworkRetryOptions): boolean { + return shouldAutomaticallyRetrySandbox({ + retryAllowed: options.retryWithAllowNetworkRequests, + retryAlreadyRequested: options.requestUnsandboxedExecution || options.requestAllowNetwork, + didSandboxWrapCommand: options.didSandboxWrapCommand, + isPersistentSession: options.isPersistentSession, + isBackgroundExecution: options.isBackgroundExecution, + didTimeout: options.didTimeout, + exitCode: options.exitCode, + output: options.output, + outputLooksRetryable: outputLooksSandboxNetworkBlocked, + }); } /** @@ -605,6 +679,10 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { return this._allowUnsandboxedCommands && this._configurationService.getValue(AgentSandboxSettingId.AgentSandboxAutoApproveUnsandboxedCommands) === true; } + private get _retryWithAllowNetworkRequests(): boolean { + return this._configurationService.getValue(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests) === true; + } + private get _allowSandboxAutoApprove(): boolean { return this._configurationService.getValue(AgentSandboxSettingId.AgentSandboxAllowAutoApprove) === true; } @@ -617,6 +695,10 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { return isSandboxEnabled && args.requestUnsandboxedExecution === true && !allowUnsandboxedCommands; } + private _shouldRejectAllowNetworkRequest(isSandboxEnabled: boolean, isSandboxAllowNetworkEnabled: boolean, args: IRunInTerminalInputParams): boolean { + return isSandboxEnabled && !isSandboxAllowNetworkEnabled && args.requestAllowNetwork === true && !this._retryWithAllowNetworkRequests; + } + private _getUnsandboxedExecutionDisabledMessage(): string { return localize( 'runInTerminal.unsandboxed.disabled.result', @@ -624,6 +706,13 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { ); } + private _getAllowNetworkRequestDisabledMessage(): string { + return localize( + 'runInTerminal.allowNetwork.disabled.result', + "The command was not executed because it requested unrestricted network access in the terminal sandbox, but per-command network access is disabled by chat.agent.sandbox.retryWithAllowNetworkRequests. Run the command with restricted network access instead, or enable the setting to allow network access requests." + ); + } + /** * Controls whether this tool wires up sandbox-specific command-line * behavior, including both the {@link CommandLineSandboxRewriter} and the @@ -768,14 +857,24 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { ]); const language = os === OperatingSystem.Windows ? 'pwsh' : 'sh'; const isSandboxEnabled = sandboxPrereqs.enabled; + const isSandboxAllowNetworkEnabled = isSandboxEnabled && await this._terminalSandboxService.isSandboxAllowNetworkEnabled(); const allowUnsandboxedCommands = this._getAllowToRunUnsandboxedCommands(args); const explicitUnsandboxRequest = isSandboxEnabled && allowUnsandboxedCommands && args.requestUnsandboxedExecution === true; + const explicitAllowNetworkRequest = isSandboxEnabled && !isSandboxAllowNetworkEnabled && this._retryWithAllowNetworkRequests && !explicitUnsandboxRequest && args.requestAllowNetwork === true; let requiresUnsandboxConfirmation = explicitUnsandboxRequest; let requestUnsandboxedExecutionReason = explicitUnsandboxRequest ? args.requestUnsandboxedExecutionReason : undefined; + let requiresAllowNetworkConfirmation = explicitAllowNetworkRequest; + let requestAllowNetworkReason = explicitAllowNetworkRequest ? args.requestAllowNetworkReason : undefined; const missingDependencies = sandboxPrereqs.failedCheck === TerminalSandboxPrerequisiteCheck.Dependencies && sandboxPrereqs.missingDependencies?.length ? sandboxPrereqs.missingDependencies : undefined; + const sandboxRemediations = sandboxPrereqs.failedCheck === TerminalSandboxPrerequisiteCheck.Bubblewrap && sandboxPrereqs.remediations?.length + ? [...sandboxPrereqs.remediations] + : undefined; + const sandboxPrerequisiteFailure = sandboxPrereqs.failedCheck === TerminalSandboxPrerequisiteCheck.Bubblewrap && !sandboxRemediations + ? localize('runInTerminal.bubblewrap.unusable', "Bubblewrap is installed but cannot create the required sandbox namespace on this system. The command was not executed.") + : undefined; const terminalToolSessionId = generateUuid(); // Generate a custom command ID to link the command between renderer and pty host @@ -804,6 +903,29 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { }; } + if (this._shouldRejectAllowNetworkRequest(isSandboxEnabled, isSandboxAllowNetworkEnabled, args)) { + const commandToDisplay = normalizeTerminalCommandForDisplay(args.command); + return { + invocationMessage: new MarkdownString(localize('runInTerminal.allowNetwork.disabled.invocation', "Not running `{0}` because unrestricted network access in the sandbox is disabled", escapeMarkdownSyntaxTokens(buildCommandDisplayText(commandToDisplay)))), + icon: Codicon.error, + confirmationMessages: undefined, + toolSpecificData: { + kind: 'terminal', + terminalToolSessionId, + terminalCommandId, + commandLine: { + original: args.command, + forDisplay: commandToDisplay, + }, + cwd, + language, + isBackground: executionOptions.persistentSession, + requestAllowNetwork: false, + requestAllowNetworkReason: undefined, + }, + }; + } + const rewriteResult = await this._rewriteCommandLine(args.command, { cwd, shell, @@ -811,6 +933,8 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { isBackground: executionOptions.persistentSession, requestUnsandboxedExecution: allowUnsandboxedCommands ? requiresUnsandboxConfirmation : false, requestUnsandboxedExecutionReason, + requestAllowNetwork: explicitAllowNetworkRequest, + requestAllowNetworkReason, sandboxPrecheckInputs, }); const rewrittenCommand: string | undefined = rewriteResult.rewrittenCommand; @@ -818,6 +942,8 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const isSandboxWrapped = rewriteResult.isSandboxWrapped; requiresUnsandboxConfirmation = rewriteResult.requiresUnsandboxConfirmation; requestUnsandboxedExecutionReason = rewriteResult.requestUnsandboxedExecutionReason; + requiresAllowNetworkConfirmation = rewriteResult.requiresAllowNetworkConfirmation; + requestAllowNetworkReason = rewriteResult.requestAllowNetworkReason; const blockedDomains = rewriteResult.blockedDomains; const toolSpecificData: IChatTerminalToolInvocationData = { @@ -835,15 +961,19 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { isBackground: executionOptions.persistentSession, requestUnsandboxedExecution: requiresUnsandboxConfirmation, requestUnsandboxedExecutionReason, + requestAllowNetwork: requiresAllowNetworkConfirmation, + requestAllowNetworkReason, missingSandboxDependencies: missingDependencies, + sandboxRemediations, + sandboxPrerequisiteFailure, }; - let sandboxConfirmationMessageForMissingDeps: IToolConfirmationMessages | undefined = undefined; + let sandboxPrerequisiteConfirmation: 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 = { + sandboxPrerequisiteConfirmation = { title: localize('runInTerminal.missingDeps.title', "Missing Sandbox Dependencies"), message: new MarkdownString(localize( 'runInTerminal.missingDeps.message', @@ -855,6 +985,22 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { { id: 'cancel', label: localize('runInTerminal.missingDeps.cancel', "Cancel"), kind: ConfirmationOptionKind.Deny }, ], }; + } else if (sandboxRemediations) { + const customOptions = []; + if (sandboxRemediations.includes(TerminalSandboxPreCheckRemediation.InstallUbuntuAppArmorProfile)) { + customOptions.push({ id: TerminalSandboxPreCheckRemediation.InstallUbuntuAppArmorProfile, label: localize('runInTerminal.bubblewrap.repairAppArmor', "Apply AppArmor Fix"), kind: ConfirmationOptionKind.Approve }); + } + if (sandboxRemediations.includes(TerminalSandboxPreCheckRemediation.DisableUbuntuUserNamespaceRestriction)) { + customOptions.push({ id: TerminalSandboxPreCheckRemediation.DisableUbuntuUserNamespaceRestriction, label: localize('runInTerminal.bubblewrap.disableRestriction', "Disable Restriction and Retry"), kind: ConfirmationOptionKind.Approve }); + } + customOptions.push({ id: 'cancel', label: localize('runInTerminal.bubblewrap.cancel', "Cancel"), kind: ConfirmationOptionKind.Deny }); + sandboxPrerequisiteConfirmation = { + title: localize('runInTerminal.bubblewrap.title', "Repair Bubblewrap Sandbox"), + message: new MarkdownString(sandboxRemediations.includes(TerminalSandboxPreCheckRemediation.InstallUbuntuAppArmorProfile) + ? localize('runInTerminal.bubblewrap.message', "Bubblewrap is installed but cannot create the required sandbox namespace. Apply the recommended AppArmor fix, or disable Ubuntu's unprivileged user namespace restriction and retry. Disabling the restriction reduces system security.") + : localize('runInTerminal.bubblewrap.disableOnly.message', "Bubblewrap is installed but cannot create the required sandbox namespace. You may disable Ubuntu's unprivileged user namespace restriction and retry. This reduces system security.")), + customOptions, + }; } // HACK: Exit early if there's an alternative recommendation, this is a little hacky but @@ -886,6 +1032,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { terminalToolSessionId, chatSessionResource, requiresUnsandboxConfirmation, + requiresAllowNetworkConfirmation, hasSessionAutoApproval: !!chatSessionResource && this._terminalChatService.hasChatSessionAutoApproval(chatSessionResource), }; @@ -933,7 +1080,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { wouldBeAutoApproved ); const isUnsandboxedAutoApproved = isSandboxEnabled && requiresUnsandboxConfirmation === true && this._autoApproveUnsandboxedCommands; - const isSandboxAutoApproved = isSandboxEnabled && toolSpecificData.commandLine.isSandboxWrapped === true && this._allowSandboxAutoApprove; + const isSandboxAutoApproved = isSandboxEnabled && toolSpecificData.commandLine.isSandboxWrapped === true && !requiresAllowNetworkConfirmation && this._allowSandboxAutoApprove; const isFinalAutoApproved = isUnsandboxedAutoApproved || isSandboxAutoApproved || isAutoApprovedByRules || commandLineAnalyzerResults.some(e => e.forceAutoApproval); // Pass auto approve info if the command: @@ -1010,10 +1157,12 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { confirmationTitle = blockedDomains?.length ? localize('runInTerminal.unsandboxed.domain', "Run `{0}` command outside the [sandbox]({1}) to access {2}?", shellType, TERMINAL_SANDBOX_DOCUMENTATION_URL, this._formatBlockedDomainsForTitle(blockedDomains)) : localize('runInTerminal.unsandboxed', "Run `{0}` command outside the [sandbox]({1})?", shellType, TERMINAL_SANDBOX_DOCUMENTATION_URL); + } else if (requiresAllowNetworkConfirmation) { + confirmationTitle = localize('runInTerminal.allowNetwork', "Allow the sandbox to run `{0}` command with unrestricted network access.", shellType); } // If forceConfirmationReason is set, always show confirmation regardless of auto-approval - const shouldShowConfirmation = (!isFinalAutoApproved && !isSessionAutoApproved) || context.forceConfirmationReason !== undefined; + const shouldShowConfirmation = (!isFinalAutoApproved && (!isSessionAutoApproved || requiresAllowNetworkConfirmation)) || context.forceConfirmationReason !== undefined; const explanation = args.explanation || localize('runInTerminal.defaultExplanation', "No explanation provided"); const goal = args.goal || localize('runInTerminal.defaultGoal', "No goal provided"); const confirmationMessage = requiresUnsandboxConfirmation @@ -1024,7 +1173,15 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { goal, requestUnsandboxedExecutionReason || localize('runInTerminal.unsandboxed.confirmationMessage.defaultReason', "The model indicated that this command needs unsandboxed access.") )) - : new MarkdownString(localize('runInTerminal.confirmationMessage', "Explanation: {0}\n\nGoal: {1}", explanation, goal)); + : requiresAllowNetworkConfirmation + ? new MarkdownString(localize( + 'runInTerminal.allowNetwork.confirmationMessage', + "Explanation: {0}\n\nGoal: {1}\n\nReason for allowing unrestricted network access in the sandbox: {2}", + explanation, + goal, + requestAllowNetworkReason || localize('runInTerminal.allowNetwork.confirmationMessage.defaultReason', "The model indicated that this sandboxed command needs unrestricted network access.") + )) + : new MarkdownString(localize('runInTerminal.confirmationMessage', "Explanation: {0}\n\nGoal: {1}", explanation, goal)); const confirmationMessages = shouldShowConfirmation ? { title: confirmationTitle, message: confirmationMessage, @@ -1044,7 +1201,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { return { invocationMessage, icon: toolSpecificData.commandLine.isSandboxWrapped ? Codicon.terminalSecure : Codicon.terminal, - confirmationMessages: sandboxConfirmationMessageForMissingDeps ?? confirmationMessages, + confirmationMessages: sandboxPrerequisiteConfirmation ?? confirmationMessages, toolSpecificData, }; } @@ -1082,6 +1239,8 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { isBackground: boolean; requestUnsandboxedExecution: boolean; requestUnsandboxedExecutionReason?: string; + requestAllowNetwork: boolean; + requestAllowNetworkReason?: string; sandboxPrecheckInputs?: ITerminalSandboxPrecheckInputs; }): Promise<{ rewrittenCommand: string; @@ -1089,6 +1248,8 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { isSandboxWrapped: boolean; requiresUnsandboxConfirmation: boolean; requestUnsandboxedExecutionReason: string | undefined; + requiresAllowNetworkConfirmation: boolean; + requestAllowNetworkReason: string | undefined; blockedDomains: string[] | undefined; }> { let rewrittenCommand = commandLine; @@ -1096,6 +1257,8 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { let isSandboxWrapped = false; let requiresUnsandboxConfirmation = options.requestUnsandboxedExecution; let requestUnsandboxedExecutionReason = options.requestUnsandboxedExecution ? options.requestUnsandboxedExecutionReason : undefined; + let requiresAllowNetworkConfirmation = false; + let requestAllowNetworkReason = options.requestAllowNetwork ? options.requestAllowNetworkReason : undefined; let blockedDomains: string[] | undefined; for (const rewriter of this._commandLineRewriters) { @@ -1106,6 +1269,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { os: options.os, isBackground: options.isBackground, requestUnsandboxedExecution: requiresUnsandboxConfirmation, + requestAllowNetwork: options.requestAllowNetwork, sandboxPrecheckInputs: options.sandboxPrecheckInputs, }); if (rewriteResult) { @@ -1119,9 +1283,17 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { if (rewriteResult.requiresUnsandboxConfirmation) { requiresUnsandboxConfirmation = true; } + if (rewriteResult.requiresAllowNetworkConfirmation) { + requiresAllowNetworkConfirmation = true; + } if (rewriteResult.blockedDomains?.length) { blockedDomains = rewriteResult.blockedDomains; - requestUnsandboxedExecutionReason = this._getBlockedDomainReason(rewriteResult.blockedDomains, rewriteResult.deniedDomains); + const blockedDomainReason = this._getBlockedDomainReason(rewriteResult.blockedDomains, rewriteResult.deniedDomains); + if (rewriteResult.requiresAllowNetworkConfirmation) { + requestAllowNetworkReason = blockedDomainReason; + } else { + requestUnsandboxedExecutionReason = blockedDomainReason; + } } this._logService.info(`RunInTerminalTool: Command rewritten by ${rewriter.constructor.name}: ${rewriteResult.reasoning}`); } @@ -1133,6 +1305,8 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { isSandboxWrapped, requiresUnsandboxConfirmation, requestUnsandboxedExecutionReason, + requiresAllowNetworkConfirmation, + requestAllowNetworkReason: requiresAllowNetworkConfirmation ? requestAllowNetworkReason : undefined, blockedDomains, }; } @@ -1141,21 +1315,18 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { return getSandboxPrecheckInputsForToolInvocation(chatSessionResource, chatRequestId, this._chatWidgetService, this._chatService); } - private async _confirmAutomaticUnsandboxRetry(sessionResource: URI | undefined, command: string, shell: string, blockedDomains: string[] | undefined, riskAssessment: { toolId: string; parameters: unknown } | undefined, token: CancellationToken): Promise { - if (this._autoApproveUnsandboxedCommands) { + private async _confirmAutomaticSandboxRetry(retryKind: AutomaticSandboxRetryKind, sessionResource: URI | undefined, command: string, shell: string, blockedDomains: string[] | undefined, riskAssessment: { toolId: string; parameters: unknown } | undefined, token: CancellationToken): Promise { + if (retryKind === 'unsandboxed' && this._autoApproveUnsandboxedCommands) { return true; } - const chatModel = sessionResource && this._chatService.getSession(sessionResource); if (!(chatModel instanceof ChatModel)) { return false; } - // In Autopilot/Bypass Approvals modes, follow the picker if (sessionResource && isSessionAutoApproveLevel(sessionResource, this._configurationService, this._chatWidgetService, this._chatService)) { return true; } - const request = chatModel.getRequests().at(-1); if (!request) { return false; @@ -1177,13 +1348,20 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { resolve(value); }; - const part = new ChatElicitationRequestPart( - this._getAutomaticUnsandboxRetryTitle(shellType, blockedDomains), - new MarkdownString(localize( + const confirmationMessage = retryKind === 'allowNetwork' + ? new MarkdownString(localize( + 'runInTerminal.allowNetwork.autoRetry.confirmationMessage', + "`{0}`", + escapeMarkdownSyntaxTokens(buildCommandDisplayText(command)) + )) + : new MarkdownString(localize( 'runInTerminal.unsandboxed.autoRetry.confirmationMessage', "`{0}`", escapeMarkdownSyntaxTokens(buildCommandDisplayText(command)) - )), + )); + const part = new ChatElicitationRequestPart( + this._getAutomaticSandboxRetryTitle(retryKind, shellType, blockedDomains), + confirmationMessage, '', localize('allow', 'Allow'), localize('skip', 'Skip'), @@ -1209,7 +1387,12 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { }); } - private _getAutomaticUnsandboxRetryTitle(shellType: string, blockedDomains: string[] | undefined): MarkdownString { + private _getAutomaticSandboxRetryTitle(retryKind: AutomaticSandboxRetryKind, shellType: string, blockedDomains: string[] | undefined): MarkdownString { + if (retryKind === 'allowNetwork') { + return blockedDomains?.length + ? new MarkdownString(localize('runInTerminal.allowNetwork.autoRetry.domain', "Retry `{0}` command in the sandbox by allowing network access to {1}?", shellType, this._formatBlockedDomainsForTitle(blockedDomains))) + : new MarkdownString(localize('runInTerminal.allowNetwork.autoRetry', "Retry `{0}` command in the sandbox by allowing network access?", shellType)); + } return blockedDomains?.length ? new MarkdownString(localize('runInTerminal.unsandboxed.autoRetry.domain', "Run `{0}` command outside the sandbox to access {1}?", shellType, this._formatBlockedDomainsForTitle(blockedDomains))) : new MarkdownString(localize('runInTerminal.unsandboxed.autoRetry', "Run `{0}` command outside the sandbox?", shellType)); @@ -1329,7 +1512,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { return store; } - private _acceptAutomaticUnsandboxRetryToolInvocationUpdate(sessionResource: URI | undefined, toolCallId: string, toolSpecificData: IChatTerminalToolInvocationData, isComplete: boolean, toolResultMessage?: string | IMarkdownString): void { + private _acceptAutomaticSandboxRetryToolInvocationUpdate(retryKind: AutomaticSandboxRetryKind, sessionResource: URI | undefined, toolCallId: string, toolSpecificData: IChatTerminalToolInvocationData, isComplete: boolean, toolResultMessage?: string | IMarkdownString): void { const chatModel = sessionResource && this._chatService.getSession(sessionResource); if (!(chatModel instanceof ChatModel)) { return; @@ -1346,12 +1529,95 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { toolCallId, toolName: localize('runInTerminalTool.displayName', 'Run in Terminal'), isComplete, - invocationMessage: new MarkdownString(localize('runInTerminal.unsandboxed.autoRetry.invocation', "Running `{0}` outside the sandbox", escapeMarkdownSyntaxTokens(displayCommand))), + invocationMessage: retryKind === 'allowNetwork' + ? new MarkdownString(localize('runInTerminal.allowNetwork.autoRetry.invocation', "Running `{0}` in the sandbox with unrestricted network access", escapeMarkdownSyntaxTokens(displayCommand))) + : new MarkdownString(localize('runInTerminal.unsandboxed.autoRetry.invocation', "Running `{0}` outside the sandbox", escapeMarkdownSyntaxTokens(displayCommand))), pastTenseMessage: toolResultMessage, toolSpecificData, }; chatModel.acceptResponseProgress(request, progress); } + + private async _runAutomaticSandboxRetry(options: { + retryKind: AutomaticSandboxRetryKind; + invocation: IToolInvocation; + countTokens: CountTokensCallback; + progress: ToolProgress; + token: CancellationToken; + args: IRunInTerminalInputParams; + toolSpecificData: IChatTerminalToolInvocationData; + command: string; + allowUnsandboxedCommands: boolean; + isBackground: boolean; + retryReason: string; + }): Promise { + const requestAllowNetwork = options.retryKind === 'allowNetwork'; + const requestUnsandboxedExecution = options.retryKind === 'unsandboxed' && options.allowUnsandboxedCommands; + const [os, shell] = await Promise.all([ + this._osBackend, + this._profileFetcher.getCopilotShell(), + ]); + const retryRewriteResult = await this._rewriteCommandLine(options.args.command, { + cwd: options.toolSpecificData.cwd ? URI.revive(options.toolSpecificData.cwd) : undefined, + shell, + os, + isBackground: options.isBackground, + requestUnsandboxedExecution, + requestUnsandboxedExecutionReason: requestUnsandboxedExecution ? options.retryReason : undefined, + requestAllowNetwork, + requestAllowNetworkReason: requestAllowNetwork ? options.retryReason : undefined, + }); + const rewrittenRetryReason = (requestAllowNetwork ? retryRewriteResult.requestAllowNetworkReason : retryRewriteResult.requestUnsandboxedExecutionReason) ?? options.retryReason; + const retryParameters: IRunInTerminalInputParams = { + ...options.args, + command: options.args.command, + allowToRunUnsandboxedCommands: options.allowUnsandboxedCommands, + requestUnsandboxedExecution, + requestUnsandboxedExecutionReason: requestUnsandboxedExecution ? rewrittenRetryReason : undefined, + requestAllowNetwork, + requestAllowNetworkReason: requestAllowNetwork ? rewrittenRetryReason : undefined, + }; + const retryRiskAssessment = { + toolId: TerminalToolId.RunInTerminal, + parameters: { + ...retryParameters, + command: retryRewriteResult.rewrittenCommand, + }, + }; + const retryConfirmationCommand = options.toolSpecificData.presentationOverrides?.commandLine ?? options.command; + const shouldRetry = await this._confirmAutomaticSandboxRetry(options.retryKind, options.invocation.context?.sessionResource, retryConfirmationCommand, shell, retryRewriteResult.blockedDomains, retryRiskAssessment, options.token); + if (!shouldRetry) { + return undefined; + } + + const retryToolSpecificData: IChatTerminalToolInvocationData = { + ...options.toolSpecificData, + terminalCommandId: `tool-${generateUuid()}`, + commandLine: { + original: options.args.command, + toolEdited: retryRewriteResult.rewrittenCommand === options.args.command ? undefined : retryRewriteResult.rewrittenCommand, + forDisplay: retryRewriteResult.forDisplayCommand ?? normalizeTerminalCommandForDisplay(retryRewriteResult.rewrittenCommand ?? options.args.command), + isSandboxWrapped: retryRewriteResult.isSandboxWrapped, + }, + requestUnsandboxedExecution: requestUnsandboxedExecution || (requestAllowNetwork ? false : undefined), + requestUnsandboxedExecutionReason: requestUnsandboxedExecution ? rewrittenRetryReason : undefined, + requestAllowNetwork: requestAllowNetwork || undefined, + requestAllowNetworkReason: requestAllowNetwork ? rewrittenRetryReason : undefined, + terminalCommandUri: undefined, + terminalCommandOutput: undefined, + terminalTheme: undefined, + terminalCommandState: undefined, + didContinueInBackground: undefined, + }; + const retryToolCallId = `automatic-${options.retryKind === 'allowNetwork' ? 'allow-network' : 'unsandbox'}-retry-${generateUuid()}`; + this._acceptAutomaticSandboxRetryToolInvocationUpdate(options.retryKind, options.invocation.context?.sessionResource, retryToolCallId, retryToolSpecificData, false); + + return await this.invoke({ + ...options.invocation, + parameters: retryParameters, + toolSpecificData: retryToolSpecificData, + }, options.countTokens, options.progress, options.token); + } async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, token: CancellationToken): Promise { const toolSpecificData = invocation.toolSpecificData as IChatTerminalToolInvocationData | undefined; if (!toolSpecificData) { @@ -1391,20 +1657,44 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { }; } + const sandboxPrerequisiteTerminalOptions = { + createTerminal: async () => this._terminalService.createTerminal({}), + focusTerminal: async (terminal: { focus(): void }) => { + this._terminalService.setActiveInstance(terminal as ITerminalInstance); + await this._terminalService.revealTerminal(terminal as ITerminalInstance, true); + terminal.focus(); + }, + }; + + if (toolSpecificData.sandboxPrerequisiteFailure) { + return { + content: [{ kind: 'text', value: toolSpecificData.sandboxPrerequisiteFailure }], + }; + } + const isSandboxAllowNetworkEnabled = isSandboxEnabled && await this._terminalSandboxService.isSandboxAllowNetworkEnabled(); + if (this._shouldRejectAllowNetworkRequest(isSandboxEnabled, isSandboxAllowNetworkEnabled, args)) { + const message = this._getAllowNetworkRequestDisabledMessage(); + return { + toolResultError: message, + toolResultDetails: { + input: args.command, + output: [{ type: 'embed', isText: true, value: message }], + isError: true, + }, + content: [{ + kind: 'text', + value: message, + }], + }; + } + // Handle missing sandbox dependencies install flow. // The user was shown a confirmation window in prepareToolInvocation. if (toolSpecificData.missingSandboxDependencies?.length) { if (invocation.selectedCustomButton === 'install') { // 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(); - }, - }); + const { exitCode } = await this._terminalSandboxService.installMissingSandboxDependencies(toolSpecificData.missingSandboxDependencies, sessionResource, token, sandboxPrerequisiteTerminalOptions); if (exitCode !== undefined && exitCode !== 0) { return { content: [{ @@ -1428,7 +1718,20 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { }], }; } - // Installation succeeded — fall through to execute the original command + const refreshedPrereqs = await this._terminalSandboxService.checkForSandboxingPrereqs(true, sandboxPrecheckInputs); + if (refreshedPrereqs.failedCheck !== undefined) { + return { + content: [{ + kind: 'text', + value: refreshedPrereqs.failedCheck === TerminalSandboxPrerequisiteCheck.Bubblewrap && refreshedPrereqs.remediations?.length + ? localize('runInTerminal.missingDeps.bubblewrapFailed', "Sandbox dependencies were installed, but bubblewrap cannot create the required sandbox namespace. Run the command again to choose an available repair option.") + : refreshedPrereqs.failedCheck === TerminalSandboxPrerequisiteCheck.Bubblewrap + ? localize('runInTerminal.missingDeps.bubblewrapFailedNoRepair', "Sandbox dependencies were installed, but bubblewrap cannot create the required sandbox namespace on this system. The command was not executed.") + : localize('runInTerminal.missingDeps.recheckFailed', "Sandbox prerequisites are still not satisfied after installation. The command was not executed."), + }], + }; + } + // Installation and verification 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 @@ -1445,6 +1748,36 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } } + if (toolSpecificData.sandboxRemediations?.length) { + const selectedRemediation = invocation.selectedCustomButton as TerminalSandboxPreCheckRemediation | undefined; + if (!selectedRemediation || !toolSpecificData.sandboxRemediations.includes(selectedRemediation)) { + return { + content: [{ kind: 'text', value: localize('runInTerminal.bubblewrap.cancelled', "Bubblewrap sandbox repair was cancelled by the user.") }], + }; + } + const { exitCode } = await this._terminalSandboxService.runSandboxRemediation(selectedRemediation, invocation.context.sessionResource, token, sandboxPrerequisiteTerminalOptions); + if (exitCode !== 0) { + return { + content: [{ + kind: 'text', value: exitCode === undefined + ? localize('runInTerminal.bubblewrap.repairUnknown', "Could not determine whether the bubblewrap repair succeeded. The command was not executed.") + : localize('runInTerminal.bubblewrap.repairFailed', "Bubblewrap repair failed (exit code {0}). The command was not executed.", exitCode) + }], + }; + } + const refreshedPrereqs = await this._terminalSandboxService.checkForSandboxingPrereqs(true, sandboxPrecheckInputs); + if (refreshedPrereqs.failedCheck !== undefined) { + return { + content: [{ + kind: 'text', value: selectedRemediation === TerminalSandboxPreCheckRemediation.InstallUbuntuAppArmorProfile + ? localize('runInTerminal.bubblewrap.profileDidNotResolve', "The AppArmor repair completed, but bubblewrap still cannot create the required sandbox namespace. Run the command again and choose Disable Restriction and Retry only if you accept the reduced system security.") + : localize('runInTerminal.bubblewrap.stillUnavailable', "Bubblewrap still cannot create the required sandbox namespace after remediation. The command was not executed.") + }], + }; + } + this._logService.info('RunInTerminalTool: Bubblewrap remediation succeeded, proceeding with command execution'); + } + const executionOptions = this._resolveExecutionOptions(args); this._logService.debug(`RunInTerminalTool: Invoking with options ${JSON.stringify(args)}`); let toolResultMessage: string | IMarkdownString | undefined; @@ -1495,6 +1828,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { let error: string | undefined; const automaticUnsandboxRetryReason = localize('runInTerminal.unsandboxed.autoRetry.reason', 'The sandboxed execution output indicated the sandbox blocked the command.'); + const automaticAllowNetworkRetryReason = localize('runInTerminal.allowNetwork.autoRetry.reason', 'The sandboxed execution output indicated the sandbox blocked required network access.'); const isNewSession = !executionOptions.persistentSession && !this._sessionTerminalAssociations.has(chatSessionResource); const timingStart = Date.now(); @@ -1970,61 +2304,38 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { exitCode, output: terminalResult, }); + const shouldAutoRetryAllowNetwork = shouldAutomaticallyRetryAllowNetworkInSandboxed({ + retryWithAllowNetworkRequests: isSandboxEnabled && !isSandboxAllowNetworkEnabled && this._retryWithAllowNetworkRequests, + didSandboxWrapCommand, + requestUnsandboxedExecution: args.requestUnsandboxedExecution === true, + requestAllowNetwork: args.requestAllowNetwork === true, + isPersistentSession: executionOptions.persistentSession, + isBackgroundExecution: isBackgroundExecution || didInputNeeded, + didTimeout, + exitCode, + output: terminalResult, + }); - if (shouldAutoRetryUnsandboxed) { - const requestUnsandboxedExecution = allowUnsandboxedCommands; - const [os, shell] = await Promise.all([ - this._osBackend, - this._profileFetcher.getCopilotShell(), - ]); - const retryReason = automaticUnsandboxRetryReason; - const retryRewriteResult = await this._rewriteCommandLine(args.command, { - cwd: toolSpecificData.cwd ? URI.revive(toolSpecificData.cwd) : undefined, - shell, - os, + const automaticSandboxRetry = shouldAutoRetryAllowNetwork + ? { retryKind: 'allowNetwork' as const, retryReason: automaticAllowNetworkRetryReason } + : shouldAutoRetryUnsandboxed + ? { retryKind: 'unsandboxed' as const, retryReason: automaticUnsandboxRetryReason } + : undefined; + if (automaticSandboxRetry) { + const retryResult = await this._runAutomaticSandboxRetry({ + ...automaticSandboxRetry, + invocation, + countTokens: _countTokens, + progress: _progress, + token, + args, + toolSpecificData, + command, + allowUnsandboxedCommands, isBackground: executionOptions.persistentSession, - requestUnsandboxedExecution, - requestUnsandboxedExecutionReason: retryReason, }); - const rewrittenRetryReason = retryRewriteResult.requestUnsandboxedExecutionReason ?? retryReason; - const retryConfirmationCommand = toolSpecificData.presentationOverrides?.commandLine ?? command; - const retryRiskAssessment = { - toolId: TerminalToolId.RunInTerminal, - parameters: { ...args, command: retryRewriteResult.rewrittenCommand, allowToRunUnsandboxedCommands: allowUnsandboxedCommands, requestUnsandboxedExecution }, - }; - const shouldRetry = await this._confirmAutomaticUnsandboxRetry(invocation.context.sessionResource, retryConfirmationCommand, shell, retryRewriteResult.blockedDomains, retryRiskAssessment, token); - if (shouldRetry) { - const retryToolSpecificData: IChatTerminalToolInvocationData = { - ...toolSpecificData, - terminalCommandId: `tool-${generateUuid()}`, - commandLine: { - original: args.command, - toolEdited: retryRewriteResult.rewrittenCommand === args.command ? undefined : retryRewriteResult.rewrittenCommand, - forDisplay: retryRewriteResult.forDisplayCommand ?? normalizeTerminalCommandForDisplay(retryRewriteResult.rewrittenCommand ?? args.command), - isSandboxWrapped: retryRewriteResult.isSandboxWrapped, - }, - requestUnsandboxedExecution, - requestUnsandboxedExecutionReason: rewrittenRetryReason, - terminalCommandUri: undefined, - terminalCommandOutput: undefined, - terminalTheme: undefined, - terminalCommandState: undefined, - didContinueInBackground: undefined, - }; - const retryToolCallId = `automatic-unsandbox-retry-${generateUuid()}`; - this._acceptAutomaticUnsandboxRetryToolInvocationUpdate(invocation.context.sessionResource, retryToolCallId, retryToolSpecificData, false); - - return await this.invoke({ - ...invocation, - parameters: { - ...args, - command: args.command, - allowToRunUnsandboxedCommands: allowUnsandboxedCommands, - requestUnsandboxedExecution, - requestUnsandboxedExecutionReason: rewrittenRetryReason, - }, - toolSpecificData: retryToolSpecificData, - }, _countTokens, _progress, token); + if (retryResult) { + return retryResult; } } @@ -2694,7 +3005,9 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { disposeNotification(); const exitCode = command.exitCode; - const exitCodeText = exitCode !== undefined ? ` with exit code ${exitCode}` : ''; + // A successful completion is already conveyed by "command completed"; + // only surface an exit code in chat when it provides failure context. + const exitCodeText = exitCode !== undefined && exitCode !== 0 ? ` with exit code ${exitCode}` : ''; const currentOutput = execution.getOutput(); // Only dispose if the terminal is still hidden from the user. Once the // user reveals it (via the "Show" link), it joins `foregroundInstances` @@ -2770,7 +3083,8 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } const currentOutput = executionForDisposal?.getOutput() ?? ''; const exitCode = terminalInstance.exitCode; - const exitCodeText = exitCode !== undefined ? ` with exit code ${exitCode}` : ''; + // Avoid reporting a successful exit code as diagnostic information in chat. + const exitCodeText = exitCode !== undefined && exitCode !== 0 ? ` with exit code ${exitCode}` : ''; disposeNotification(); const message = `[Terminal ${termId} notification: terminal exited${exitCodeText}. The terminal process ended before the command could complete normally; further commands cannot be sent to this terminal ID.]\nTerminal output:\n${currentOutput}`; this._logService.debug(`RunInTerminalTool: Background terminal ${termId} disposed${exitCodeText}, notifying chat session`); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts index 8ca6f476cc2ef..2158da2d69783 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts @@ -5,14 +5,17 @@ import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { OperatingSystem } from '../../../../../../base/common/platform.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { ITerminalSandboxService } from '../../common/terminalSandboxService.js'; import type { IOutputAnalyzer, IOutputAnalyzerOptions } from './outputAnalyzer.js'; import { TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js'; import { AgentNetworkDomainSettingId } from '../../../../../../platform/networkFilter/common/settings.js'; +import { AgentSandboxSettingId } from '../../../../../../platform/sandbox/common/settings.js'; export class SandboxOutputAnalyzer extends Disposable implements IOutputAnalyzer { constructor( @ITerminalSandboxService private readonly _sandboxService: ITerminalSandboxService, + @IConfigurationService private readonly _configurationService: IConfigurationService, ) { super(); } @@ -46,9 +49,13 @@ export class SandboxOutputAnalyzer extends Disposable implements IOutputAnalyzer 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:'; + const retryWithAllowNetworkRequests = this._configurationService.getValue(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests) === true; + const networkRecovery = retryWithAllowNetworkRequests + ? '- If you determine from the output that the failure was caused by blocked network access, immediately retry the command with requestAllowNetwork=true and provide requestAllowNetworkReason. This keeps the command sandboxed with unrestricted network access and automatically shows a confirmation prompt to the user.\n- If it is not a network restriction, or the command still fails after retrying with requestAllowNetwork=true, immediately retry the command with requestUnsandboxedExecution=true and provide requestUnsandboxedExecutionReason. Do NOT ask the user - setting this flag automatically shows a confirmation prompt to the user.' + : '- Sandboxed execution with unrestricted network access is disabled by chat.agent.sandbox.retryWithAllowNetworkRequests. Do not set requestAllowNetwork=true.\n- If the command cannot be made to work by updating sandbox rules, immediately retry it with requestUnsandboxedExecution=true and provide requestUnsandboxedExecutionReason. Do NOT ask the user - setting this flag automatically shows a confirmation prompt to the user.'; 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 ${AgentNetworkDomainSettingId.AllowedNetworkDomains}. -- Otherwise, immediately retry the command with requestUnsandboxedExecution=true. Do NOT ask the user — setting this flag automatically shows a confirmation prompt to the user. +${networkRecovery} Here is the output of the command:\n`; } @@ -74,3 +81,12 @@ 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); } + +/** + * Checks whether output clearly suggests a network failure. This is used only + * to prevent automatic unsandboxing; retry selection is left to the model. + */ +export function outputLooksSandboxNetworkBlocked(output: string): boolean { + const normalized = output.replace(/\n/g, ' '); + return /Could not resolve host|Temporary failure in name resolution|Name or service not known|EAI_AGAIN|ENETUNREACH|Network is unreachable|network (?:access )?(?:blocked|disabled)|(?:connect|socket).*(?:Operation not permitted|Permission denied)|(?:Operation not permitted|Permission denied).*(?:connect|socket)/i.test(normalized); +} diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index 1aed18a25a1e3..fbfbda442659f 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -596,6 +596,13 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary { - return this._engine.wrapCommand(command, requestUnsandboxedExecution, shell, cwd, commandDetails); + wrapCommand(command: string, requestUnsandboxedExecution?: boolean, shell?: string, cwd?: URI, commandDetails?: readonly ITerminalSandboxCommand[], requestAllowNetwork?: boolean): Promise { + return this._engine.wrapCommand(command, requestUnsandboxedExecution, shell, cwd, commandDetails, requestAllowNetwork); } checkForSandboxingPrereqs(forceRefresh: boolean = false, precheckInputs?: ITerminalSandboxPrecheckInputs): Promise { @@ -283,7 +283,25 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb async installMissingSandboxDependencies(missingDependencies: string[], sessionResource: URI | undefined, token: CancellationToken, options: ISandboxDependencyInstallOptions): Promise { const depsList = missingDependencies.join(' '); - const installCommand = `sudo apt install -y ${depsList}`; + return this._runSandboxPrerequisiteCommand(`sudo apt install -y ${depsList}`, sessionResource, token, options); + } + + async runSandboxRemediation(remediation: TerminalSandboxPreCheckRemediation, sessionResource: URI | undefined, token: CancellationToken, options: ISandboxDependencyInstallOptions): Promise { + let command: string; + switch (remediation) { + case TerminalSandboxPreCheckRemediation.InstallUbuntuAppArmorProfile: + command = 'sudo apt update && sudo apt install -y apparmor-profiles apparmor-utils && sudo install -m 0644 /usr/share/apparmor/extra-profiles/bwrap-userns-restrict /etc/apparmor.d/bwrap-userns-restrict && sudo apparmor_parser -r /etc/apparmor.d/bwrap-userns-restrict'; + break; + case TerminalSandboxPreCheckRemediation.DisableUbuntuUserNamespaceRestriction: + command = 'sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0'; + break; + default: + throw new Error('Unsupported sandbox remediation'); + } + return this._runSandboxPrerequisiteCommand(command, sessionResource, token, options); + } + + private async _runSandboxPrerequisiteCommand(command: string, sessionResource: URI | undefined, token: CancellationToken, options: ISandboxDependencyInstallOptions): Promise { const instance = await options.createTerminal(); // Wait for the install command to finish so the chat can proceed automatically. @@ -320,7 +338,7 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb // Handle cancellation store.add(token.onCancellationRequested(() => resolveOnce(undefined))); - // Safety timeout — 5 minutes should be more than enough for apt install + // Safety timeout — 5 minutes should be enough for package or system-policy remediation. const safetyTimeout = timeout(5 * 60 * 1000); store.add({ dispose: () => safetyTimeout.cancel() }); safetyTimeout.then(() => resolveOnce(undefined)); @@ -338,7 +356,7 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb // 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); + await instance.sendText(command, true); installCommandSent = true; return { exitCode: await completionPromise }; @@ -363,7 +381,7 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb 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." + "Applying sandbox prerequisites may prompt for your sudo password. Select Focus Terminal to type it in the terminal." )), '', localize('runInTerminal.missingDeps.focusTerminal', 'Focus Terminal'), @@ -387,4 +405,6 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb })); return store; } + } + diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/commandLineSandboxAnalyzer.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/commandLineSandboxAnalyzer.test.ts index e0cb565d21ee2..109542f08603a 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/commandLineSandboxAnalyzer.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/commandLineSandboxAnalyzer.test.ts @@ -90,6 +90,15 @@ suite('CommandLineSandboxAnalyzer', () => { strictEqual(result.forceAutoApproval, false); }); + test('should not force auto approval when unrestricted sandbox network confirmation is required', async () => { + setConfig(TerminalChatAgentToolsSettingId.EnableAutoApprove, true); + + const result = await analyzer.analyze(createOptions({ requiresAllowNetworkConfirmation: true })); + + strictEqual(result.isAutoApproveAllowed, true); + strictEqual(result.forceAutoApproval, false); + }); + test('should set auto approval allowed from setting when sandbox is disabled', async () => { sandboxEnabled = false; setConfig(TerminalChatAgentToolsSettingId.EnableAutoApprove, false); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sandboxOutputAnalyzer.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sandboxOutputAnalyzer.test.ts index 2349129f7f5c4..487be5fb4322c 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sandboxOutputAnalyzer.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sandboxOutputAnalyzer.test.ts @@ -3,9 +3,61 @@ * 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 { ok, strictEqual } from 'assert'; +import { OperatingSystem } from '../../../../../../base/common/platform.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import type { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { AgentSandboxSettingId } from '../../../../../../platform/sandbox/common/settings.js'; +import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { outputLooksSandboxBlocked, outputLooksSandboxNetworkBlocked, SandboxOutputAnalyzer } from '../../browser/tools/sandboxOutputAnalyzer.js'; +import { ITerminalSandboxService } from '../../common/terminalSandboxService.js'; + +suite('SandboxOutputAnalyzer', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let analyzer: SandboxOutputAnalyzer; + let configurationService: TestConfigurationService; + + setup(() => { + const instantiationService: TestInstantiationService = workbenchInstantiationService(undefined, store); + configurationService = new TestConfigurationService(); + configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, true); + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(ITerminalSandboxService, { + _serviceBrand: undefined, + getOS: async () => OperatingSystem.Linux, + } as unknown as ITerminalSandboxService); + analyzer = store.add(instantiationService.createInstance(SandboxOutputAnalyzer)); + }); + + test('leaves network retry selection to the model', async () => { + const guidance = await analyzer.analyze({ + exitCode: 1, + exitResult: '/bin/bash: /tmp/test.txt: Operation not permitted', + commandLine: 'echo test > /tmp/test.txt', + isSandboxWrapped: true, + }); + + ok(guidance?.includes('If you determine from the output that the failure was caused by blocked network access')); + ok(guidance?.includes('If it is not a network restriction, or the command still fails after retrying with requestAllowNetwork=true')); + }); + + test('does not recommend allow-network requests when per-command network access is disabled', async () => { + configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, false); + + const guidance = await analyzer.analyze({ + exitCode: 1, + exitResult: 'connect: Operation not permitted', + commandLine: 'curl https://example.com', + isSandboxWrapped: true, + }); + + ok(guidance?.includes('chat.agent.sandbox.retryWithAllowNetworkRequests')); + ok(guidance?.includes('Do not set requestAllowNetwork=true')); + }); +}); suite('outputLooksSandboxBlocked', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -39,3 +91,31 @@ suite('outputLooksSandboxBlocked', () => { }); } }); + +suite('outputLooksSandboxNetworkBlocked', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const positives: [string, string][] = [ + ['curl resolution failure', 'curl: (6) Could not resolve host: example.com'], + ['dns unavailable', 'getaddrinfo EAI_AGAIN registry.npmjs.org'], + ['socket permission failure', 'connect: Operation not permitted'], + ['network unreachable', 'connect: Network is unreachable'], + ]; + + for (const [label, output] of positives) { + test(`detects: ${label}`, () => { + strictEqual(outputLooksSandboxNetworkBlocked(output), true); + }); + } + + const negatives: [string, string][] = [ + ['filesystem permission failure', '/bin/bash: /tmp/test.txt: Operation not permitted'], + ['application error', 'Error: invalid configuration'], + ]; + + for (const [label, output] of negatives) { + test(`ignores: ${label}`, () => { + strictEqual(outputLooksSandboxNetworkBlocked(output), false); + }); + } +}); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts index 8638a2685d243..e6948b4388338 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts @@ -4,11 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { deepStrictEqual, strictEqual, ok } from 'assert'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; 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 { TerminalSandboxPrerequisiteCheck, TerminalSandboxService } from '../../common/terminalSandboxService.js'; +import { TerminalSandboxPrerequisiteCheck, TerminalSandboxPreCheckRemediation, TerminalSandboxService, type ISandboxDependencyInstallTerminal } from '../../common/terminalSandboxService.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { IEnvironmentService } from '../../../../../../platform/environment/common/environment.js'; @@ -160,6 +161,7 @@ suite('TerminalSandboxService - network domains', () => { callCount = 0; status: ISandboxDependencyStatus = { bubblewrapInstalled: true, + bubblewrapUsable: true, socatInstalled: true, }; filesystemPolicy: IWindowsMxcFilesystemPolicy = { @@ -258,6 +260,7 @@ suite('TerminalSandboxService - network domains', () => { // Setup default configuration configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.On); configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands, true); + configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, true); configurationService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, []); configurationService.setUserConfiguration(AgentNetworkDomainSettingId.DeniedNetworkDomains, []); @@ -301,6 +304,7 @@ suite('TerminalSandboxService - network domains', () => { test('should report dependency prereq failures', async () => { sandboxHelperService.status = { bubblewrapInstalled: false, + bubblewrapUsable: false, socatInstalled: true, }; @@ -314,6 +318,58 @@ suite('TerminalSandboxService - network domains', () => { ok(result.sandboxConfigPath, 'Sandbox config path should still be returned when config creation succeeds'); }); + test('should report repair actions when Ubuntu AppArmor remediation is supported', async () => { + sandboxHelperService.status = { + bubblewrapInstalled: true, + bubblewrapUsable: false, + bubblewrapError: 'No permissions to create namespace', + supportsUbuntuAppArmorRemediation: true, + socatInstalled: true, + }; + + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const result = await sandboxService.checkForSandboxingPrereqs(); + + strictEqual(result.failedCheck, TerminalSandboxPrerequisiteCheck.Bubblewrap); + deepStrictEqual(result.remediations, [TerminalSandboxPreCheckRemediation.InstallUbuntuAppArmorProfile, TerminalSandboxPreCheckRemediation.DisableUbuntuUserNamespaceRestriction]); + strictEqual(result.detail, 'No permissions to create namespace'); + }); + + test('should run approved Ubuntu bubblewrap remediation commands', async () => { + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const runAndCapture = async (remediation: TerminalSandboxPreCheckRemediation): Promise => { + let sentCommand: string | undefined; + const commandFinishedEmitter = store.add(new Emitter<{ exitCode: number | undefined }>()); + const terminal: ISandboxDependencyInstallTerminal = { + sendText: async command => { + sentCommand = command; + commandFinishedEmitter.fire({ exitCode: 0 }); + }, + focus: () => { }, + capabilities: { + get: () => ({ onCommandFinished: commandFinishedEmitter.event }), + onDidAddCapability: Event.None, + }, + onDidInputData: Event.None, + onDisposed: Event.None, + }; + const result = await sandboxService.runSandboxRemediation(remediation, undefined, CancellationToken.None, { + createTerminal: async () => terminal, + focusTerminal: async () => { }, + }); + strictEqual(result.exitCode, 0); + return sentCommand; + }; + + deepStrictEqual([ + await runAndCapture(TerminalSandboxPreCheckRemediation.InstallUbuntuAppArmorProfile), + await runAndCapture(TerminalSandboxPreCheckRemediation.DisableUbuntuUserNamespaceRestriction), + ], [ + 'sudo apt update && sudo apt install -y apparmor-profiles apparmor-utils && sudo install -m 0644 /usr/share/apparmor/extra-profiles/bwrap-userns-restrict /etc/apparmor.d/bwrap-userns-restrict && sudo apparmor_parser -r /etc/apparmor.d/bwrap-userns-restrict', + 'sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0', + ]); + }); + test('should report successful sandbox prereq checks', async () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); const result = await sandboxService.checkForSandboxingPrereqs(); @@ -600,7 +656,7 @@ suite('TerminalSandboxService - network domains', () => { ok(!config.filesystem.allowRead.includes('/app/node_modules/@vscode/ripgrep'), 'Sandbox config should not redundantly include app root child paths'); }); - test('should reallow reads from workspace storage', async () => { + test('should allow reads and writes from workspace storage', async () => { remoteAgentService.remoteEnvironment = { ...remoteAgentService.remoteEnvironment!, workspaceStorageHome: URI.file('/home/user/.vscode-server/data/User/workspaceStorage') @@ -924,6 +980,7 @@ suite('TerminalSandboxService - network domains', () => { }); test('should switch to unsandboxed execution when a domain is not allowlisted', async () => { + configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, false); const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); @@ -935,8 +992,36 @@ suite('TerminalSandboxService - network domains', () => { strictEqual(wrapResult.command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'bash' -c 'curl https://example.com'`); }); + test('should request network-enabled sandbox execution for a non-allowlisted domain when enabled', async () => { + configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, true); + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + await sandboxService.getSandboxConfigPath(); + + const wrapResult = await sandboxService.wrapCommand('curl https://example.com', false, 'bash'); + + strictEqual(wrapResult.isSandboxWrapped, true, 'Blocked domains should stay sandboxed when network requests are enabled'); + strictEqual(wrapResult.requiresAllowNetworkConfirmation, true, 'Blocked domains should require network confirmation'); + strictEqual(wrapResult.requiresUnsandboxConfirmation, undefined, 'Blocked domains should not request unsandbox confirmation when a safer network request is available'); + deepStrictEqual(wrapResult.blockedDomains, ['example.com']); + ok(wrapResult.command.includes('--settings'), 'Command should remain wrapped with the sandbox runtime'); + }); + + test('should request network-enabled sandbox execution even when unsandboxed commands are disabled', async () => { + configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands, false); + configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, true); + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + await sandboxService.getSandboxConfigPath(); + + const wrapResult = await sandboxService.wrapCommand('curl https://example.com', false, 'bash'); + + strictEqual(wrapResult.isSandboxWrapped, true, 'Network access should not require leaving the sandbox'); + strictEqual(wrapResult.requiresAllowNetworkConfirmation, true, 'Network access should be confirmable independently from unsandboxed execution'); + deepStrictEqual(wrapResult.blockedDomains, ['example.com']); + }); + test('should keep blocked-domain commands sandboxed when unsandboxed commands are disabled', async () => { configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands, false); + configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, false); const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); @@ -970,6 +1055,7 @@ suite('TerminalSandboxService - network domains', () => { }); test('should give denied domains precedence over allowlisted domains', async () => { + configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, false); configurationService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, ['*.github.com']); configurationService.setUserConfiguration(AgentNetworkDomainSettingId.DeniedNetworkDomains, ['api.github.com']); const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); @@ -982,6 +1068,21 @@ suite('TerminalSandboxService - network domains', () => { deepStrictEqual(wrapResult.deniedDomains, ['api.github.com']); }); + test('should allow confirmed sandboxed network override for explicitly denied domains when enabled', async () => { + configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, true); + configurationService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, ['*.github.com']); + configurationService.setUserConfiguration(AgentNetworkDomainSettingId.DeniedNetworkDomains, ['api.github.com']); + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + await sandboxService.getSandboxConfigPath(); + + const wrapResult = await sandboxService.wrapCommand('curl https://api.github.com/repos/microsoft/vscode', false, 'bash'); + + strictEqual(wrapResult.isSandboxWrapped, true, 'Denied domains should remain filesystem sandboxed when network requests are enabled'); + strictEqual(wrapResult.requiresAllowNetworkConfirmation, true, 'Explicitly denied domains should require network confirmation'); + deepStrictEqual(wrapResult.blockedDomains, ['api.github.com']); + deepStrictEqual(wrapResult.deniedDomains, ['api.github.com']); + }); + test('should skip domain checks when configured to allow network', async () => { configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.AllowNetwork); configurationService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, ['example.com']); @@ -1053,11 +1154,13 @@ suite('TerminalSandboxService - network domains', () => { await sandboxService.getSandboxConfigPath(); const testComResult = await sandboxService.wrapCommand('curl test.com', false, 'bash'); - strictEqual(testComResult.isSandboxWrapped, false, 'Well-known bare domain suffixes should trigger domain checks'); + strictEqual(testComResult.isSandboxWrapped, true, 'Well-known bare domain suffixes should keep the command sandboxed pending network confirmation'); + strictEqual(testComResult.requiresAllowNetworkConfirmation, true, 'Well-known bare domain suffixes should trigger allow-network confirmation'); deepStrictEqual(testComResult.blockedDomains, ['test.com']); const testOrgComResult = await sandboxService.wrapCommand('curl test.org.com', false, 'bash'); - strictEqual(testOrgComResult.isSandboxWrapped, false, 'Well-known bare domain suffixes should trigger domain checks for multi-label hosts'); + strictEqual(testOrgComResult.isSandboxWrapped, true, 'Well-known bare domain suffixes should keep multi-label hosts sandboxed pending network confirmation'); + strictEqual(testOrgComResult.requiresAllowNetworkConfirmation, true, 'Well-known bare domain suffixes should trigger allow-network confirmation for multi-label hosts'); deepStrictEqual(testOrgComResult.blockedDomains, ['test.org.com']); }); @@ -1067,7 +1170,8 @@ suite('TerminalSandboxService - network domains', () => { const wrapResult = await sandboxService.wrapCommand('curl https://example.zip/path', false, 'bash'); - strictEqual(wrapResult.isSandboxWrapped, false, 'URL authorities should still trigger blocked-domain prompts even when their suffix looks like a file extension'); + strictEqual(wrapResult.isSandboxWrapped, true, 'URL authorities should stay sandboxed pending network confirmation even when their suffix looks like a file extension'); + strictEqual(wrapResult.requiresAllowNetworkConfirmation, true, 'URL authorities should still trigger allow-network prompts even when their suffix looks like a file extension'); deepStrictEqual(wrapResult.blockedDomains, ['example.zip']); }); @@ -1077,7 +1181,8 @@ suite('TerminalSandboxService - network domains', () => { const wrapResult = await sandboxService.wrapCommand('curl https://example.bar/path', false, 'bash'); - strictEqual(wrapResult.isSandboxWrapped, false, 'URL authorities should not require a well-known bare-host suffix'); + strictEqual(wrapResult.isSandboxWrapped, true, 'URL authorities should stay sandboxed pending network confirmation without requiring a well-known bare-host suffix'); + strictEqual(wrapResult.requiresAllowNetworkConfirmation, true, 'URL authorities should trigger allow-network prompts without requiring a well-known bare-host suffix'); deepStrictEqual(wrapResult.blockedDomains, ['example.bar']); }); @@ -1087,7 +1192,8 @@ suite('TerminalSandboxService - network domains', () => { const wrapResult = await sandboxService.wrapCommand('git clone git@example.zip:owner/repo.git', false, 'bash'); - strictEqual(wrapResult.isSandboxWrapped, false, 'SSH remotes should still trigger blocked-domain prompts even when their suffix looks like a file extension'); + strictEqual(wrapResult.isSandboxWrapped, true, 'SSH remotes should stay sandboxed pending network confirmation even when their suffix looks like a file extension'); + strictEqual(wrapResult.requiresAllowNetworkConfirmation, true, 'SSH remotes should still trigger allow-network prompts even when their suffix looks like a file extension'); deepStrictEqual(wrapResult.blockedDomains, ['example.zip']); }); @@ -1154,7 +1260,8 @@ suite('TerminalSandboxService - network domains', () => { const wrapResult = await sandboxService.wrapCommand('git clone git@github.com:microsoft/vscode.git'); - strictEqual(wrapResult.isSandboxWrapped, false, 'SSH-style remotes should trigger domain checks'); + strictEqual(wrapResult.isSandboxWrapped, true, 'SSH-style remotes should stay sandboxed pending network confirmation'); + strictEqual(wrapResult.requiresAllowNetworkConfirmation, true, 'SSH-style remotes should trigger allow-network confirmation'); deepStrictEqual(wrapResult.blockedDomains, ['github.com']); }); @@ -1199,10 +1306,10 @@ suite('TerminalSandboxService - network domains', () => { const command = 'echo $HOME $(curl eth0.me) `id`'; const wrapResult = await sandboxService.wrapCommand(command, false, 'bash'); - strictEqual(wrapResult.isSandboxWrapped, false, 'Commands with blocked domains inside substitutions should not stay sandboxed'); - strictEqual(wrapResult.requiresUnsandboxConfirmation, true, 'Blocked domains inside substitutions should require confirmation'); + strictEqual(wrapResult.isSandboxWrapped, true, 'Commands with blocked domains inside substitutions should stay sandboxed pending network confirmation'); + strictEqual(wrapResult.requiresAllowNetworkConfirmation, true, 'Blocked domains inside substitutions should require allow-network confirmation'); deepStrictEqual(wrapResult.blockedDomains, ['eth0.me']); - strictEqual(wrapResult.command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'bash' -c 'echo $HOME $(curl eth0.me) \`id\`'`); + ok(wrapResult.command.includes('--settings'), 'Command should remain wrapped with the sandbox runtime'); }); test('should escape single-quote breakout payloads in wrapped command argument', async () => { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineSandboxRewriter.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineSandboxRewriter.test.ts index b221ae81d09a8..5d52643d154dc 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineSandboxRewriter.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineSandboxRewriter.test.ts @@ -140,4 +140,29 @@ suite('CommandLineSandboxRewriter', () => { strictEqual(result?.reasoning, 'Wrapped command for sandbox execution'); deepStrictEqual(calls, ['prereqs', 'wrap:echo hello:true', 'commands:']); }); + + test('forwards explicit sandboxed allow-network requests', async () => { + const calls: string[] = []; + stubSandboxService({ + wrapCommand: async (command, _requestUnsandboxedExecution, _shell, _cwd, _commandDetails, requestAllowNetwork) => { + calls.push(`wrap:${command}:${String(requestAllowNetwork)}`); + return { + command: `network-sandbox:${command}`, + isSandboxWrapped: true, + requiresAllowNetworkConfirmation: true, + }; + }, + checkForSandboxingPrereqs: async () => ({ enabled: true, sandboxConfigPath: '/tmp/sandbox.json', failedCheck: undefined }), + }); + + const rewriter = store.add(instantiationService.createInstance(CommandLineSandboxRewriter, stubTreeSitterCommandParser())); + const result = await rewriter.rewrite({ + ...createRewriteOptions('curl https://example.com'), + requestAllowNetwork: true, + }); + + strictEqual(result?.rewritten, 'network-sandbox:curl https://example.com'); + strictEqual(result?.requiresAllowNetworkConfirmation, true); + deepStrictEqual(calls, ['wrap:curl https://example.com:true']); + }); }); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index 402b922e0f106..c0c2770876816 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -40,13 +40,13 @@ import { ChatAgentLocation, ChatPermissionLevel } from '../../../../chat/common/ import { ChatModel, type IChatRequestModeInfo } from '../../../../chat/common/model/chatModel.js'; import { LocalChatSessionUri } from '../../../../chat/common/model/chatUri.js'; import { ChatRequestTextPart } from '../../../../chat/common/requestParser/chatParserTypes.js'; -import { ITerminalSandboxService, TerminalSandboxPrerequisiteCheck, type ITerminalSandboxPrecheckInputs, type ITerminalSandboxPrerequisiteCheckResult } from '../../common/terminalSandboxService.js'; +import { ITerminalSandboxService, TerminalSandboxPrerequisiteCheck, type ITerminalSandboxCommand, TerminalSandboxPreCheckRemediation, type ITerminalSandboxPrecheckInputs, type ITerminalSandboxPrerequisiteCheckResult } from '../../common/terminalSandboxService.js'; import { ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolProgress, ToolSet, type ToolConfirmationAction } from '../../../../chat/common/tools/languageModelToolsService.js'; import { IToolResultCompressor } from '../../../../chat/common/tools/toolResultCompressor.js'; import { ITerminalChatService, ITerminalService, type ITerminalInstance } from '../../../../terminal/browser/terminal.js'; import { ITerminalProfileResolverService } from '../../../../terminal/common/terminal.js'; import type { ICommandLinePresenter } from '../../browser/tools/commandLinePresenter/commandLinePresenter.js'; -import { createRunInTerminalToolData, RunInTerminalTool, shouldAutomaticallyRetryUnsandboxed, type IRunInTerminalInputParams } from '../../browser/tools/runInTerminalTool.js'; +import { createRunInTerminalToolData, RunInTerminalTool, shouldAutomaticallyRetryAllowNetworkInSandboxed, shouldAutomaticallyRetryUnsandboxed, type IRunInTerminalInputParams } from '../../browser/tools/runInTerminalTool.js'; import { ShellIntegrationQuality } from '../../browser/toolTerminalCreator.js'; import { terminalChatAgentToolsConfiguration, TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js'; import { AgentNetworkDomainSettingId } from '../../../../../../platform/networkFilter/common/settings.js'; @@ -111,6 +111,7 @@ suite('RunInTerminalTool', () => { setConfig(TerminalChatAgentToolsSettingId.EnableAutoApprove, true); setConfig(TerminalChatAgentToolsSettingId.BlockDetectedFileWrites, 'outsideWorkspace'); setConfig(AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands, true); + setConfig(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, true); setConfig(AgentSandboxSettingId.AgentSandboxAutoApproveUnsandboxedCommands, false); setConfig(AgentSandboxSettingId.AgentSandboxAllowAutoApprove, false); sandboxEnabled = false; @@ -219,6 +220,7 @@ suite('RunInTerminalTool', () => { await terminal.sendText(`sudo apt install -y ${missingDependencies.join(' ')}`, true); return { exitCode: 0 }; }, + runSandboxRemediation: async () => ({ exitCode: 0 }), }; instantiationService.stub(ITerminalSandboxService, terminalSandboxService); @@ -279,7 +281,8 @@ suite('RunInTerminalTool', () => { } async function invokeToolTest( - params: Partial + params: Partial, + selectedCustomButton?: string, ): Promise { const parameters = { command: 'echo hello', @@ -298,6 +301,7 @@ suite('RunInTerminalTool', () => { parameters, context: { sessionResource: LocalChatSessionUri.forSession('run-in-terminal-test') }, toolSpecificData: preparedInvocation.toolSpecificData, + selectedCustomButton, } as IToolInvocation, countTokens, noProgress, CancellationToken.None); } @@ -343,8 +347,18 @@ suite('RunInTerminalTool', () => { return model; } + type AutomaticSandboxRetryKindForTest = 'unsandboxed' | 'allowNetwork'; + + function confirmAutomaticSandboxRetry(tool: RunInTerminalTool, retryKind: AutomaticSandboxRetryKindForTest, sessionResource: URI | undefined, command: string, shell: string, blockedDomains: string[] | undefined): Promise { + return (tool as unknown as Record Promise>)['_confirmAutomaticSandboxRetry'](retryKind, sessionResource, command, shell, blockedDomains, undefined, CancellationToken.None); + } + function confirmAutomaticUnsandboxRetry(tool: RunInTerminalTool, sessionResource: URI | undefined, command: string, shell: string, blockedDomains: string[] | undefined): Promise { - return (tool as unknown as Record Promise>)['_confirmAutomaticUnsandboxRetry'](sessionResource, command, shell, blockedDomains, undefined, CancellationToken.None); + return confirmAutomaticSandboxRetry(tool, 'unsandboxed', sessionResource, command, shell, blockedDomains); + } + + function confirmAutomaticAllowNetworkRetry(tool: RunInTerminalTool, sessionResource: URI | undefined, command: string, shell: string, blockedDomains: string[] | undefined): Promise { + return confirmAutomaticSandboxRetry(tool, 'allowNetwork', sessionResource, command, shell, blockedDomains); } async function assertAutomaticUnsandboxRetryElicitation(tool: RunInTerminalTool, sessionResource: URI, command: string, shell: string, blockedDomains: string[] | undefined): Promise { @@ -362,8 +376,34 @@ suite('RunInTerminalTool', () => { strictEqual(await shouldRetry, false); } + async function assertAutomaticAllowNetworkRetryElicitation(tool: RunInTerminalTool, sessionResource: URI, command: string, shell: string, blockedDomains: string[] | undefined, expectedTitle: string): Promise { + const model = createChatModelWithRequest(sessionResource); + const shouldRetry = confirmAutomaticAllowNetworkRetry(tool, sessionResource, command, shell, blockedDomains); + const request = model.getRequests().at(-1); + const response = request?.response; + ok(response, 'Expected chat request with response'); + const elicitation = response.response.value.find(part => part.kind === 'elicitation2'); + ok(elicitation?.kind === 'elicitation2', 'Expected automatic allow-network retry elicitation'); + const title = elicitation.title; + ok(typeof title !== 'string', 'Expected automatic allow-network retry title to be markdown'); + strictEqual(title.value, expectedTitle); + const reject = elicitation.reject; + ok(reject, 'Expected automatic allow-network retry elicitation to have a reject action'); + + await reject(); + strictEqual(await shouldRetry, false); + } + + function getAutomaticSandboxRetryTitle(tool: RunInTerminalTool, retryKind: AutomaticSandboxRetryKindForTest, shellType: string, blockedDomains: string[] | undefined): IMarkdownString { + return (tool as unknown as Record IMarkdownString>)['_getAutomaticSandboxRetryTitle'](retryKind, shellType, blockedDomains); + } + function getAutomaticUnsandboxRetryTitle(tool: RunInTerminalTool, shellType: string, blockedDomains: string[] | undefined): IMarkdownString { - return (tool as unknown as Record IMarkdownString>)['_getAutomaticUnsandboxRetryTitle'](shellType, blockedDomains); + return getAutomaticSandboxRetryTitle(tool, 'unsandboxed', shellType, blockedDomains); + } + + function getAutomaticAllowNetworkRetryTitle(tool: RunInTerminalTool, shellType: string, blockedDomains: string[] | undefined): IMarkdownString { + return getAutomaticSandboxRetryTitle(tool, 'allowNetwork', shellType, blockedDomains); } suite('sandbox invocation messaging', () => { @@ -376,7 +416,8 @@ suite('RunInTerminalTool', () => { ok(toolData.modelDescription?.includes('The /tmp directory is not guaranteed to be accessible or writable and must be avoided'), 'Expected sandboxed tool description to discourage /tmp usage'); }); - test('should include requestUnsandboxedExecution in schema when sandbox is enabled', async () => { + test('should include sandbox escalation requests in schema when sandbox is enabled', async () => { + setConfig(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, true); sandboxEnabled = true; const toolData = await instantiationService.invokeFunction(createRunInTerminalToolData); @@ -384,6 +425,8 @@ suite('RunInTerminalTool', () => { const allowToRunUnsandboxedCommandsProperty = properties?.['allowToRunUnsandboxedCommands'] as { const?: boolean; default?: boolean; description?: string } | undefined; const requestUnsandboxedExecutionProperty = properties?.['requestUnsandboxedExecution'] as { description?: string } | undefined; const requestUnsandboxedExecutionReasonProperty = properties?.['requestUnsandboxedExecutionReason'] as { description?: string } | undefined; + const requestAllowNetworkProperty = properties?.['requestAllowNetwork'] as { description?: string } | undefined; + const requestAllowNetworkReasonProperty = properties?.['requestAllowNetworkReason'] as { description?: string } | undefined; ok(properties?.['allowToRunUnsandboxedCommands'], 'Expected allowToRunUnsandboxedCommands in schema when sandbox is enabled'); strictEqual(allowToRunUnsandboxedCommandsProperty?.const, true, 'Expected allowToRunUnsandboxedCommands const to match the setting value'); @@ -391,12 +434,17 @@ suite('RunInTerminalTool', () => { ok(allowToRunUnsandboxedCommandsProperty?.description?.includes('chat.agent.sandbox.allowUnsandboxedCommands'), 'Expected allowToRunUnsandboxedCommands description to mention the source setting'); ok(properties?.['requestUnsandboxedExecution'], 'Expected requestUnsandboxedExecution in schema when sandbox is enabled'); ok(properties?.['requestUnsandboxedExecutionReason'], 'Expected requestUnsandboxedExecutionReason in schema when sandbox is enabled'); + ok(properties?.['requestAllowNetwork'], 'Expected requestAllowNetwork in schema when sandbox is enabled'); + ok(properties?.['requestAllowNetworkReason'], 'Expected requestAllowNetworkReason in schema when sandbox is enabled'); ok(requestUnsandboxedExecutionProperty?.description?.includes('Only set this when the command clearly needs unsandboxed access'), 'Expected schema description to require a clear need for unsandboxed access'); ok(requestUnsandboxedExecutionReasonProperty?.description?.includes('why this command must run outside the terminal sandbox'), 'Expected reason schema description to require concrete sandbox justification'); + ok(requestAllowNetworkProperty?.description?.includes('remain in the terminal sandbox but run with unrestricted network access'), 'Expected network schema description to retain sandboxing'); + ok(requestAllowNetworkReasonProperty?.description?.includes('needs unrestricted network access'), 'Expected network reason schema description to request justification'); }); test('should set allowToRunUnsandboxedCommands from setting in schema when unsandboxed commands are disabled', async () => { setConfig(AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands, false); + setConfig(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, true); sandboxEnabled = true; const toolData = await instantiationService.invokeFunction(createRunInTerminalToolData); @@ -408,9 +456,23 @@ suite('RunInTerminalTool', () => { strictEqual(allowToRunUnsandboxedCommandsProperty?.default, false, 'Expected allowToRunUnsandboxedCommands to reflect the disabled setting'); ok(properties?.['requestUnsandboxedExecution'], 'Expected requestUnsandboxedExecution to remain in schema when sandbox is enabled'); ok(properties?.['requestUnsandboxedExecutionReason'], 'Expected requestUnsandboxedExecutionReason to remain in schema when sandbox is enabled'); + ok(properties?.['requestAllowNetwork'], 'Expected requestAllowNetwork to remain in schema when unsandboxed commands are disabled'); + ok(properties?.['requestAllowNetworkReason'], 'Expected requestAllowNetworkReason to remain in schema when unsandboxed commands are disabled'); ok(toolData.modelDescription?.includes('Running commands outside the sandbox is disabled'), 'Expected model description to explain that unsandboxed commands are disabled'); }); + test('should not recommend allow-network requests in model description when per-command network access is disabled', async () => { + setConfig(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, false); + sandboxEnabled = true; + + const toolData = await instantiationService.invokeFunction(createRunInTerminalToolData); + const properties = toolData.inputSchema?.properties as Record | undefined; + + ok(properties?.['requestAllowNetwork'], 'Expected requestAllowNetwork to remain in schema so disabled requests can fail with a clear message'); + ok(properties?.['requestAllowNetworkReason'], 'Expected requestAllowNetworkReason to remain in schema so disabled requests can fail with a clear message'); + ok(!toolData.modelDescription?.includes('requestAllowNetwork=true'), 'Expected model description not to recommend allow-network requests when per-command network access is disabled'); + }); + test('should not include requestUnsandboxedExecution in schema when sandbox is disabled', async () => { sandboxEnabled = false; @@ -420,6 +482,8 @@ suite('RunInTerminalTool', () => { ok(!properties?.['allowToRunUnsandboxedCommands'], 'Expected no allowToRunUnsandboxedCommands when sandbox is disabled'); ok(!properties?.['requestUnsandboxedExecution'], 'Expected no requestUnsandboxedExecution in schema when sandbox is disabled'); ok(!properties?.['requestUnsandboxedExecutionReason'], 'Expected no requestUnsandboxedExecutionReason in schema when sandbox is disabled'); + ok(!properties?.['requestAllowNetwork'], 'Expected no requestAllowNetwork in schema when sandbox is disabled'); + ok(!properties?.['requestAllowNetworkReason'], 'Expected no requestAllowNetworkReason in schema when sandbox is disabled'); }); test('should reflect sandbox setting changes in tool data', async () => { @@ -469,7 +533,65 @@ suite('RunInTerminalTool', () => { strictEqual((result?.toolSpecificData as IChatTerminalToolInvocationData | undefined)?.missingSandboxDependencies?.length, 1); }); + test('should show repair choices when bubblewrap is installed but unusable on Linux', async () => { + sandboxPrereqResult = { + enabled: true, + sandboxConfigPath: '/tmp/sandbox.json', + failedCheck: TerminalSandboxPrerequisiteCheck.Bubblewrap, + remediations: [TerminalSandboxPreCheckRemediation.InstallUbuntuAppArmorProfile, TerminalSandboxPreCheckRemediation.DisableUbuntuUserNamespaceRestriction], + }; + + const result = await executeToolTest({ command: 'echo hello' }); + const terminalData = result?.toolSpecificData as IChatTerminalToolInvocationData | undefined; + + ok(result?.confirmationMessages, 'Expected confirmation messages for bubblewrap repair'); + strictEqual(result?.confirmationMessages?.customOptions?.length, 3, 'Expected recommended repair, fallback, and cancel choices'); + strictEqual(terminalData?.sandboxRemediations?.length, 2, 'Expected repair options in terminal invocation data'); + strictEqual(terminalData?.missingSandboxDependencies, undefined, 'Should not classify unusable bubblewrap as missing'); + }); + + test('should recheck bubblewrap after dependency installation and not execute when it remains unavailable', async () => { + let forceRefreshCalled = false; + terminalSandboxService.checkForSandboxingPrereqs = async forceRefresh => { + if (forceRefresh) { + forceRefreshCalled = true; + return { + enabled: true, + sandboxConfigPath: '/tmp/sandbox.json', + failedCheck: TerminalSandboxPrerequisiteCheck.Bubblewrap, + remediations: [TerminalSandboxPreCheckRemediation.InstallUbuntuAppArmorProfile], + }; + } + return { + enabled: true, + sandboxConfigPath: '/tmp/sandbox.json', + failedCheck: TerminalSandboxPrerequisiteCheck.Dependencies, + missingDependencies: ['bubblewrap'], + }; + }; + + const result = await invokeToolTest({ command: 'echo hello' }, 'install'); + + strictEqual(forceRefreshCalled, true, 'Expected dependency installation to force a new prerequisite check'); + strictEqual(createTerminalCallCount, 1, 'Expected only the installation terminal, not original command execution'); + ok((result.content[0] as { value?: string }).value?.includes('bubblewrap'), 'Expected result to identify the failed bubblewrap verification'); + }); + + test('should not execute when bubblewrap is unusable and no supported remediation is available', async () => { + sandboxPrereqResult = { + enabled: true, + sandboxConfigPath: '/tmp/sandbox.json', + failedCheck: TerminalSandboxPrerequisiteCheck.Bubblewrap, + }; + + const result = await invokeToolTest({ command: 'echo hello' }); + + strictEqual(createTerminalCallCount, 0, 'Expected no terminal execution for unusable bubblewrap'); + ok((result.content[0] as { value?: string }).value?.includes('Bubblewrap'), 'Expected a bubblewrap capability failure message'); + }); + test('should include allowed and denied network domains in model description', async () => { + setConfig(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, true); sandboxEnabled = true; terminalSandboxService.getResolvedNetworkDomains = () => ({ allowedDomains: ['github.com', 'npmjs.org'], @@ -480,7 +602,8 @@ suite('RunInTerminalTool', () => { ok(toolData.modelDescription?.includes('github.com, npmjs.org'), 'Expected allowed domains in description'); ok(toolData.modelDescription?.includes('evil.com'), 'Expected denied domains in description'); - ok(toolData.modelDescription?.includes('Only set requestUnsandboxedExecution=true when there is evidence of failures caused by the sandbox'), 'Expected model description to require concrete evidence before unsandboxing'); + ok(toolData.modelDescription?.includes('requestAllowNetwork=true'), 'Expected model description to recommend network-enabled sandbox execution first'); + ok(toolData.modelDescription?.includes('Only set requestAllowNetwork=true when there is evidence of network failures caused by the sandbox'), 'Expected model description to constrain allow-network requests to sandbox network failures'); }); test('should exclude denied domains from effective allowed list', async () => { @@ -657,6 +780,17 @@ suite('RunInTerminalTool', () => { exitCode: 1, output: '/bin/bash: /workspace/out.txt: Operation not permitted', }; + const baseAllowNetworkRetryOptions = { + retryWithAllowNetworkRequests: true, + didSandboxWrapCommand: true, + requestUnsandboxedExecution: false, + requestAllowNetwork: false, + isPersistentSession: false, + isBackgroundExecution: false, + didTimeout: false, + exitCode: 1, + output: 'connect: Operation not permitted', + }; test('should retry completed foreground sandbox commands when output indicates sandbox block', () => { strictEqual(shouldAutomaticallyRetryUnsandboxed(baseRetryOptions), true); @@ -676,6 +810,36 @@ suite('RunInTerminalTool', () => { }), false); }); + test('should not automatically retry outside the sandbox for apparent network failures', () => { + strictEqual(shouldAutomaticallyRetryUnsandboxed({ + ...baseRetryOptions, + output: 'connect: Operation not permitted', + }), false); + }); + + test('should retry in the sandbox by allowing network for apparent network failures', () => { + strictEqual(shouldAutomaticallyRetryAllowNetworkInSandboxed(baseAllowNetworkRetryOptions), true); + }); + + test('should not retry with allow-network when disabled or already requested', () => { + strictEqual(shouldAutomaticallyRetryAllowNetworkInSandboxed({ + ...baseAllowNetworkRetryOptions, + retryWithAllowNetworkRequests: false, + }), false); + strictEqual(shouldAutomaticallyRetryAllowNetworkInSandboxed({ + ...baseAllowNetworkRetryOptions, + requestAllowNetwork: true, + }), false); + strictEqual(shouldAutomaticallyRetryAllowNetworkInSandboxed({ + ...baseAllowNetworkRetryOptions, + requestUnsandboxedExecution: true, + }), false); + strictEqual(shouldAutomaticallyRetryAllowNetworkInSandboxed({ + ...baseAllowNetworkRetryOptions, + output: 'regular command failure', + }), false); + }); + test('should not retry background, timed-out, successful, or non-sandbox-blocked results', () => { strictEqual(shouldAutomaticallyRetryUnsandboxed({ ...baseRetryOptions, @@ -753,6 +917,29 @@ suite('RunInTerminalTool', () => { strictEqual(title.value, 'Run `bash` command outside the sandbox to access `example.com`?'); }); + test('should use allow-network retry confirmation title without sandbox link', () => { + const title = getAutomaticAllowNetworkRetryTitle(runInTerminalTool, 'bash', undefined); + + strictEqual(title.value, 'Retry `bash` command in the sandbox by allowing network access?'); + }); + + test('should use allow-network retry confirmation title without sandbox link for blocked domains', () => { + const title = getAutomaticAllowNetworkRetryTitle(runInTerminalTool, 'bash', ['example.com']); + + strictEqual(title.value, 'Retry `bash` command in the sandbox by allowing network access to `example.com`?'); + }); + + test('should show allow-network retry elicitation with sandbox-preserving title', async () => { + await assertAutomaticAllowNetworkRetryElicitation( + runInTerminalTool, + LocalChatSessionUri.forSession('auto-retry-allow-network-session'), + 'curl https://example.com', + 'bash', + undefined, + 'Retry `bash` command in the sandbox by allowing network access?' + ); + }); + test('should show retry elicitation when sandbox force-approved command would otherwise require confirmation', async () => { setAutoApprove({}); sandboxEnabled = true; @@ -1049,6 +1236,88 @@ suite('RunInTerminalTool', () => { ok(confirmationMessage.value.includes('Reason for leaving the sandbox: This command accesses evil.com, which is blocked by chat.agent.deniedNetworkDomains.')); }); + test('should force confirmation for explicit sandboxed allow-network requests', async () => { + setConfig(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, true); + sandboxEnabled = true; + sandboxPrereqResult = { + enabled: true, + sandboxConfigPath: '/tmp/sandbox.json', + failedCheck: undefined, + }; + terminalSandboxService.wrapCommand = async (command: string, _requestUnsandboxedExecution?: boolean, _shell?: string, _cwd?: URI, _details?: readonly ITerminalSandboxCommand[], requestAllowNetwork?: boolean) => ({ + command: requestAllowNetwork ? `network-sandbox:${command}` : `sandbox:${command}`, + isSandboxWrapped: true, + requiresAllowNetworkConfirmation: requestAllowNetwork ? true : undefined, + }); + + const result = await executeToolTest({ + requestAllowNetwork: true, + requestAllowNetworkReason: 'Needs registry access while remaining sandboxed', + }); + + assertConfirmationRequired(result, 'Allow the sandbox to run `bash` command with unrestricted network access.'); + const terminalData = result?.toolSpecificData as IChatTerminalToolInvocationData; + strictEqual(terminalData.requestAllowNetwork, true); + strictEqual(terminalData.requestAllowNetworkReason, 'Needs registry access while remaining sandboxed'); + strictEqual(terminalData.commandLine.toolEdited, 'network-sandbox:echo hello'); + const confirmationMessage = result?.confirmationMessages?.message; + ok(confirmationMessage && typeof confirmationMessage !== 'string'); + if (!confirmationMessage || typeof confirmationMessage === 'string') { + throw new Error('Expected markdown confirmation message'); + } + ok(confirmationMessage.value.includes('Reason for allowing unrestricted network access in the sandbox: Needs registry access while remaining sandboxed')); + }); + + test('should use allow-network confirmation for blocked domains selected before execution', async () => { + setConfig(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, true); + sandboxEnabled = true; + sandboxPrereqResult = { + enabled: true, + sandboxConfigPath: '/tmp/sandbox.json', + failedCheck: undefined, + }; + terminalSandboxService.wrapCommand = async (command: string) => ({ + command: `network-sandbox:${command}`, + isSandboxWrapped: true, + requiresAllowNetworkConfirmation: true, + blockedDomains: ['evil.com'], + deniedDomains: ['evil.com'], + }); + + const result = await executeToolTest({ command: 'curl https://evil.com' }); + + assertConfirmationRequired(result, 'Allow the sandbox to run `bash` command with unrestricted network access.'); + const terminalData = result?.toolSpecificData as IChatTerminalToolInvocationData; + strictEqual(terminalData.requestAllowNetwork, true); + strictEqual(terminalData.requestUnsandboxedExecution, false); + const confirmationMessage = result?.confirmationMessages?.message; + ok(confirmationMessage && typeof confirmationMessage !== 'string'); + if (!confirmationMessage || typeof confirmationMessage === 'string') { + throw new Error('Expected markdown confirmation message'); + } + ok(confirmationMessage.value.includes('Reason for allowing unrestricted network access in the sandbox: This command accesses evil.com, which is blocked by chat.agent.deniedNetworkDomains.')); + }); + + test('should reject explicit allow-network requests when per-command network access is disabled', async () => { + setConfig(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, false); + sandboxEnabled = true; + sandboxPrereqResult = { + enabled: true, + sandboxConfigPath: '/tmp/sandbox.json', + failedCheck: undefined, + }; + + const prepared = await executeToolTest({ requestAllowNetwork: true, requestAllowNetworkReason: 'Needs registry access' }); + ok(prepared, 'Expected prepared invocation to be defined'); + ok(!prepared.confirmationMessages, 'Expected no confirmation because the command will not run'); + ok((prepared.invocationMessage as IMarkdownString).value.includes('unrestricted network access in the sandbox is disabled')); + + const result = await invokeToolTest({ requestAllowNetwork: true, requestAllowNetworkReason: 'Needs registry access' }); + strictEqual(createTerminalCallCount, 0, 'Expected no terminal to be created'); + ok(result.toolResultError, 'Expected the rejected request to be returned as a tool error'); + ok(result.content[0].kind === 'text' && result.content[0].value.includes('chat.agent.sandbox.retryWithAllowNetworkRequests')); + }); + test('should force confirmation for explicit unsandboxed execution requests', async () => { sandboxEnabled = true; sandboxPrereqResult = { @@ -2359,7 +2628,7 @@ suite('RunInTerminalTool', () => { strictEqual(capturedSteeringRequests.length, 2, 'Expected a changed prompt to trigger a new notification'); }); - test('should suppress background input-needed notification when the terminal is disposed', () => { + test('should suppress input-needed after disposal and omit successful exit code from terminal-exited notice', () => { const termId = 'test-input-needed-disposed-term'; const sessionResource = LocalChatSessionUri.forSession('test-input-needed-disposed-session'); const output = 'Press ENTER or type command to continue'; @@ -2376,6 +2645,7 @@ suite('RunInTerminalTool', () => { }, onDisposed: terminalDisposedEmitter.event, onDidInputData: inputDataEmitter.event, + exitCode: 0, get isDisposed() { return isDisposed; }, } as unknown as ITerminalInstance; @@ -2403,6 +2673,11 @@ suite('RunInTerminalTool', () => { isDisposed = true; inputNeededEmitter.fire(); strictEqual(capturedSteeringRequests.length, 0, 'Closing the terminal should not produce a spurious input-needed chat turn'); + + terminalDisposedEmitter.fire(); + strictEqual(capturedSteeringRequests.length, 1, 'Closing the terminal should send one terminal-exited notification'); + ok(capturedSteeringRequests[0].message.includes('terminal exited.'), 'Successful terminal exit should be reported without qualification'); + ok(!capturedSteeringRequests[0].message.includes('exit code 0'), 'Successful terminal exit should not print exit code 0 to chat'); }); test('should suppress redundant input-needed notification for output already returned via foreground inputNeeded', () => { @@ -2510,6 +2785,9 @@ suite('RunInTerminalTool', () => { // After command finishes, the fg association still persists commandFinishedEmitter.fire({ exitCode: 0 }); + strictEqual(capturedSteeringRequests.length, 2, 'Should send a completion steering request'); + ok(capturedSteeringRequests[1].message.includes('command completed.'), 'Successful completion should be reported without qualification'); + ok(!capturedSteeringRequests[1].message.includes('exit code 0'), 'Successful completion should not print exit code 0 to chat'); ok(runInTerminalTool.sessionTerminalAssociations.has(sessionResource), 'Session terminal association should still be preserved after command finishes'); strictEqual(runInTerminalTool.sessionTerminalAssociations.get(sessionResource)!.isBackground, false, 'Terminal should still be foreground after command finishes'); }); @@ -2925,6 +3203,7 @@ suite('ChatAgentToolsContribution - tool registration refresh', () => { getResolvedNetworkDomains: () => ({ allowedDomains: [], deniedDomains: [] }), getMissingSandboxDependencies: async () => [], installMissingSandboxDependencies: async () => ({ exitCode: 0 }), + runSandboxRemediation: async () => ({ exitCode: 0 }), }; instantiationService.stub(ITerminalSandboxService, terminalSandboxService);