diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index bd4e2dd1b72a3..6a516e4a56eb6 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -11,6 +11,7 @@ import type { IMcpServerConfiguration } from '../../mcp/common/mcpPlatformTypes. import { TelemetryConfiguration, TelemetryLevel } from '../../telemetry/common/telemetry.js'; import { telemetryLevelToAgentHostValue } from './agentHostTelemetry.js'; import { SessionConfigKey } from './sessionConfigKeys.js'; +import type { IShellInitScript } from './shellInitScript.js'; import type { SessionConfigPropertySchema, SessionConfigSchema } from './state/protocol/commands.js'; import { JsonRpcErrorCodes, ProtocolError } from './state/sessionProtocol.js'; @@ -299,6 +300,41 @@ const permissionsProperty = schemaProperty({ sessionMutable: true, }); +/** + * Scripts the client generated for this session, sourced before every built-in + * shell tool command (see `common/shellInitScript.ts`). Written by the + * workbench and consumed by the Copilot provider; `readOnly` because no user + * edits it directly, `sessionMutable` because the selected Python environment + * can change while a session is live. The value is transient and omitted from + * persisted session config. + * + * Deliberately has no `default`: an absent value means "nothing to apply", + * which must stay distinguishable from an explicit empty array (clear). + */ +const shellInitScriptsProperty = schemaProperty({ + type: 'array', + title: localize('agentHost.sessionConfig.shellInitScripts', "Shell Init Script"), + description: localize('agentHost.sessionConfig.shellInitScriptsDescription', "A script sourced before each built-in shell tool command."), + items: { + type: 'object', + title: localize('agentHost.sessionConfig.shellInitScripts.item', "Shell Init Script"), + properties: { + shell: { + type: 'string', + title: localize('agentHost.sessionConfig.shellInitScripts.shell', "Shell"), + enum: ['bash', 'powershell'], + }, + script: { + type: 'string', + title: localize('agentHost.sessionConfig.shellInitScripts.script', "Script"), + }, + }, + required: ['shell', 'script'], + }, + readOnly: true, + sessionMutable: true, +}); + /** * Session-config properties owned by the platform itself — i.e. consumed * by the agent host rather than by any particular agent. @@ -345,6 +381,7 @@ export const platformSessionSchema = createSchema({ default: 'interactive', sessionMutable: true, }), + [SessionConfigKey.ShellInitScripts]: shellInitScriptsProperty, }); /** diff --git a/src/vs/platform/agentHost/common/copilotCliConfig.ts b/src/vs/platform/agentHost/common/copilotCliConfig.ts index de6bed7136d87..15fb58e06c782 100644 --- a/src/vs/platform/agentHost/common/copilotCliConfig.ts +++ b/src/vs/platform/agentHost/common/copilotCliConfig.ts @@ -16,6 +16,8 @@ import { reasoningEffortLevels } from './reasoningEffort.js'; export const enum CopilotCliConfigKey { /** Use Agent Host's custom terminal tool instead of the SDK's default. Off by default. */ EnableCustomTerminalTool = 'enableCustomTerminalTool', + /** Apply the shell init script a client published for a session to SDK shell commands. Off by default. */ + EnableShellInitScript = 'enableShellInitScript', /** Log level passed to the Copilot SDK client. */ CopilotSdkLogLevel = 'copilotSdkLogLevel', /** Enable the rubber duck critic subagent. */ @@ -52,6 +54,9 @@ export const CopilotCliVSCodeAssignmentContextKey = 'copilotCliVSCodeAssignmentC export const AgentHostCustomTerminalToolEnabledSettingId = 'chat.agentHost.customTerminalTool.enabled'; +/** Enable VS Code's generated init script for the SDK built-in shell tool. */ +export const AgentHostShellToolInitScriptEnabledSettingId = 'chat.agentHost.shellTool.initScript.enabled'; + export const AgentHostCopilotSdkLogLevelSettingId = 'chat.agentHost.copilotSdk.logLevel'; export const AgentHostOpus48PromptEnabledSettingId = 'chat.agentHost.opus48Prompt.enabled'; @@ -144,6 +149,12 @@ export const copilotCliConfigSchema = createSchema({ description: localize('agentHost.config.enableCustomTerminalTool.description', "When enabled, Copilot SDK sessions use Agent Host's terminal tool override instead of the SDK's default terminal behavior."), default: false, }), + [CopilotCliConfigKey.EnableShellInitScript]: schemaProperty({ + type: 'boolean', + title: localize('agentHost.config.enableShellInitScript.title', "Shell Init Script"), + description: localize('agentHost.config.enableShellInitScript.description', "When enabled, Copilot SDK sessions apply the shell init script published by the client before each shell command."), + default: false, + }), [CopilotCliConfigKey.CopilotSdkLogLevel]: schemaProperty({ type: 'string', title: localize('agentHost.config.copilotSdkLogLevel.title', "Copilot SDK Log Level"), diff --git a/src/vs/platform/agentHost/common/sessionConfigKeys.ts b/src/vs/platform/agentHost/common/sessionConfigKeys.ts index b7373c4c2c2b7..55d4f98656a99 100644 --- a/src/vs/platform/agentHost/common/sessionConfigKeys.ts +++ b/src/vs/platform/agentHost/common/sessionConfigKeys.ts @@ -39,6 +39,8 @@ export const enum SessionConfigKey { AgentMerge = 'agentMerge', /** `'agentMerge.controller'` — host-owned Agent Merge lifecycle state. */ AgentMergeController = 'agentMerge.controller', + /** `'shellInitScripts'` — scripts a client generated for the session, sourced before built-in shell tool commands. */ + ShellInitScripts = 'shellInitScripts', } /** @@ -58,3 +60,13 @@ export const KNOWN_AUTO_APPROVE_VALUES: ReadonlySet = new Set(['default' * property: the agent execution mode axis. */ export const KNOWN_MODE_VALUES: ReadonlySet = new Set(['interactive', 'plan', 'autopilot']); + +/** + * Removes session config that is derived from live client state and must not + * survive an Agent Host restart. + */ +export function omitTransientSessionConfigValues(values: Record): Record { + const result = { ...values }; + delete result[SessionConfigKey.ShellInitScripts]; + return result; +} diff --git a/src/vs/platform/agentHost/common/shellInitScript.ts b/src/vs/platform/agentHost/common/shellInitScript.ts new file mode 100644 index 0000000000000..db3f07cc21e62 --- /dev/null +++ b/src/vs/platform/agentHost/common/shellInitScript.ts @@ -0,0 +1,127 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; + +export type ShellInitScriptShell = 'bash' | 'powershell'; + +/** + * One client-generated script sourced before every SDK built-in shell command. + * The array-valued session config carries either no script (`[]`) or this one + * script, so clearing a previously applied script is explicit. + */ +export interface IShellInitScript { + readonly shell: ShellInitScriptShell; + readonly script: string; +} + +function quoteBash(value: string): string { + return `'${value.replaceAll(`'`, `'\\''`)}'`; +} + +function encodedPowerShellExpression(value: string): string { + const encoded = encodeBase64(VSBuffer.fromString(value)); + return `[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${encoded}'))`; +} + +function powerShellBlock(body: readonly string[], failureMessage: string): string[] { + return [ + `$__vscodePreviousErrorActionPreference = $ErrorActionPreference`, + `try {`, + `\t$ErrorActionPreference = 'Stop'`, + ...body, + `} catch {`, + `\tWrite-Output '${failureMessage.replaceAll(`'`, `''`)}'`, + `} finally {`, + `\t$ErrorActionPreference = $__vscodePreviousErrorActionPreference`, + `}`, + ]; +} + +/** + * Creates the single script VS Code registers with the SDK shell tool. + * + * Profile loading comes first so activation runs against the user's shell + * setup. Activation is whatever command the Python Environments extension + * published for the folder; nothing tool-specific is added here. + * + * Every script ends successfully unless sourced profile code itself + * terminates the shell: the runtime reports a nonzero init-script status + * before every later command, and discards Bash init-script stderr. + */ +export function createShellInitScript(shell: ShellInitScriptShell, pythonActivation: string | undefined): IShellInitScript { + return shell === 'powershell' + ? createPowerShellInitScript(pythonActivation) + : createBashInitScript(pythonActivation); +} + +function createBashInitScript(pythonActivation: string | undefined): IShellInitScript { + const lines = [ + `# Generated by VS Code for Agent Host shell commands.`, + `if [ -r "$HOME/.bashrc" ]; then`, + // An rc file's status is the status of its final command. A nonzero + // status therefore does not mean profile setup failed. + `\tbuiltin source "$HOME/.bashrc" || builtin true`, + `fi`, + ]; + if (pythonActivation?.trim()) { + lines.push( + `if ! builtin eval ${quoteBash(pythonActivation)}; then`, + `\tprintf '%s\\n' 'copilot shell init: Python activation failed; continuing without the selected environment.'`, + `fi`, + ); + } + lines.push(`builtin true`, ``); + return { shell: 'bash', script: lines.join('\n') }; +} + +function createPowerShellInitScript(pythonActivation: string | undefined): IShellInitScript { + // Profiles load with their normal 'Continue' preference — the runtime + // sources init scripts under 'Stop', which would let one benign + // non-terminating profile error skip everything after it. Each profile has + // its own try/catch so a broken profile does not skip the next one. A + // preference changed by a profile affects later init work; the runtime may + // restore its outer preference after this script completes. + const lines = [ + `# Generated by VS Code for Agent Host shell commands.`, + `$ErrorActionPreference = 'Continue'`, + `foreach ($__vscodeProfile in @($PROFILE.CurrentUserAllHosts, $PROFILE.CurrentUserCurrentHost)) {`, + `\ttry {`, + `\t\tif ($__vscodeProfile -and (Test-Path -LiteralPath $__vscodeProfile)) {`, + `\t\t\t. $__vscodeProfile`, + `\t\t}`, + `\t} catch {`, + `\t\tWrite-Output 'copilot shell init: loading the PowerShell profile failed; continuing.'`, + `\t}`, + `}`, + ]; + if (pythonActivation?.trim()) { + lines.push(...powerShellBlock( + [`\tInvoke-Expression (${encodedPowerShellExpression(pythonActivation)})`], + 'copilot shell init: Python activation failed; continuing without the selected environment.', + )); + } + lines.push(`$global:LASTEXITCODE = 0`, ``); + return { shell: 'powershell', script: lines.join('\n') }; +} + +/** Generated scripts are a few hundred bytes; anything near this is not one. */ +const MAX_SHELL_INIT_SCRIPT_LENGTH = 64 * 1024; + +/** Validates the client-pushed list before the agent host writes it to disk. */ +export function isShellInitScriptList(value: unknown): value is readonly IShellInitScript[] { + return Array.isArray(value) + && value.length <= 1 + && value.every(entry => { + if (!entry || typeof entry !== 'object') { + return false; + } + const script = entry as Partial; + return (script.shell === 'bash' || script.shell === 'powershell') + && typeof script.script === 'string' + && script.script.length > 0 + && script.script.length <= MAX_SHELL_INIT_SCRIPT_LENGTH; + }); +} diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 6c8d5128a1868..70251586c94af 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -23,7 +23,7 @@ import { AgentChatMigrationDeferred, AgentProvider, AgentSession, AgentSignal, I import { type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentService } from '../common/agentService.js'; import { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js'; import { IAgentEditAttributionService, ICancelEditAttributionFlushParams, ICommitEditAttributionFlushParams, IEditAttributionFlushResult, IPrepareEditAttributionFlushParams, IPreparedEditAttributionFlush, parseEditAttributionResource } from '../common/fileEditAttribution.js'; -import { SessionConfigKey } from '../common/sessionConfigKeys.js'; +import { omitTransientSessionConfigValues, SessionConfigKey } from '../common/sessionConfigKeys.js'; import type { IAgentCustomizationSettingsRegistration } from '../common/agentCustomizationSettings.js'; import { buildAnnotationsUri, parseAnnotationsUri } from '../common/annotationsUri.js'; import { AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY, isAgentHostAutomationMigrationCompletion } from '../common/automationMigration.js'; @@ -3022,9 +3022,12 @@ export class AgentService extends Disposable implements IAgentService { this._syncAgentMergeIndex(session, undefined, sessionConfig); this._serverToolHost.advertise(session.toString()); // Persist resolved config values for restore. Mid-session updates are - // persisted by `AgentSideEffects` on `SessionConfigChanged`. + // persisted by `SessionFlagsContribution` on `SessionConfigChanged`. if (sessionConfig?.values && Object.keys(sessionConfig.values).length > 0 && !created.provisional) { - this._persistConfigValues(session, sessionConfig.values); + const persistedConfigValues = omitTransientSessionConfigValues(sessionConfig.values); + if (Object.keys(persistedConfigValues).length > 0) { + this._persistConfigValues(session, persistedConfigValues); + } } this._changesetCoordinator.onSessionCreated(session.toString()); @@ -3754,7 +3757,10 @@ export class AgentService extends Disposable implements IAgentService { }; const configValues = state.config?.values; if (configValues && Object.keys(configValues).length > 0) { - this._persistConfigValues(session, configValues); + const persistedConfigValues = omitTransientSessionConfigValues(configValues); + if (Object.keys(persistedConfigValues).length > 0) { + this._persistConfigValues(session, persistedConfigValues); + } } // Persist the AH-owned workspace-less marker now that the session has a // real on-disk database (deferred from create for provisional sessions). @@ -5611,7 +5617,7 @@ export class AgentService extends Disposable implements IAgentService { if (m.configValues) { try { - persistedConfigValues = JSON.parse(m.configValues); + persistedConfigValues = omitTransientSessionConfigValues(JSON.parse(m.configValues)); } catch (err) { this._logService.warn(`[AgentService] Failed to parse persisted configValues for ${sessionStr}: ${toErrorMessage(err)}`); } diff --git a/src/vs/platform/agentHost/node/chatContributions/sessionFlags/sessionFlagsContribution.ts b/src/vs/platform/agentHost/node/chatContributions/sessionFlags/sessionFlagsContribution.ts index b545b84731975..f1f257fb46b13 100644 --- a/src/vs/platform/agentHost/node/chatContributions/sessionFlags/sessionFlagsContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/sessionFlags/sessionFlagsContribution.ts @@ -9,6 +9,7 @@ import type { IAgentHostChatContribution, IAgentHostChatContributionContext, IDi import { ISessionDataService } from '../../../common/sessionDataService.js'; import { ActionType } from '../../../common/state/sessionActions.js'; import { AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_READ_DB_KEY } from '../../../common/state/sessionState.js'; +import { omitTransientSessionConfigValues } from '../../../common/sessionConfigKeys.js'; import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js'; import { persistSessionMetadata } from '../../shared/persistSessionMetadata.js'; @@ -35,7 +36,7 @@ export class SessionFlagsContribution extends Disposable implements IAgentHostCh if (dispatched.action.type === ActionType.SessionConfigChanged) { const values = this._stateManager.getSessionState(dispatched.channel)?.config?.values; if (values) { - persistSessionMetadata(this._sessionDataService, this._logService, dispatched.channel, 'configValues', JSON.stringify(values)); + persistSessionMetadata(this._sessionDataService, this._logService, dispatched.channel, 'configValues', JSON.stringify(omitTransientSessionConfigValues(values))); } } // Persisting here rather than in `handleAction` covers client- and diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index e61218f1cbbf9..32d076253d09d 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -24,7 +24,7 @@ import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; import { INativeEnvironmentService } from '../../../environment/common/environment.js'; -import { IFileService } from '../../../files/common/files.js'; +import { FileOperationResult, FileSystemProviderCapabilities, IFileService, toFileOperationResult } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService, LogLevel } from '../../../log/common/log.js'; import product from '../../../product/common/product.js'; @@ -44,6 +44,7 @@ import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import { toToolCallMeta, type IToolCallMeta, type IToolCallUiMeta, type IToolSearchCandidate } from '../../common/meta/agentToolCallMeta.js'; import { OtelData, type OtelAttributeValue } from '../../common/otlp/otlpLogEmitter.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +import { isShellInitScriptList, type IShellInitScript } from '../../common/shellInitScript.js'; import { SEMANTIC_SEARCH_TOOL_NAME } from '../../common/semanticSearchConstants.js'; import { resolveCopilotConfigSlashCommandOnSend } from '../../common/copilotConfigSlashCommands.js'; import { STREAMING_TOOL_DISPLAY_INTERVAL_MS, streamingToolDisplayText } from '../../common/streamingToolCallDisplay.js'; @@ -993,6 +994,19 @@ export class CopilotAgentSession extends Disposable { private readonly _onDidSessionProgress: Emitter; private readonly _sessionLauncher: ICopilotSessionLauncher; + /** Last config materialized and pushed, so unchanged turns do no file I/O or RPC. */ + private _lastAppliedShellInitScripts: string | undefined; + /** Path registered with the SDK, so content-only changes rewrite the file without an RPC. */ + private _registeredShellInitScriptPath: string | undefined; + /** Set once a script file may exist; gates the sandbox grant and dispose-time cleanup. */ + private _shellInitScriptMaterialized = false; + private readonly _shellInitScriptSequencer = new Sequencer(); + private _shellInitScriptDisposing = false; + /** + * Scopes this instance's script files so a disposed predecessor's queued + * cleanup for the same SDK session cannot delete a successor's live script. + */ + private readonly _shellInitScriptInstanceId = generateUuid().substring(0, 8); private readonly _launchPlan: CopilotSessionLaunchPlan; private _detectInterruptedTurnOnRestore: boolean; private readonly _isLaunchTokenStillCurrent: () => boolean; @@ -2114,6 +2128,7 @@ export class CopilotAgentSession extends Disposable { this._subscribeForMemoInvalidation(); this._subscribeForInstructionsCollectedTelemetry(); this._subscribeToPermissionConfigChanges(); + await this._syncShellInitScript(); this._promptCacheState = this._promptCache.read(this.resourceUri); if (this._launchPlan.kind === 'resume') { await this._refreshSessionUsageMetrics(); @@ -2696,13 +2711,14 @@ export class CopilotAgentSession extends Disposable { /** * Applies the per-turn SDK configuration shared by every operation that starts * an agent loop (normal `session.send` and the `/fleet` start path): agent mode, - * permission mode, sandbox, and MCP enablement. Mode and sandbox keep their - * existing best-effort semantics. + * permission mode, sandbox, shell init script, and MCP enablement. Mode, + * sandbox, and shell init keep their existing best-effort semantics. */ private async _prepareSdkTurn(mode: CopilotSdkMode | undefined): Promise { await this.applyMode(mode); await this.syncPermissionMode('turn-start'); await this._applyEffectiveSandboxConfig(); + await this._syncShellInitScript(); await this._reconcileMcpServerEnablement(); } @@ -3070,6 +3086,19 @@ export class CopilotAgentSession extends Disposable { this._logService.warn(`[Copilot:${this.sessionId}] Failed to flush edit attribution: ${error}`); }); this._beginAbort(); + this._shellInitScriptDisposing = true; + // Only a session that wrote a script has anything to remove; every other + // session keeps the plain dispose path. Remove it once the SDK session's + // disconnect has settled, so a command that is still running can source + // it, and even when disconnect fails so nothing is left behind. A session + // that failed before its wrapper existed has no script either, and + // dispose must never throw ahead of the base disposal below. + const wrapper: CopilotSessionWrapper | undefined = this._wrapper; + if (wrapper && this._shellInitScriptMaterialized) { + void wrapper.disconnect() + .catch(error => this._logService.warn(`[Copilot:${this.sessionId}] Failed to disconnect before shell init cleanup: ${getErrorMessage(error)}`)) + .then(() => this._disposeShellInitScript()); + } super.dispose(); } @@ -3086,6 +3115,7 @@ export class CopilotAgentSession extends Disposable { this._logService.warn(`[Copilot:${this.sessionId}] Failed to flush edit attribution: ${error}`); } await this._wrapper.disconnect(); + await this._disposeShellInitScript(); } /** @@ -3674,7 +3704,36 @@ export class CopilotAgentSession extends Disposable { return undefined; } const sandbox = this._configurationService.getRootValue(sandboxConfigSchema, AgentHostSandboxConfigKey.Sandbox); - return buildSandboxConfigForSdk(this._platform, sandbox); + return buildSandboxConfigForSdk(this._platform, sandbox, this._sandboxExtraReadonlyPaths()); + } + + /** + * Grants the generated directory once a script has been materialized for + * this instance and keeps it for the instance lifetime, so a command that + * already holds the path can still read it after a clear. Sessions that + * never configure a script see no policy change. + */ + private _sandboxExtraReadonlyPaths(): readonly string[] { + return this._shellInitScriptMaterialized ? [this._shellInitScriptDirectory().fsPath] : []; + } + + /** + * Session-scoped root granted to the sandbox; stable across instances of + * the same SDK session so replacements do not churn the sandbox policy. + */ + private _shellInitScriptDirectory(): URI { + const sessionId = this.sessionId.replace(/[^a-zA-Z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').substring(0, 128) || 'session'; + return URI.joinPath(URI.file(this._environmentService.userDataPath), 'agentHost', 'shellInit', sessionId); + } + + /** + * Where this instance writes and deletes its script. Instance-scoped because + * {@link dispose} queues the deletion without awaiting it: a resumed + * replacement for the same SDK session can register its script first, and a + * shared directory would let the stale cleanup remove the live file. + */ + private _shellInitScriptInstanceDirectory(): URI { + return URI.joinPath(this._shellInitScriptDirectory(), this._shellInitScriptInstanceId); } /** @@ -3709,14 +3768,34 @@ export class CopilotAgentSession extends Disposable { private _subscribeToPermissionConfigChanges(): void { this._register(this._configurationService.onDidRootConfigChange(() => { void this._syncPermissionModeAfterConfigChange(); + // The forwarded shell init setting lives in root config. + void this._syncShellInitScript(); })); this._register(this._configurationService.onDidSessionConfigChange(event => { - if (event.session === this._ownerSessionUri.toString() && Object.hasOwn(event.config, SessionConfigKey.AutoApprove)) { + if (event.session !== this._ownerSessionUri.toString()) { + return; + } + if (Object.hasOwn(event.config, SessionConfigKey.AutoApprove)) { void this._syncPermissionModeAfterConfigChange(); } + if (Object.hasOwn(event.config, SessionConfigKey.ShellInitScripts)) { + void this._syncShellInitScript(); + } })); } + private _syncShellInitScript(): Promise { + if (this._shellInitScriptDisposing) { + return Promise.resolve(); + } + return this._shellInitScriptSequencer.queue(() => this._applyEffectiveShellInitScripts()); + } + + private _disposeShellInitScript(): Promise { + this._shellInitScriptDisposing = true; + return this._shellInitScriptSequencer.queue(() => this._clearShellInitScript()); + } + private async _syncPermissionModeAfterConfigChange(): Promise { if (!this.hasActiveTurn) { return; @@ -3787,7 +3866,7 @@ export class CopilotAgentSession extends Disposable { return; } const sandbox = this._configurationService.getRootValue(sandboxConfigSchema, AgentHostSandboxConfigKey.Sandbox); - const base = buildSandboxConfigForSdk(this._platform, sandbox); + const base = buildSandboxConfigForSdk(this._platform, sandbox, this._sandboxExtraReadonlyPaths()); const sandboxConfig: SandboxConfig = base ?? { enabled: false }; try { const result = await this._wrapper.session.rpc.options.update({ sandboxConfig }); @@ -3802,6 +3881,107 @@ export class CopilotAgentSession extends Disposable { } } + /** + * Applies the transient shell init script published by the active client. + * Best-effort: failures are logged and retried on the next turn. + */ + private async _applyEffectiveShellInitScripts(): Promise { + if (this._shellInitScriptDisposing) { + return; + } + try { + // Off states are decided before the payload is looked at, so a stale + // registration is always cleared: the forwarded setting is false, or + // the custom terminal tool has replaced the SDK's built-in shell. + const enabled = this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.EnableShellInitScript) === true + && !this._isCustomTerminalToolEnabled(); + // Session-only by construction: root and parent-session values are + // never consulted. + const own = enabled ? this._configurationService.getSessionConfigValues(this._ownerSessionUri.toString())?.[SessionConfigKey.ShellInitScripts] : undefined; + if (own !== undefined && !isShellInitScriptList(own)) { + // Keep the last valid registration rather than clearing it. + this._logService.warn(`[Copilot:${this.sessionId}] Ignoring malformed shell init script config`); + return; + } + const scripts = own ?? []; + const serialized = JSON.stringify(scripts); + if (this._lastAppliedShellInitScripts === serialized) { + return; + } + if (scripts.length === 0) { + // The file and its sandbox grant stay until dispose, so a command + // that already captured the path can still source it. + if (this._registeredShellInitScriptPath) { + const result = await this._wrapper.session.rpc.options.update({ shell: { initScripts: [] } }); + if (!result.success) { + throw new Error('Copilot SDK rejected shell init script update'); + } + this._registeredShellInitScriptPath = undefined; + } + this._lastAppliedShellInitScripts = serialized; + return; + } + + this._shellInitScriptMaterialized = true; + const ref = await this._materializeShellInitScript(scripts[0]); + if (!ref) { + // Leave the cache unchanged so the next turn retries. + return; + } + // Changed content is rewritten in place and the runtime re-reads the + // file before each command, so only a new path needs the RPC. + if (ref.path !== this._registeredShellInitScriptPath) { + // The SDK requires init scripts to be readable when registered. + await this._applyEffectiveSandboxConfig(true); + const result = await this._wrapper.session.rpc.options.update({ shell: { initScripts: [ref] } }); + if (!result.success) { + throw new Error('Copilot SDK rejected shell init script update'); + } + this._registeredShellInitScriptPath = ref.path; + } + this._lastAppliedShellInitScripts = serialized; + this._logService.trace(`[Copilot:${this.sessionId}] Applied shell init script`); + } catch (err) { + this._logService.warn(`[Copilot:${this.sessionId}] Failed to update shell init scripts`, err); + } + } + + private async _materializeShellInitScript(script: IShellInitScript): Promise<{ shell: IShellInitScript['shell']; path: string } | undefined> { + const resource = URI.joinPath(this._shellInitScriptInstanceDirectory(), script.shell === 'powershell' ? 'init.ps1' : 'init.sh'); + const atomic = this._fileService.hasCapability(resource, FileSystemProviderCapabilities.FileAtomicWrite) + ? { postfix: '.vsctmp' } + : false; + try { + await this._fileService.writeFile(resource, VSBuffer.fromString(script.script), { atomic }); + return { shell: script.shell, path: resource.fsPath }; + } catch (error) { + this._logService.warn(`[Copilot:${this.sessionId}] Failed to write shell init script: ${getErrorMessage(error)}`); + return undefined; + } + } + + /** Removes this instance's script directory. Runs only on dispose, after the SDK session disconnected. */ + private async _clearShellInitScript(): Promise { + if (!this._shellInitScriptMaterialized) { + return; + } + try { + await this._fileService.del(this._shellInitScriptInstanceDirectory(), { recursive: true }); + } catch (error) { + if (!(error instanceof Error) || toFileOperationResult(error) !== FileOperationResult.FILE_NOT_FOUND) { + this._logService.warn(`[Copilot:${this.sessionId}] Failed to remove shell init script: ${getErrorMessage(error)}`); + } + } + try { + // Non-recursive, so the session directory is only pruned once empty; + // while a successor instance still occupies it this fails, keeping + // that instance's script intact. + await this._fileService.del(this._shellInitScriptDirectory()); + } catch { + // Occupied by a successor or already gone — both expected. + } + } + /** * Builds an {@link FileEdit} preview for a write permission request. * diff --git a/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts b/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts index 3e4d2ee0f2954..0af78e827b494 100644 --- a/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts +++ b/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts @@ -123,10 +123,17 @@ export interface SandboxSeatbeltPolicy { * Windows uses its platform-specific enablement and filesystem settings. It * does not fall back to the shared enablement setting so Windows rollout is * controlled independently. + * + * `extraReadonlyPaths` grants read access to host-generated files the shell + * tool needs, such as the session's shell init scripts. The SDK treats init + * script readability as a caller obligation and fails silently when a script + * cannot be read. `CopilotAgentSession` therefore includes the directory when + * it applies the effective sandbox immediately before each turn. */ export function buildSandboxConfigForSdk( platform: NodeJS.Platform, sandbox: ISandboxConfigValue | undefined, + extraReadonlyPaths?: readonly string[], ): SandboxConfig | undefined { const enabledRaw = platform === 'win32' ? sandbox?.[AgentHostSandboxKey.WindowsEnabled] @@ -161,6 +168,15 @@ export function buildSandboxConfigForSdk( readonly.add(p); } } + // Host-generated files the shell tool must be able to read (see + // `extraReadonlyPaths`). Routed through the same precedence sets as user + // paths so an explicit `denyRead` still wins, and so a path the user already + // made readwrite is not downgraded. + for (const p of extraReadonlyPaths ?? []) { + if (!denied.has(p) && !readonly.has(p) && !readwrite.has(p)) { + readonly.add(p); + } + } const allowNetwork = sandbox?.[AgentHostSandboxKey.AllowNetwork]; const allowBypass = sandbox?.[AgentHostSandboxKey.AllowUnsandboxedCommands] ?? false; diff --git a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts index 340d3c1347632..7ff222c3631e9 100644 --- a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts @@ -8,6 +8,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import type { IConfigurationValue } from '../../../configuration/common/configuration.js'; import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostMarkdownPlanRichLinksEnabledConfigKey, createSchema, migrateLegacyAutopilotConfig, normalizeAgentHostTerminalAutoApproveRulesConfig, platformRootSchema, platformSessionSchema, schemaProperty, type AgentHostTerminalAutoApproveRules, type AutoApproveLevel, type IPermissionsValue, type SessionMode } from '../../common/agentHostSchema.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +import type { IShellInitScript } from '../../common/shellInitScript.js'; import { JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js'; /** @@ -343,6 +344,40 @@ suite('agentHostSchema', () => { assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Mode, 'shell'), false); assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Mode, 42), false); }); + + test('validates the shellInitScripts shape', () => { + assert.deepStrictEqual([ + platformSessionSchema.validate(SessionConfigKey.ShellInitScripts, []), + platformSessionSchema.validate(SessionConfigKey.ShellInitScripts, [{ shell: 'bash', script: 'x' }]), + platformSessionSchema.validate(SessionConfigKey.ShellInitScripts, [{ shell: 'zsh', script: 'x' }]), + platformSessionSchema.validate(SessionConfigKey.ShellInitScripts, [{ shell: 'bash' }]), + platformSessionSchema.validate(SessionConfigKey.ShellInitScripts, [{ shell: 'bash', script: 1 }]), + platformSessionSchema.validate(SessionConfigKey.ShellInitScripts, 'nope'), + ], [true, true, false, false, false, false]); + }); + + test('is marked read-only so it stays out of the session settings file', () => { + const property = platformSessionSchema.toProtocol().properties[SessionConfigKey.ShellInitScripts]; + assert.deepStrictEqual({ readOnly: property.readOnly, type: property.type }, { readOnly: true, type: 'array' }); + }); + + test('keeps a pushed shellInitScripts value and has no default of its own', () => { + const scripts = [{ shell: 'bash', script: 'x' }]; + // Mirrors `resolveChatConfig`, which supplies defaults only for + // autoApprove and mode. An absent value must stay absent so it is + // distinguishable from an explicit empty array (clear). + const defaults: { [SessionConfigKey.AutoApprove]: AutoApproveLevel;[SessionConfigKey.Mode]: SessionMode;[SessionConfigKey.ShellInitScripts]?: readonly IShellInitScript[] } = { + [SessionConfigKey.AutoApprove]: 'default', + [SessionConfigKey.Mode]: 'interactive', + }; + assert.deepStrictEqual(platformSessionSchema.validateOrDefault({ [SessionConfigKey.ShellInitScripts]: scripts }, defaults), { + ...defaults, + [SessionConfigKey.ShellInitScripts]: scripts, + }); + assert.deepStrictEqual(platformSessionSchema.validateOrDefault({}, defaults), defaults); + // An invalid pushed value must not survive into the resolved config. + assert.deepStrictEqual(platformSessionSchema.validateOrDefault({ [SessionConfigKey.ShellInitScripts]: 'nope' }, defaults), defaults); + }); }); // ---- legacy autopilot migration ---------------------------------------- diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index f138537d73871..a43bfdc2ee6a5 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -14055,7 +14055,13 @@ suite('AgentService (node dispatcher)', () => { const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); registerTestAgentProvider(localService, localAgent); - await localService.createSession({ provider: 'copilot', config: { autoApprove: 'autoApprove' } }); + await localService.createSession({ + provider: 'copilot', + config: { + autoApprove: 'autoApprove', + [SessionConfigKey.ShellInitScripts]: [{ shell: 'bash', script: 'export TRANSIENT=1' }], + }, + }); // Persistence is fire-and-forget; wait for it to flush await new Promise(r => setTimeout(r, 50)); @@ -14102,7 +14108,10 @@ suite('AgentService (node dispatcher)', () => { ))); registerTestAgentProvider(localService, localAgent); - await sessionDb.setMetadata('configValues', JSON.stringify({ autoApprove: 'autoApprove' })); + await sessionDb.setMetadata('configValues', JSON.stringify({ + autoApprove: 'autoApprove', + [SessionConfigKey.ShellInitScripts]: [{ shell: 'bash', script: 'export STALE=1' }], + })); const { session } = await createAgentSession(localAgent); localAgent.sessionMessages = [ { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, @@ -14115,9 +14124,11 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ isolation: values?.[SessionConfigKey.Isolation], autoApprove: values?.autoApprove, + shellInitScripts: values?.[SessionConfigKey.ShellInitScripts], }, { isolation: 'folder', autoApprove: 'autoApprove', + shellInitScripts: undefined, }); }); diff --git a/src/vs/platform/agentHost/test/node/chatContributions.test.ts b/src/vs/platform/agentHost/test/node/chatContributions.test.ts index 9562a5edca9c5..931bb10c53427 100644 --- a/src/vs/platform/agentHost/test/node/chatContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/chatContributions.test.ts @@ -24,6 +24,7 @@ import { withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js'; import { readAgentMessageDelegationMeta, toAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType } from '../../common/state/sessionActions.js'; +import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ChatOriginKind } from '../../common/state/protocol/state.js'; import { AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChatInteractivity, MessageKind, PendingMessageKind, ResponsePartKind, SessionStatus, TurnState, type ISessionGitHubState, type Message, type PendingMessage, type Turn } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; @@ -1549,7 +1550,11 @@ suite('AgentHostChatContributions', () => { test('skips rejected session flags while persisting config values', async () => { const contributions = createBuiltInContributions(disposables); - const config = { mode: 'plan', autoApprove: 'default' }; + const config = { + mode: 'plan', + autoApprove: 'default', + [SessionConfigKey.ShellInitScripts]: [{ shell: 'bash', script: 'export TRANSIENT=1' }], + }; contributions.stateManager.setSessionConfig(contributions.session, { schema: { type: 'object', properties: {} }, values: config, @@ -1566,7 +1571,7 @@ suite('AgentHostChatContributions', () => { }, { isRead: undefined, isArchived: undefined, - configValues: JSON.stringify(config), + configValues: JSON.stringify({ mode: 'plan', autoApprove: 'default' }), }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index f5fa5c641161b..6a2529cc97c68 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -953,6 +953,7 @@ function createTestAgentContext(disposables: Pick, optio _serviceBrand: undefined, userHome: options?.userHome ?? URI.from({ scheme: Schemas.inMemory, path: '/mock-home' }), tmpDir: URI.from({ scheme: Schemas.inMemory, path: '/mock-tmp' }), + userDataPath: '/mock-userdata', } as INativeEnvironmentService; services.set(INativeEnvironmentService, environmentService); } diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index be20f39eac742..b09b36b958472 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -19,7 +19,7 @@ import { join, sep } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { INativeEnvironmentService } from '../../../environment/common/environment.js'; -import { IFileService } from '../../../files/common/files.js'; +import { FileSystemProviderCapabilities, IFileService, type IWriteFileOptions } from '../../../files/common/files.js'; import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; @@ -47,9 +47,10 @@ import { CustomizationEnablementKind, CustomizationType, McpAuthRequiredReason, import { CopilotAgentSession } from '../../node/copilot/copilotAgentSession.js'; import { buildNonPtyShellTerminalUri } from '../../node/copilot/copilotNonPtyShellTerminals.js'; import { buildMcpChannel } from '../../node/shared/mcpCustomizationController.js'; -import { buildSandboxConfigForSdk } from '../../node/copilot/sandboxConfigForSdk.js'; +import { buildSandboxConfigForSdk, type SandboxConfig } from '../../node/copilot/sandboxConfigForSdk.js'; import { ActiveClientToolSet } from '../../node/activeClientState.js'; import { type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from '../../node/copilot/copilotSessionLauncher.js'; +import { type IShellInitScript } from '../../common/shellInitScript.js'; import { CopilotSessionWrapper } from '../../node/copilot/copilotSessionWrapper.js'; import { AgentHostStateManager, IAgentHostStateManager } from '../../node/agentHostStateManager.js'; import { IAgentHostCustomizationEnablementService, type CustomizationEnablementResolution, type ICustomizationEnablementTarget } from '../../node/agentHostCustomizationEnablementService.js'; @@ -100,6 +101,7 @@ class MockCopilotSession { readonly experimentalModeUpdates: boolean[] = []; experimentalModeUpdateSuccess = true; sandboxConfigUpdateSuccess = true; + shellInitScriptUpdateSuccess = true; abortCalls = 0; abortGate: Promise | undefined; readonly compactCalls: unknown[] = []; @@ -434,7 +436,7 @@ class MockCopilotSession { cancelSamplingExecution: async () => { /* no-op */ }, }, options: { - update: async (params: { sandboxConfig?: unknown; isExperimentalMode?: boolean }) => { + update: async (params: { sandboxConfig?: unknown; isExperimentalMode?: boolean; shell?: { initScripts?: unknown } }) => { if (params.sandboxConfig !== undefined) { this.operationLog.push('options.update:sandbox'); this.sandboxConfigUpdates.push(params.sandboxConfig); @@ -442,6 +444,11 @@ class MockCopilotSession { if (params.isExperimentalMode !== undefined) { this.experimentalModeUpdates.push(params.isExperimentalMode); } + if (params.shell !== undefined) { + this.operationLog.push('options.update:shell'); + this.shellInitScriptUpdates.push(params.shell.initScripts); + return { success: this.shellInitScriptUpdateSuccess }; + } return { success: params.sandboxConfig !== undefined ? this.sandboxConfigUpdateSuccess : this.experimentalModeUpdateSuccess }; }, }, @@ -472,6 +479,7 @@ class MockCopilotSession { }; readonly sandboxConfigUpdates: unknown[] = []; + readonly shellInitScriptUpdates: unknown[] = []; mcpListResult: { servers: ReadonlyArray<{ name: string; status: 'connected' | 'failed' | 'needs-auth' | 'pending' | 'disabled' | 'not_configured'; error?: string }> } = { servers: [] }; mcpListError: unknown = undefined; @@ -704,6 +712,12 @@ async function createAgentSession(disposables: DisposableStore, options?: { rootValues?: Record; fileContents?: Record; fileReadErrors?: readonly string[]; + shellInitWriteFailures?: number; + fileAtomicWrite?: boolean; + shellInitWriteGate?: Promise; + onShellInitWrite?: () => void; + /** Values visible only through `getEffectiveValue`, as if inherited from root or a parent session. */ + inheritedConfigValues?: Record; sessionDatabase?: ISessionDatabase; /** Configure the mock session before {@link CopilotAgentSession.initializeSession} runs. */ configureMockSession?: (session: MockCopilotSession) => void; @@ -742,6 +756,8 @@ async function createAgentSession(disposables: DisposableStore, options?: { signals: AgentSignal[]; waitForSignal: (predicate: (signal: AgentSignal) => boolean) => Promise; terminalManager: TestAgentHostTerminalManager; + storedFileContents: ReadonlyMap; + fileWriteOptions: ReadonlyMap; dispatchedActions: readonly StateAction[]; sessionConfigUpdates: ReadonlyArray<{ session: string; patch: Record }>; setConfigValue: (key: string, value: unknown) => void; @@ -840,6 +856,8 @@ async function createAgentSession(disposables: DisposableStore, options?: { copilotApiService.restrictedTelemetryContextError = options?.restrictedTelemetryContextError; services.set(ICopilotApiService, copilotApiService); const storedFileContents = new Map(Object.entries(options?.fileContents ?? {})); + const fileWriteOptions = new Map(); + let shellInitWriteFailures = options?.shellInitWriteFailures ?? 0; services.set(IFileService, { _serviceBrand: undefined, readFile: async (resource: URI) => { @@ -849,15 +867,38 @@ async function createAgentSession(disposables: DisposableStore, options?: { return { value: VSBuffer.fromString(storedFileContents.get(resource.toString()) ?? storedFileContents.get(resource.fsPath) ?? '') }; }, exists: async (resource: URI) => storedFileContents.has(resource.toString()) || storedFileContents.has(resource.fsPath), - writeFile: async (resource: URI, content: VSBuffer) => { + hasCapability: (resource: URI, capability: FileSystemProviderCapabilities) => + options?.fileAtomicWrite === true && + capability === FileSystemProviderCapabilities.FileAtomicWrite && + resource.path.includes('/agentHost/shellInit/'), + writeFile: async (resource: URI, content: VSBuffer, writeOptions?: IWriteFileOptions) => { + fileWriteOptions.set(resource.fsPath, writeOptions); + if (resource.path.includes('/agentHost/shellInit/')) { + options?.onShellInitWrite?.(); + await options?.shellInitWriteGate; + if (shellInitWriteFailures > 0) { + shellInitWriteFailures--; + throw new Error('write failed'); + } + } storedFileContents.set(resource.toString(), content.toString()); return { resource } as Awaited>; }, - del: async (resource: URI) => { - storedFileContents.delete(resource.toString()); - storedFileContents.delete(resource.fsPath); + del: async (resource: URI, delOptions?: { recursive?: boolean }) => { + if (resource.path.includes('/agentHost/shellInit/')) { + mockSession.operationLog.push('file.delete:shellInit'); + } + const matches = [...storedFileContents.keys()].filter(key => key.startsWith(resource.toString()) || key.startsWith(resource.fsPath)); + // Like the disk provider, a non-recursive delete removes only an + // empty directory and fails while descendants remain. + if (!delOptions?.recursive && matches.some(key => key !== resource.toString() && key !== resource.fsPath)) { + throw new Error('ENOTEMPTY: directory not empty'); + } + for (const key of matches) { + storedFileContents.delete(key); + } }, - } as Partial as IFileService); + } as unknown as IFileService); services.set(ISessionDataService, createSessionDataService(options?.sessionDatabase)); services.set(IDiffComputeService, createZeroDiffComputeService()); const sessionConfigUpdates: Array<{ session: string; patch: Record }> = []; @@ -876,9 +917,12 @@ async function createAgentSession(disposables: DisposableStore, options?: { // session class will read. Gated on `sessionUri` (the owning // session/configuration scope) so tests can catch a caller that // mistakenly reads with a peer chat's own resource URI instead. - getEffectiveValue: ((session: string, _schema: unknown, key: string) => session === sessionUri.toString() ? configValues[key] : undefined) as IAgentConfigurationService['getEffectiveValue'], + getEffectiveValue: ((session: string, _schema: unknown, key: string) => session === sessionUri.toString() ? (configValues[key] ?? options?.inheritedConfigValues?.[key]) : undefined) as IAgentConfigurationService['getEffectiveValue'], getEffectiveWorkingDirectories: () => undefined, - getSessionConfigValues: () => undefined, + // Own values only, like the real service; `inheritedConfigValues` is + // visible through `getEffectiveValue` alone so tests can prove a + // consumer does not fall through to root or parent config. + getSessionConfigValues: (session: string) => session === sessionUri.toString() ? configValues : undefined, updateSessionConfig: (session, patch) => { sessionConfigUpdates.push({ session, patch }); }, getRootValue: ((_schema: unknown, key: string) => rootValues[key]) as IAgentConfigurationService['getRootValue'], updateRootConfig: () => { /* no-op */ }, @@ -959,6 +1003,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { _serviceBrand: undefined, userHome: URI.file('/mock-home'), tmpDir: URI.file('/mock-tmp'), + userDataPath: '/mock-userdata', } as INativeEnvironmentService; if (options?.environmentServiceRegistration !== 'none') { services.set(INativeEnvironmentService, environmentService); @@ -1011,6 +1056,8 @@ async function createAgentSession(disposables: DisposableStore, options?: { signals, waitForSignal, terminalManager, + storedFileContents, + fileWriteOptions, dispatchedActions: stateManager.dispatchedActions, sessionConfigUpdates, setConfigValue: (key, value) => { configValues[key] = value; }, @@ -1039,6 +1086,13 @@ function expectedSnapshotReadonlyNote(paths: string[]): string { + paths.map(path => `- ${path}`).join('\n'); } +/** + * Session-scoped shell init root granted read access while a script is active. + * Scripts land in an instance-scoped subdirectory beneath it. + */ +const TEST_SHELL_INIT_DIRECTORY = URI.file('/mock-userdata/agentHost/shellInit/test-session-1'); +const TEST_SHELL_INIT_DIR = TEST_SHELL_INIT_DIRECTORY.fsPath; + suite('CopilotAgentSession', () => { const disposables = new DisposableStore(); @@ -12268,4 +12322,344 @@ Use the attached image as context. assert.strictEqual(mockSession.getInstructionSourcesCallCount, 1); }); }); + suite('shell init scripts', () => { + + const initScript = { shell: 'bash', script: 'activate' } satisfies IShellInitScript; + + /** The workbench forwards its setting into root config; the host applies nothing without it. */ + function createEnabledSession(options?: Parameters[1]) { + return createAgentSession(disposables, { + ...options, + rootValues: { [CopilotCliConfigKey.EnableShellInitScript]: true, ...options?.rootValues }, + }); + } + + test('grants sandbox access before initial SDK registration', async () => { + const { mockSession } = await createEnabledSession({ + rootValues: { [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On } }, + configValues: { [SessionConfigKey.ShellInitScripts]: [initScript] }, + }); + + assert.ok( + mockSession.operationLog.indexOf('options.update:sandbox') < mockSession.operationLog.indexOf('options.update:shell'), + JSON.stringify(mockSession.operationLog), + ); + }); + + test('does not apply a session script while the host flag is off', async () => { + const { session, mockSession, storedFileContents, setConfigValue } = await createAgentSession(disposables); + setConfigValue(SessionConfigKey.ShellInitScripts, [initScript]); + + await session.send('go', undefined, 'turn-1', 'interactive'); + + // The host honors the forwarded setting regardless of the session value. + assert.deepStrictEqual({ + registered: mockSession.shellInitScriptUpdates, + materialized: [...storedFileContents.keys()].some(key => key.includes('/agentHost/shellInit/')), + }, { + registered: [], + materialized: false, + }); + }); + + test('unregisters when the host flag turns off', async () => { + const { session, mockSession, setConfigValue, setRootValue, fireRootConfigChange } = await createEnabledSession(); + setConfigValue(SessionConfigKey.ShellInitScripts, [initScript]); + await session.send('go', undefined, 'turn-1', 'interactive'); + + setRootValue(CopilotCliConfigKey.EnableShellInitScript, false); + fireRootConfigChange(); + await timeout(0); + + assert.deepStrictEqual(mockSession.shellInitScriptUpdates.map(update => (update as unknown[]).length), [1, 0]); + }); + + test('unregisters when the host flag turns off even if the session value became malformed', async () => { + const { session, mockSession, setConfigValue, setRootValue, fireRootConfigChange } = await createEnabledSession(); + setConfigValue(SessionConfigKey.ShellInitScripts, [initScript]); + await session.send('go', undefined, 'turn-1', 'interactive'); + + // The off state is decided before the payload is validated. + setConfigValue(SessionConfigKey.ShellInitScripts, [initScript, { ...initScript, script: 'second' }]); + setRootValue(CopilotCliConfigKey.EnableShellInitScript, false); + fireRootConfigChange(); + await timeout(0); + + assert.deepStrictEqual(mockSession.shellInitScriptUpdates.map(update => (update as unknown[]).length), [1, 0]); + }); + + test('unregisters when the custom terminal tool replaces the SDK shell mid-session', async () => { + const { session, mockSession, setConfigValue, setRootValue, fireRootConfigChange } = await createEnabledSession(); + setConfigValue(SessionConfigKey.ShellInitScripts, [initScript]); + await session.send('go', undefined, 'turn-1', 'interactive'); + + setRootValue(CopilotCliConfigKey.EnableCustomTerminalTool, true); + fireRootConfigChange(); + await timeout(0); + + assert.deepStrictEqual(mockSession.shellInitScriptUpdates.map(update => (update as unknown[]).length), [1, 0]); + }); + + test('ignores a script that only exists in inherited config', async () => { + // Root and parent-session values must never reach the shell. + const { session, mockSession, storedFileContents } = await createEnabledSession({ + inheritedConfigValues: { [SessionConfigKey.ShellInitScripts]: [initScript] }, + }); + + await session.send('go', undefined, 'turn-1', 'interactive'); + + assert.deepStrictEqual({ + registered: mockSession.shellInitScriptUpdates, + materialized: [...storedFileContents.keys()].some(key => key.includes('/agentHost/shellInit/')), + }, { + registered: [], + materialized: false, + }); + }); + + test('does not register a shell init script when the sandbox update fails', async () => { + const { session, mockSession, storedFileContents, setConfigValue } = await createEnabledSession(); + mockSession.sandboxConfigUpdateSuccess = false; + setConfigValue(SessionConfigKey.ShellInitScripts, [initScript]); + + await session.send('go', undefined, 'turn-1', 'interactive'); + + // Best-effort: the turn still runs, just without the script. The file + // is written before the grant, so only registration is withheld. + assert.deepStrictEqual({ + registered: mockSession.shellInitScriptUpdates, + materialized: [...storedFileContents.keys()].some(key => key.includes('/agentHost/shellInit/')), + sends: mockSession.sendRequests.length, + }, { + registered: [], + materialized: true, + sends: 1, + }); + }); + + test('retries materialization after a write failure', async () => { + const { session, mockSession, setConfigValue } = await createEnabledSession({ shellInitWriteFailures: 1 }); + setConfigValue(SessionConfigKey.ShellInitScripts, [initScript]); + + await session.send('go', undefined, 'turn-1', 'interactive'); + assert.deepStrictEqual(mockSession.shellInitScriptUpdates, []); + + await session.send('go', undefined, 'turn-2', 'interactive'); + assert.strictEqual(mockSession.shellInitScriptUpdates.length, 1); + }); + + test('uses atomic writes when the file provider supports them', async () => { + const { mockSession, fileWriteOptions } = await createEnabledSession({ + configValues: { [SessionConfigKey.ShellInitScripts]: [initScript] }, + fileAtomicWrite: true, + }); + const scriptPath = (mockSession.shellInitScriptUpdates.at(-1) as Array<{ path: string }>)?.[0]?.path; + + assert.deepStrictEqual(scriptPath ? fileWriteOptions.get(scriptPath)?.atomic : undefined, { postfix: '.vsctmp' }); + }); + + test('materializes, registers, rewrites in place, and clears', async () => { + const { session, mockSession, storedFileContents, setConfigValue, fireSessionConfigChange } = await createEnabledSession(); + setConfigValue(SessionConfigKey.ShellInitScripts, [initScript]); + + await session.send('go', undefined, 'turn-1', 'interactive'); + const scriptPath = (mockSession.shellInitScriptUpdates.at(-1) as Array<{ path: string }>)?.[0]?.path; + assert.ok(scriptPath?.startsWith(TEST_SHELL_INIT_DIR) && scriptPath.endsWith('init.sh'), String(scriptPath)); + + await session.send('go', undefined, 'turn-2', 'interactive'); + assert.strictEqual(mockSession.shellInitScriptUpdates.length, 1); + + // Changed content is rewritten at the registered path. The runtime + // re-reads the file before each command, so no RPC is needed. + setConfigValue(SessionConfigKey.ShellInitScripts, [{ ...initScript, script: 'changed' }]); + fireSessionConfigChange({ [SessionConfigKey.ShellInitScripts]: [{ ...initScript, script: 'changed' }] }); + await timeout(0); + assert.deepStrictEqual({ + updates: mockSession.shellInitScriptUpdates.length, + content: storedFileContents.get(URI.file(scriptPath).toString()), + }, { + updates: 1, + content: 'changed', + }); + + // Clearing unregisters but keeps the file until dispose, so a command + // already holding the path can still source it. + setConfigValue(SessionConfigKey.ShellInitScripts, []); + await session.send('go', undefined, 'turn-3', 'interactive'); + assert.deepStrictEqual({ + updates: mockSession.shellInitScriptUpdates, + retained: storedFileContents.has(URI.file(scriptPath).toString()), + }, { + updates: [[{ shell: 'bash', path: scriptPath }], []], + retained: true, + }); + + session.dispose(); + await timeout(0); + assert.ok(![...storedFileContents.keys()].some(key => key.includes('/agentHost/shellInit/'))); + }); + + test('keeps the last valid registration when config becomes malformed', async () => { + const { session, mockSession, setConfigValue, fireSessionConfigChange } = await createEnabledSession(); + setConfigValue(SessionConfigKey.ShellInitScripts, [initScript]); + await session.send('go', undefined, 'turn-1', 'interactive'); + const registered = mockSession.shellInitScriptUpdates.at(-1); + + const malformed = [initScript, { ...initScript, script: 'second' }]; + setConfigValue(SessionConfigKey.ShellInitScripts, malformed); + fireSessionConfigChange({ [SessionConfigKey.ShellInitScripts]: malformed }); + await timeout(0); + await session.send('go', undefined, 'turn-2', 'interactive'); + + assert.deepStrictEqual(mockSession.shellInitScriptUpdates, [registered]); + }); + + test('each instance owns a distinct directory so a stale cleanup cannot delete a successor script', async () => { + // dispose() queues the deletion without awaiting it; a resumed + // replacement for the same SDK session may register its script first. + const first = await createEnabledSession(); + const second = await createEnabledSession(); + first.setConfigValue(SessionConfigKey.ShellInitScripts, [initScript]); + second.setConfigValue(SessionConfigKey.ShellInitScripts, [initScript]); + + await first.session.send('go', undefined, 'turn-1', 'interactive'); + await second.session.send('go', undefined, 'turn-1', 'interactive'); + + const firstPath = (first.mockSession.shellInitScriptUpdates.at(-1) as Array<{ path: string }>)?.[0]?.path; + const secondPath = (second.mockSession.shellInitScriptUpdates.at(-1) as Array<{ path: string }>)?.[0]?.path; + assert.ok(firstPath && secondPath && firstPath !== secondPath, `${firstPath} vs ${secondPath}`); + }); + + test('does nothing when the custom terminal tool replaces the SDK shell', async () => { + const { session, mockSession, setConfigValue } = await createEnabledSession({ + rootValues: { [CopilotCliConfigKey.EnableCustomTerminalTool]: true }, + }); + setConfigValue(SessionConfigKey.ShellInitScripts, [initScript]); + + await session.send('go', undefined, 'turn-1', 'interactive'); + + assert.deepStrictEqual(mockSession.shellInitScriptUpdates, []); + }); + + test('removes its own script on dispose and leaves a successor instance script intact', async () => { + const successorScript = '/mock-userdata/agentHost/shellInit/test-session-1/successor-instance/init.sh'; + const { session, storedFileContents, setConfigValue } = await createEnabledSession({ + fileContents: { [successorScript]: 'successor' }, + }); + setConfigValue(SessionConfigKey.ShellInitScripts, [initScript]); + await session.send('go', undefined, 'turn-1', 'interactive'); + assert.strictEqual([...storedFileContents.keys()].filter(key => key.includes('/test-session-1/') && key.endsWith('.sh')).length, 2); + + // The session-directory prune must not take the successor's script + // with it; only this instance's directory may be removed. + session.dispose(); + await timeout(0); + assert.deepStrictEqual([...storedFileContents.keys()].filter(key => key.includes('/test-session-1/')), [successorScript]); + }); + + test('does not grant shell init directory read access when no script is configured', async () => { + const { session, mockSession, setRootValue } = await createEnabledSession(); + setRootValue(AgentHostSandboxConfigKey.Sandbox, { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On }); + + await session.send('go', undefined, 'turn-1', 'interactive'); + + const sandboxConfig = mockSession.sandboxConfigUpdates.at(-1) as SandboxConfig | undefined; + assert.ok(!sandboxConfig?.userPolicy?.filesystem?.readonlyPaths?.includes(TEST_SHELL_INIT_DIR)); + }); + + test('grants the shell init directory read access while a script is configured', async () => { + const { session, mockSession, setConfigValue, setRootValue } = await createEnabledSession(); + setRootValue(AgentHostSandboxConfigKey.Sandbox, { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On }); + setConfigValue(SessionConfigKey.ShellInitScripts, [initScript]); + + await session.send('go', undefined, 'turn-1', 'interactive'); + + // The SDK fails silently when an init script is outside the sandbox + // read policy, so the per-turn policy must include this directory. + const sandboxConfig = mockSession.sandboxConfigUpdates.at(-1) as SandboxConfig | undefined; + assert.ok( + sandboxConfig?.userPolicy?.filesystem?.readonlyPaths?.includes(TEST_SHELL_INIT_DIR), + JSON.stringify(sandboxConfig?.userPolicy?.filesystem), + ); + }); + + test('unregisters on clear and keeps the file and sandbox grant until dispose', async () => { + const { session, mockSession, storedFileContents, setConfigValue, setRootValue } = await createEnabledSession(); + setRootValue(AgentHostSandboxConfigKey.Sandbox, { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On }); + setConfigValue(SessionConfigKey.ShellInitScripts, [initScript]); + await session.send('go', undefined, 'turn-1', 'interactive'); + const scriptPath = (mockSession.shellInitScriptUpdates.at(-1) as Array<{ path: string }>)[0].path; + + mockSession.operationLog.length = 0; + setConfigValue(SessionConfigKey.ShellInitScripts, []); + await session.send('go', undefined, 'turn-2', 'interactive'); + + // A command that already captured the path can still read the file. + assert.deepStrictEqual({ + operations: mockSession.operationLog.filter(operation => operation === 'options.update:shell' || operation === 'file.delete:shellInit'), + hasGrant: (mockSession.sandboxConfigUpdates.at(-1) as SandboxConfig | undefined)?.userPolicy?.filesystem?.readonlyPaths?.includes(TEST_SHELL_INIT_DIR) ?? false, + retainedContent: storedFileContents.get(URI.file(scriptPath).toString()), + }, { + operations: ['options.update:shell'], + hasGrant: true, + retainedContent: initScript.script, + }); + }); + + test('does not delete shell init files on dispose when none were materialized', async () => { + const { session, mockSession } = await createEnabledSession(); + + session.dispose(); + await timeout(0); + + assert.ok(!mockSession.operationLog.includes('file.delete:shellInit')); + }); + + test('deletes a shell init script materialized while disposal waits for an in-flight update', async () => { + const writeStarted = new DeferredPromise(); + const writeGate = new DeferredPromise(); + const { session, storedFileContents, setConfigValue } = await createEnabledSession({ + shellInitWriteGate: writeGate.p, + onShellInitWrite: () => writeStarted.complete(), + }); + setConfigValue(SessionConfigKey.ShellInitScripts, [initScript]); + const send = session.send('go', undefined, 'turn-1', 'interactive'); + await writeStarted.p; + + session.dispose(); + writeGate.complete(); + await send; + await timeout(0); + + assert.ok(![...storedFileContents.keys()].some(key => key.includes('/agentHost/shellInit/'))); + }); + + test('a failed registration is logged without aborting the turn', async () => { + const { session, mockSession, setConfigValue } = await createEnabledSession(); + mockSession.shellInitScriptUpdateSuccess = false; + setConfigValue(SessionConfigKey.ShellInitScripts, [initScript]); + + await session.send('go', undefined, 'turn-1', 'interactive'); + + assert.deepStrictEqual({ + abortCalls: mockSession.abortCalls, + sends: mockSession.sendRequests.length, + }, { + abortCalls: 0, + sends: 1, + }); + }); + + test('removes the script on dispose even when disconnect fails', async () => { + const { session, mockSession, storedFileContents, setConfigValue } = await createEnabledSession(); + setConfigValue(SessionConfigKey.ShellInitScripts, [initScript]); + await session.send('go', undefined, 'turn-1', 'interactive'); + mockSession.disconnectError = new Error('disconnect failed'); + + session.dispose(); + await timeout(0); + + assert.ok(![...storedFileContents.keys()].some(key => key.includes('/agentHost/shellInit/'))); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md index ade6ec52a3029..e64b541415b7d 100644 --- a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md +++ b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md @@ -703,6 +703,7 @@ Copilot's ordinary provider shell also omits `ToolResultTerminalContent.result.p - `lists workspace entries` - `runs a deterministic shell command` - `inspects git status` +- `shell init script runs before the shell command` Use the affected provider command with `--grep ""` and temporarily remove the platform gate to reevaluate a row. diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-shell-init-script-runs-before-the-shell-command.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-shell-init-script-runs-before-the-shell-command.yaml new file mode 100644 index 0000000000000..1d4f02bf3a553 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-shell-init-script-runs-before-the-shell-command.yaml @@ -0,0 +1,47 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: >- + Run exactly this shell command, with no modifications: `node -e + "console.log('marker=' + process.env.AHP_E2E_INIT_MARKER)"`. Then + reply with its exact output only. + response: + content: + - type: tool_use + id: toolcall_0 + name: ${shell} + input: + command: node -e "console.log('marker=' + process.env.AHP_E2E_INIT_MARKER)" + description: Print the init marker + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: >- + Run exactly this shell command, with no modifications: `node -e + "console.log('marker=' + process.env.AHP_E2E_INIT_MARKER)"`. Then + reply with its exact output only. + - role: assistant + content: + - type: tool_use + name: bash + input: + command: node -e "console.log('marker=' + process.env.AHP_E2E_INIT_MARKER)" + description: Print the init marker + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: |- + marker=init_marker_91 + + response: + content: marker=init_marker_91 + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_shell_init_script_runs_before_the_shell_command.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_shell_init_script_runs_before_the_shell_command.traffic.ahp.yaml new file mode 100644 index 0000000000000..5d1677598c59b --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_shell_init_script_runs_before_the_shell_command.traffic.ahp.yaml @@ -0,0 +1,57 @@ +version: 1 +rounds: + - clientToServer: + - channel: ${session_0} + action: + type: session/configChanged + - channel: ${chat_0} + action: + type: chat/turnStarted + turnId: ${turn_0} + message: + text: 'Run exactly this shell command, with no modifications: `node -e "console.log(''marker='' + process.env.AHP_E2E_INIT_MARKER)"`. Then reply with its exact output only.' + origin: + kind: user + serverToClient: + - channel: ${session_0} + action: + type: session/configChanged + - channel: ${chat_0} + action: + type: chat/turnStarted + turnId: ${turn_0} + message: + text: 'Run exactly this shell command, with no modifications: `node -e "console.log(''marker='' + process.env.AHP_E2E_INIT_MARKER)"`. Then reply with its exact output only.' + origin: + kind: user + - channel: ${session_0} + action: + type: session/titleChanged + - method: root/sessionAdded + - channel: ${session_0} + action: + type: session/ready + - channel: ${chat_0} + action: + type: chat/toolCallStart + turnId: ${turn_0} + toolCallId: ${toolCall_0} + toolName: ${shell} + - channel: ${chat_0} + action: + type: chat/toolCallComplete + turnId: ${turn_0} + toolCallId: ${toolCall_0} + result: + success: true + - channel: ${chat_0} + action: + type: chat/responsePart + turnId: ${turn_0} + part: + kind: markdown + content: marker=init_marker_91 + - channel: ${chat_0} + action: + type: chat/turnComplete + turnId: ${turn_0} diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/fileOperationsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/fileOperationsSuite.ts index 4ea333120b02d..a17383178bd31 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/fileOperationsSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/fileOperationsSuite.ts @@ -9,6 +9,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from import { tmpdir } from 'os'; import { join } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { CopilotCliConfigKey } from '../../../../common/copilotCliConfig.js'; import { SessionConfigKey } from '../../../../common/sessionConfigKeys.js'; import { buildDefaultChatUri, getInlineToolInput, ROOT_STATE_URI, ToolCallCancellationReason, ToolResultContentType, type ToolResultFileEditContent } from '../../../../common/state/sessionState.js'; import type { StringOrMarkdown } from '../../../../common/state/protocol/state.js'; @@ -254,6 +255,61 @@ export function defineFileOperationsTests(context: IAgentHostE2ETestContext): vo responseEndsWithCreated: true, }); }); + + (portableShellToolReplayEnabled && shellOutputOracleAvailable ? test : test.skip)('shell init script runs before the shell command', async function () { + this.timeout(180_000); + const workspace = mkdtempSync(join(tmpdir(), 'ahp-shell-init-')); + tempDirs.push(workspace); + const sessionUri = await createRealSession(context.client, config, 'shell-init-script', createdSessions, URI.file(workspace)); + // The host applies a published script only while the client's setting + // is forwarded as root config. Set it before the recorded round so the + // snapshot stays limited to the session config and the turn. + await context.client.call('subscribe', { channel: ROOT_STATE_URI }); + context.client.dispatch({ + channel: ROOT_STATE_URI, + clientSeq: 1, + action: { type: ActionType.RootConfigChanged, config: { [CopilotCliConfigKey.EnableShellInitScript]: true } }, + }); + await context.client.waitForNotification(n => + isActionNotification(n, ActionType.RootConfigChanged) + && getActionEnvelope(n).channel === ROOT_STATE_URI + && (getActionEnvelope(n).action as { readonly config?: Record }).config?.[CopilotCliConfigKey.EnableShellInitScript] === true, + 30_000, + ); + // Leave the root channel so its later notifications stay out of the + // recorded round, then drop the root exchange from the recorder. + context.client.notify('unsubscribe', { channel: ROOT_STATE_URI }); + context.client.clearAhpSnapshot(); + // Session config carries script text; the host materializes the file + // and registers it through the SDK's `shell.initScripts`. The first + // turn is dispatched immediately afterward: dispatch is ordered per + // connection and the host applies config before starting the turn, + // so no server echo is awaited. + context.client.beginAhpSnapshotRound(); + context.client.dispatch({ + channel: sessionUri, + clientSeq: 1, + action: { + type: ActionType.SessionConfigChanged, + config: { [SessionConfigKey.ShellInitScripts]: [{ shell: 'bash', script: 'export AHP_E2E_INIT_MARKER=init_marker_91\nbuiltin true\n' }] }, + }, + }); + + // `node -e` keeps the recorded command platform-neutral; the marker can + // only be present if the registered init script ran first. + const markerCommand = `node -e "console.log('marker=' + process.env.AHP_E2E_INIT_MARKER)"`; + const result = await driveTurnToCompletion(context.client, sessionUri, 'turn-shell-init', `Run exactly this shell command, with no modifications: \`${markerCommand}\`. Then reply with its exact output only.`, 2); + assert.match(result.responseText, /marker=init_marker_91/); + assertToolCallCompleteText(context.client, { + channel: buildDefaultChatUri(sessionUri), + turnId: 'turn-shell-init', + toolNames: [config.shellToolName], + workspace, + expected: [/marker=init_marker_91/], + success: true, + }); + await assertRecordedAhpSnapshot(this.test!, context.client, BEHAVIOR_SNAPSHOT); + }); } fileOperationTest(context, 'reads an existing text file', async function () { diff --git a/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts b/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts index 28b914f56e6cc..16fc5fa0fd05f 100644 --- a/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts +++ b/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts @@ -249,4 +249,38 @@ suite('buildSandboxConfigForSdk', () => { }); }); }); + + suite('extraReadonlyPaths', () => { + + test('grants read access to host-generated paths', () => { + assert.deepStrictEqual(buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.On), ['/data/shellInit/s1'])?.userPolicy?.filesystem, { + readonlyPaths: ['/data/shellInit/s1'], + clearPolicyOnExit: true, + }); + }); + + test('keeps user denyRead winning over a host-generated path', () => { + assert.deepStrictEqual(buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.On, { denyRead: ['/data/shellInit/s1'] }), ['/data/shellInit/s1'])?.userPolicy?.filesystem, { + deniedPaths: ['/data/shellInit/s1'], + clearPolicyOnExit: true, + }); + }); + + test('does not downgrade a path the user already made readwrite', () => { + assert.deepStrictEqual(buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.On, { allowWrite: ['/work'] }), ['/work'])?.userPolicy?.filesystem, { + readwritePaths: ['/work'], + clearPolicyOnExit: true, + }); + }); + + test('changes nothing when omitted or empty', () => { + const base = buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.On, { allowRead: ['/repo'] })); + assert.deepStrictEqual(buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.On, { allowRead: ['/repo'] }), []), base); + assert.deepStrictEqual(buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.On, { allowRead: ['/repo'] }), undefined), base); + }); + + test('stays undefined when sandboxing is off, regardless of extra paths', () => { + assert.strictEqual(buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.Off), ['/data/shellInit/s1']), undefined); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/shellInitScript.test.ts b/src/vs/platform/agentHost/test/node/shellInitScript.test.ts new file mode 100644 index 0000000000000..aa31c74d3db8f --- /dev/null +++ b/src/vs/platform/agentHost/test/node/shellInitScript.test.ts @@ -0,0 +1,187 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { execFile } from 'child_process'; +import { mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { promisify } from 'util'; +import { join } from '../../../../base/common/path.js'; +import { decodeBase64 } from '../../../../base/common/buffer.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { createShellInitScript, isShellInitScriptList } from '../../common/shellInitScript.js'; + +const execFileAsync = promisify(execFile); + +suite('shellInitScript', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('combines bash profile loading before Python activation and ends successfully', () => { + const { script } = createShellInitScript('bash', ' source /repo/.venv/bin/activate'); + assert.deepStrictEqual({ + profileBeforeActivation: script.indexOf(`source "$HOME/.bashrc"`) < script.indexOf('source /repo/.venv/bin/activate'), + doesNotInferFailureFromRcStatus: script.includes(`source "$HOME/.bashrc" || builtin true`), + doesNotPrintAProfileFailure: !script.includes('loading ~/.bashrc failed'), + endsSuccessfully: script.trimEnd().endsWith('builtin true'), + }, { + profileBeforeActivation: true, + doesNotInferFailureFromRcStatus: true, + doesNotPrintAProfileFailure: true, + endsSuccessfully: true, + }); + }); + + test('combines PowerShell profile loading before Python activation', () => { + const { script } = createShellInitScript('powershell', `& 'C:\\repo\\.venv\\Scripts\\Activate.ps1'`); + assert.ok(script.indexOf('$PROFILE.CurrentUserAllHosts') < script.indexOf('FromBase64String')); + assert.ok(script.trimEnd().endsWith('$global:LASTEXITCODE = 0')); + }); + + test('PowerShell profiles load under Continue with per-profile isolation', () => { + const { script } = createShellInitScript('powershell', `& 'C:\\repo\\.venv\\Scripts\\Activate.ps1'`); + assert.deepStrictEqual({ + // The runtime sources init scripts under 'Stop'; profiles must get + // their normal preference back or a benign error skips the rest. + continueBeforeProfiles: script.includes(`$ErrorActionPreference = 'Continue'`) + && script.indexOf(`$ErrorActionPreference = 'Continue'`) < script.indexOf('$PROFILE.CurrentUserAllHosts'), + tryInsideForeach: script.includes('try {') && script.indexOf('foreach ($__vscodeProfile') < script.indexOf('try {'), + stopOnlyForActivation: script.indexOf(`'Stop'`) > script.lastIndexOf('$__vscodeProfile'), + }, { + continueBeforeProfiles: true, + tryInsideForeach: true, + stopOnlyForActivation: true, + }); + }); + + test('PowerShell activation uses total UTF-8 base64 encoding', () => { + const activation = `$value = @'\ncontains the old terminator\n'@\n$env:VSCODE_TEST_ACTIVATION = $value`; + const { script } = createShellInitScript('powershell', activation); + const match = /FromBase64String\('(?[A-Za-z0-9+/=]+)'\)/.exec(script); + assert.ok(match?.groups?.encoded); + assert.deepStrictEqual({ + decoded: decodeBase64(match.groups.encoded).toString(), + rawPayloadEmbedded: script.includes(activation), + }, { + decoded: activation, + rawPayloadEmbedded: false, + }); + }); + + test('accepts only an empty list or one valid script', () => { + assert.deepStrictEqual([ + isShellInitScriptList([]), + isShellInitScriptList([{ shell: 'bash', script: 'x' }]), + isShellInitScriptList([{ shell: 'bash', script: 'x' }, { shell: 'bash', script: 'y' }]), + isShellInitScriptList([{ shell: 'zsh', script: 'x' }]), + isShellInitScriptList([{ shell: 'bash', script: '' }]), + isShellInitScriptList([{ shell: 'bash', script: 'x'.repeat(64 * 1024 + 1) }]), + ], [true, true, false, false, false, false]); + }); + + (process.platform === 'win32' ? suite.skip : suite)('bash behavior', () => { + let home: string; + + setup(async () => { + home = await mkdtemp(join(tmpdir(), 'vscode-shell-init-')); + }); + + teardown(async () => { + await rm(home, { recursive: true, force: true }); + }); + + async function run(rc: string, activation: string | undefined, command: string): Promise { + await writeFile(join(home, '.bashrc'), rc, 'utf8'); + const { script } = createShellInitScript('bash', activation); + const { stdout } = await execFileAsync('bash', ['--norc', '--noprofile', '-c', `${script}\n${command}`], { + env: { ...process.env, HOME: home }, + }); + return stdout.trim().split('\n'); + } + + test('sources the rc before the activation command runs', async () => { + assert.deepStrictEqual( + await run('export VSCODE_TEST_RC_MARKER=loaded\n', 'builtin echo "activation sees rc=$VSCODE_TEST_RC_MARKER"', 'builtin true'), + ['activation sees rc=loaded'], + ); + }); + + test('does not report a profile failure when the rc ends nonzero', async () => { + assert.deepStrictEqual( + await run('export VSCODE_TEST_RC_MARKER=loaded\n[ -f /definitely/not/here ] && export NEVER=1\n', 'builtin echo "activation sees rc=$VSCODE_TEST_RC_MARKER"', 'builtin true'), + ['activation sees rc=loaded'], + ); + }); + + test('continues to activation when a non-interactive rc guard returns early', async () => { + const guardedRc = [ + 'case $- in', + '\t*i*) ;;', + '\t*) return;;', + 'esac', + 'export VSCODE_TEST_RC_MARKER=loaded', + '', + ].join('\n'); + assert.deepStrictEqual( + await run(guardedRc, 'builtin echo "activation sees rc=${VSCODE_TEST_RC_MARKER:-skipped}"', 'builtin true'), + ['activation sees rc=skipped'], + ); + }); + + test('reports a failed activation, runs the command, and leaves status zero', async () => { + assert.deepStrictEqual( + await run('', 'source /definitely/not/here/activate', 'builtin echo "command-ran status=$?"'), + [ + 'copilot shell init: Python activation failed; continuing without the selected environment.', + 'command-ran status=0', + ], + ); + }); + + }); + + (process.platform === 'win32' ? suite : suite.skip)('PowerShell behavior', () => { + let profileDirectory: string; + + setup(async () => { + profileDirectory = await mkdtemp(join(tmpdir(), 'vscode-shell-init-powershell-')); + }); + + teardown(async () => { + await rm(profileDirectory, { recursive: true, force: true }); + }); + + test('decodes and executes the activation payload', async () => { + const { script } = createShellInitScript('powershell', `$env:VSCODE_TEST_ACTIVATION = 'loaded'`); + const command = [ + `$PROFILE = [pscustomobject]@{ CurrentUserAllHosts = ''; CurrentUserCurrentHost = '' }`, + script, + `Write-Output "activation=$env:VSCODE_TEST_ACTIVATION"`, + ].join('\n'); + const { stdout } = await execFileAsync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', command]); + assert.strictEqual(stdout.trim(), 'activation=loaded'); + }); + + test('loads each profile independently before activation', async () => { + const allHostsProfile = join(profileDirectory, 'all-hosts.ps1'); + const currentHostProfile = join(profileDirectory, 'current-host.ps1'); + await writeFile(allHostsProfile, `$env:VSCODE_TEST_ALL_HOSTS = 'loaded'\nthrow 'expected profile failure'\n`, 'utf8'); + await writeFile(currentHostProfile, `$env:VSCODE_TEST_CURRENT_HOST = 'loaded'\n`, 'utf8'); + const powerShellLiteral = (value: string) => `'${value.replaceAll(`'`, `''`)}'`; + const { script } = createShellInitScript('powershell', `$env:VSCODE_TEST_ACTIVATION = 'loaded'`); + const command = [ + `$PROFILE = [pscustomobject]@{ CurrentUserAllHosts = ${powerShellLiteral(allHostsProfile)}; CurrentUserCurrentHost = ${powerShellLiteral(currentHostProfile)} }`, + script, + `Write-Output "profiles=$env:VSCODE_TEST_ALL_HOSTS,$env:VSCODE_TEST_CURRENT_HOST activation=$env:VSCODE_TEST_ACTIVATION exit=$global:LASTEXITCODE"`, + ].join('\n'); + + const { stdout } = await execFileAsync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', command]); + + assert.deepStrictEqual(stdout.trim().split(/\r?\n/), [ + 'copilot shell init: loading the PowerShell profile failed; continuing.', + 'profiles=loaded,loaded activation=loaded exit=0', + ]); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts index fbf3b88f73a80..1ca811c4114c6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts @@ -288,7 +288,10 @@ export function isWellKnownAutoApproveSchema(schema: SessionConfigPropertySchema * included so the generic lane does not invent a chip for it. * * Host-owned worktree configuration also has no chip. Including those properties - * here keeps the generic lane from surfacing them in the chat input. + * here keeps the generic lane from surfacing them in the chat input. The same + * applies to `ShellInitScripts`, which is generated rather than user-edited: + * `readOnly` keeps it out of the session-settings file but does not by itself + * suppress the generic chip. */ export const WELL_KNOWN_PICKER_PROPERTIES: ReadonlySet = new Set([ SessionConfigKey.Mode, @@ -300,6 +303,7 @@ export const WELL_KNOWN_PICKER_PROPERTIES: ReadonlySet = new Set SessionConfigKey.WorktreeBranchTrack, SessionConfigKey.WorktreeCreateNewBranch, SessionConfigKey.WorktreeIncludeFiles, + SessionConfigKey.ShellInitScripts, ClaudeSessionConfigKey.PermissionMode, CodexSessionConfigKey.PermissionsPreset, ]); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCopilotCliSettingsContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCopilotCliSettingsContribution.ts index 403f83f819b83..c5d50edda9f62 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCopilotCliSettingsContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCopilotCliSettingsContribution.ts @@ -8,7 +8,7 @@ import { autorun } from '../../../../../../base/common/observable.js'; import { isObject } from '../../../../../../base/common/types.js'; import { IAgentHostEnablementService } from '../../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; -import { AgentHostAutoModeTiersEnabledSettingId, AgentHostCopilotModelCapabilityOverridesSettingId, AgentHostCopilotSdkLogLevelSettingId, AgentHostMultiTurnContextRoutingEnabledSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostReasoningSummaryEnabledSettingId, AgentHostToolSearchDeferThresholdSettingId, AgentHostToolSearchEnabledSettingId, CopilotCliConfigKey, CopilotSubagentModelGuidanceEnabledSettingId, normalizeToolSearchDeferThreshold, type CopilotCliModelCapabilityOverrides, type CopilotSdkLogLevelSetting } from '../../../../../../platform/agentHost/common/copilotCliConfig.js'; +import { AgentHostAutoModeTiersEnabledSettingId, AgentHostCopilotModelCapabilityOverridesSettingId, AgentHostCopilotSdkLogLevelSettingId, AgentHostMultiTurnContextRoutingEnabledSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostReasoningSummaryEnabledSettingId, AgentHostShellToolInitScriptEnabledSettingId, AgentHostToolSearchDeferThresholdSettingId, AgentHostToolSearchEnabledSettingId, CopilotCliConfigKey, CopilotSubagentModelGuidanceEnabledSettingId, normalizeToolSearchDeferThreshold, type CopilotCliModelCapabilityOverrides, type CopilotSdkLogLevelSetting } from '../../../../../../platform/agentHost/common/copilotCliConfig.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IWorkbenchContribution } from '../../../../../../workbench/common/contributions.js'; import { AgentHostRootConfigForwarder, type IForwardedRootConfigKey } from './agentHostRootConfigForwarder.js'; @@ -81,6 +81,14 @@ export class AgentHostCopilotCliSettingsContribution extends Disposable implemen }, registerTriggers: (store, push) => this._pushOnSettingChange(store, push, AgentHostCopilotModelCapabilityOverridesSettingId), }, + { + // The host applies a published shell init script only while this is + // true. Like every forwarded key it is client-writable root config, + // so it is the user's opt-in mirrored to the host, not authorization. + key: CopilotCliConfigKey.EnableShellInitScript, + computeValue: () => this._configurationService.getValue(AgentHostShellToolInitScriptEnabledSettingId) === true, + registerTriggers: (store, push) => this._pushOnSettingChange(store, push, AgentHostShellToolInitScriptEnabledSettingId), + }, ]; this._forwarder = this._register(new AgentHostRootConfigForwarder(keys, agentHostService)); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index c3c41e3e3a459..d0fe2ce64eb49 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -27,7 +27,7 @@ import type { ITextModel } from '../../../../../../editor/common/model.js'; import { IModelService } from '../../../../../../editor/common/services/model.js'; import { localize } from '../../../../../../nls.js'; import { AgentHostAllowSignedOutWhenUsableSettingId, AgentProvider, AgentSession, CODEX_AGENT_PROVIDER_ID, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; -import { agentHostAuthority } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import { agentHostAuthority, LOCAL_AGENT_HOST_AUTHORITY } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { isCustomizationEnabled } from '../../../../../../platform/agentHost/common/customizationEnablement.js'; import { findDeepestContainingWorkingDirectory } from '../../../../../../platform/agentHost/common/agentHostWorkingDirectories.js'; import { AgentHostElementAttachmentDisplayKind, getElementAttachmentCorrelationId, toElementAttachmentMeta } from '../../../../../../platform/agentHost/common/meta/agentElementAttachments.js'; @@ -102,6 +102,7 @@ import { IAgentCustomizationScope, IAgentHostActiveClientService } from './agent import { IAgentHostCustomizationService } from './agentHostCustomizationService.js'; import { IAgentHostSessionWorkingDirectoryResolver } from './agentHostSessionWorkingDirectoryResolver.js'; import { IAgentHostSessionWorkingDirectorySynchronizer } from './agentHostSessionWorkingDirectorySynchronizer.js'; +import { IAgentHostShellInitSynchronizer } from './agentHostShellInitSynchronizer.js'; import { IAgentHostNewSessionFolderService, computeWorkingDirectories } from './agentHostNewSessionFolderService.js'; import { AgentHostSnapshotController } from './agentHostSnapshotController.js'; import { AgentHostResponseFileChangesProvider } from './agentHostResponseFileChanges.js'; @@ -1078,6 +1079,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC * lives exactly as long as that session's {@link _sessionSubscriptions} entry. */ private readonly _workingDirectoryRegistrations = this._register(new DisposableMap()); + private readonly _shellInitRegistrations = this._register(new DisposableMap()); /** * Active default-chat subscriptions, keyed by backend session URI string. @@ -1134,6 +1136,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC @IAgentHostTerminalService private readonly _agentHostTerminalService: IAgentHostTerminalService, @IAgentHostSessionWorkingDirectoryResolver private readonly _workingDirectoryResolver: IAgentHostSessionWorkingDirectoryResolver, @IAgentHostSessionWorkingDirectorySynchronizer private readonly _workingDirectorySynchronizer: IAgentHostSessionWorkingDirectorySynchronizer, + @IAgentHostShellInitSynchronizer private readonly _shellInitSynchronizer: IAgentHostShellInitSynchronizer, @IAgentHostNewSessionFolderService private readonly _newSessionFolderService: IAgentHostNewSessionFolderService, @IAgentHostUntitledProvisionalSessionService private readonly _provisionalService: IAgentHostUntitledProvisionalSessionService, @IAgentHostImportConversationStore private readonly _importConversationStore: IAgentHostImportConversationStore, @@ -2966,6 +2969,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } onFailureStage('prepareTurn'); + // Synchronous, so the turn dispatched next observes the current script. + this._shellInitSynchronizer.reconcile(session); if (request.acceptedConfirmationData?.some(isResumeTurnConfirmationData)) { return this._handleResumedTurn(session, request, progress, cancellationToken); } @@ -6597,6 +6602,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._sessionSubscriptions.delete(sessionUri); ref.dispose(); this._workingDirectoryRegistrations.deleteAndDispose(sessionUri); + this._shellInitRegistrations.deleteAndDispose(sessionUri); ref = undefined; } if (!ref) { @@ -6608,6 +6614,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC connection: this._config.connection, subscription: ref.object, })); + if (this._config.connectionAuthority === LOCAL_AGENT_HOST_AUTHORITY) { + this._shellInitRegistrations.set(sessionUri, this._shellInitSynchronizer.register(URI.parse(sessionUri), ref.object)); + } } return ref.object; } @@ -6670,6 +6679,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._sessionSubscriptions.delete(sessionUri); ref.dispose(); this._workingDirectoryRegistrations.deleteAndDispose(sessionUri); + this._shellInitRegistrations.deleteAndDispose(sessionUri); } const chatRef = this._defaultChatSubscriptions.get(sessionUri); if (chatRef) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostShellInitSynchronizer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostShellInitSynchronizer.ts new file mode 100644 index 0000000000000..d1101e27b8544 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostShellInitSynchronizer.ts @@ -0,0 +1,183 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { RunOnceScheduler } from '../../../../../../base/common/async.js'; +import { structuralEquals } from '../../../../../../base/common/equals.js'; +import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { isWindows } from '../../../../../../base/common/platform.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { AgentHostShellToolInitScriptEnabledSettingId } from '../../../../../../platform/agentHost/common/copilotCliConfig.js'; +import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; +import { createShellInitScript, type IShellInitScript, type ShellInitScriptShell } from '../../../../../../platform/agentHost/common/shellInitScript.js'; +import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; +import { ActionType } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; +import { SessionState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { InstantiationType, registerSingleton } from '../../../../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; +import { IEnvironmentVariableService } from '../../../../terminal/common/environmentVariable.js'; +import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; + +const PYTHON_ENV_EXTENSION_ID = 'ms-python.vscode-python-envs'; +// Only the variable matching the tool shell: the extension publishes +// shell-specific activation, so no cross-shell fallback is read. +const PYTHON_ACTIVATION_VARIABLES: readonly string[] = isWindows + ? ['VSCODE_PYTHON_PWSH_ACTIVATE'] + : ['VSCODE_PYTHON_BASH_ACTIVATE']; +const TOOL_SHELL: ShellInitScriptShell = isWindows ? 'powershell' : 'bash'; + +export const IAgentHostShellInitSynchronizer = createDecorator('agentHostShellInitSynchronizer'); + +export interface IAgentHostShellInitSynchronizer { + readonly _serviceBrand: undefined; + register(session: URI, subscription: IAgentSubscription): IDisposable; + /** + * Publishes synchronously so a turn dispatched right after it observes the + * current script: dispatch is ordered per connection and the agent host + * applies the value before it starts the turn. + */ + reconcile(session: URI): void; +} + +interface IRegistration { + readonly subscription: IAgentSubscription; + readonly store: DisposableStore; + readonly scheduler: RunOnceScheduler; + schemaReady: boolean; +} + +/** + * Publishes one combined profile-loading and Python-activation script for each + * session. Script text travels in session config; the agent host owns the file. + */ +export class AgentHostShellInitSynchronizer extends Disposable implements IAgentHostShellInitSynchronizer { + declare readonly _serviceBrand: undefined; + + private readonly _registrations = new Map(); + + constructor( + @IAgentHostService private readonly _agentHostService: IAgentHostService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IEnvironmentVariableService private readonly _environmentVariableService: IEnvironmentVariableService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @IWorkbenchEnvironmentService private readonly _environmentService: IWorkbenchEnvironmentService, + ) { + super(); + this._register(this._environmentVariableService.onDidChangeCollections(() => this._scheduleAll())); + this._register(this._workspaceContextService.onDidChangeWorkspaceFolders(() => this._scheduleAll())); + this._register(this._configurationService.onDidChangeConfiguration(event => { + if (event.affectsConfiguration(AgentHostShellToolInitScriptEnabledSettingId)) { + this._scheduleAll(); + } + })); + } + + register(session: URI, subscription: IAgentSubscription): IDisposable { + // Remote-development windows can run a host on a different OS, so the + // renderer cannot safely choose Bash versus PowerShell for them. + if (this._environmentService.remoteAuthority) { + return Disposable.None; + } + const key = session.toString(); + this._registrations.get(key)?.store.dispose(); + + const store = new DisposableStore(); + const scheduler = store.add(new RunOnceScheduler(() => this._publish(key), 0)); + const registration: IRegistration = { + subscription, + store, + scheduler, + schemaReady: this._supportsShellInit(subscription.value), + }; + this._registrations.set(key, registration); + store.add(subscription.onDidChange(state => { + // Session config echoes are shared across windows. Once the schema is + // ready, local inputs and pre-turn reconcile own publication; reacting + // to every echo can make two qualifying windows alternate forever. + if (!registration.schemaReady && this._supportsShellInit(state)) { + registration.schemaReady = true; + scheduler.schedule(); + } + })); + store.add(toDisposable(() => { + if (this._registrations.get(key) === registration) { + this._registrations.delete(key); + } + })); + scheduler.schedule(); + return store; + } + + reconcile(session: URI): void { + const key = session.toString(); + this._registrations.get(key)?.scheduler.cancel(); + this._publish(key); + } + + private _scheduleAll(): void { + for (const registration of this._registrations.values()) { + registration.scheduler.schedule(); + } + } + + private _supportsShellInit(state: SessionState | Error | undefined): state is SessionState { + return !!state && !(state instanceof Error) && !!state.config?.schema.properties[SessionConfigKey.ShellInitScripts]; + } + + private _publish(key: string): void { + const state = this._registrations.get(key)?.subscription.value; + if (!state || state instanceof Error || !state.config?.schema.properties[SessionConfigKey.ShellInitScripts]) { + return; + } + + const enabled = this._configurationService.getValue(AgentHostShellToolInitScriptEnabledSettingId) === true; + // A non-empty script belongs to the Editor Window that owns the session + // folder. The Agents window mounts the active session's folder into its + // own workspace, so ownership alone would qualify it too; it never + // publishes. The application-scoped disabled value is authoritative from + // any local window, including the Agents window. + const folder = enabled && !this._environmentService.isSessionsWindow ? this._resolveFolder(state) : undefined; + if (enabled && !folder) { + return; + } + const desired = enabled && folder ? [createShellInitScript(TOOL_SHELL, this._readPythonActivation(folder))] : []; + const current = state.config.values[SessionConfigKey.ShellInitScripts] as readonly IShellInitScript[] | undefined; + if (structuralEquals(current, desired) || (!desired.length && current === undefined)) { + return; + } + + this._agentHostService.dispatch(key, { + type: ActionType.SessionConfigChanged, + config: { [SessionConfigKey.ShellInitScripts]: desired }, + }); + } + + private _readPythonActivation(folder: IWorkspaceFolder): string | undefined { + const variables = this._environmentVariableService.mergedCollection.getVariableMap({ workspaceFolder: folder }); + for (const name of PYTHON_ACTIVATION_VARIABLES) { + const value = variables.get(name)?.find(mutator => mutator.extensionIdentifier === PYTHON_ENV_EXTENSION_ID)?.value; + if (value?.trim()) { + return value; + } + } + return undefined; + } + + private _resolveFolder(state: SessionState): IWorkspaceFolder | undefined { + for (const value of [state.project?.uri, ...(state.workingDirectories ?? [])]) { + if (value) { + const folder = this._workspaceContextService.getWorkspaceFolder(URI.parse(value)); + if (folder) { + return folder; + } + } + } + return undefined; + } +} + +registerSingleton(IAgentHostShellInitSynchronizer, AgentHostShellInitSynchronizer, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index aed935b6aff2c..e744ea6d8cf9d 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -20,7 +20,7 @@ import { AgentHostAutoReplyEnabledConfigKey, AgentHostEditAutoApprovePatternsCon import '../../../../platform/agentHost/common/agentHostStarter.config.contribution.js'; import { AgentMergeSettingId } from '../../../../platform/agentHost/common/agentMerge.js'; import { AgentHostAhpJsonlLoggingSettingId, AgentHostAllowSignedOutWhenUsableSettingId, AgentHostSdkSandboxEnabledSettingId, AgentHostSdkSandboxWindowsEnabledSettingId, CodexPreferAgentHostEditorSettingId } from '../../../../platform/agentHost/common/agentService.js'; -import { AgentHostAutoModeTiersEnabledSettingId, AgentHostCopilotModelCapabilityOverridesSettingId, AgentHostCopilotSdkLogLevelSettingId, AgentHostCustomTerminalToolEnabledSettingId, AgentHostMultiTurnContextRoutingEnabledSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostReasoningEffortOverrideSettingId, AgentHostReasoningSummaryEnabledSettingId, AgentHostToolSearchDeferThresholdSettingId, AgentHostToolSearchEnabledSettingId, AutoModeTiersExperimentName, CopilotSubagentModelGuidanceEnabledSettingId, copilotSdkLogLevelSettingValues } from '../../../../platform/agentHost/common/copilotCliConfig.js'; +import { AgentHostAutoModeTiersEnabledSettingId, AgentHostCopilotModelCapabilityOverridesSettingId, AgentHostCopilotSdkLogLevelSettingId, AgentHostCustomTerminalToolEnabledSettingId, AgentHostMultiTurnContextRoutingEnabledSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostReasoningEffortOverrideSettingId, AgentHostReasoningSummaryEnabledSettingId, AgentHostShellToolInitScriptEnabledSettingId, AgentHostToolSearchDeferThresholdSettingId, AgentHostToolSearchEnabledSettingId, AutoModeTiersExperimentName, CopilotSubagentModelGuidanceEnabledSettingId, copilotSdkLogLevelSettingValues } from '../../../../platform/agentHost/common/copilotCliConfig.js'; import { CopilotSemanticSearchEnabledSettingId } from '../../../../platform/agentHost/common/semanticSearchConstants.js'; import { ChatMicrosoftAuthenticationEnabledSettingId, DEFAULT_EDIT_AUTO_APPROVE_PATTERNS, mergeChatEditAutoApprovePatterns } from '../../../../platform/chat/common/chatSettings.js'; import { reasoningEffortLevels } from '../../../../platform/agentHost/common/reasoningEffort.js'; @@ -1608,6 +1608,13 @@ configurationRegistry.registerConfiguration({ default: false, tags: ['experimental', 'advanced'], }, + [AgentHostShellToolInitScriptEnabledSettingId]: { + type: 'boolean', + markdownDescription: nls.localize('chat.agentHost.shellTool.initScript.enabled', "When enabled, Copilot SDK sessions load your shell profile (`~/.bashrc` on macOS and Linux, PowerShell profiles on Windows) and activate the Python environment selected for the workspace before each shell command. Python activation requires the Python Environments extension with `#python-envs.terminal.autoActivationType#` set to `shellStartup`. Local windows only."), + default: false, + tags: ['experimental', 'advanced'], + scope: ConfigurationScope.APPLICATION, + }, [AgentHostCopilotSdkLogLevelSettingId]: { type: 'string', enum: [...copilotSdkLogLevelSettingValues], diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 70e7b58da92be..6e11af838790c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -89,6 +89,7 @@ import { ITerminalChatService, type ITerminalInstance } from '../../../../termin import { IAgentHostTerminalService } from '../../../../terminal/browser/agentHostTerminalService.js'; import { IAgentHostSessionWorkingDirectoryResolver } from '../../../browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; import { IAgentHostSessionWorkingDirectorySynchronizer } from '../../../browser/agentSessions/agentHost/agentHostSessionWorkingDirectorySynchronizer.js'; +import { IAgentHostShellInitSynchronizer } from '../../../browser/agentSessions/agentHost/agentHostShellInitSynchronizer.js'; import { IAgentHostUntitledProvisionalSessionService } from '../../../browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; import { IAgentHostImportConversationStore } from '../../../browser/agentSessions/agentHost/agentHostImportConversationStore.js'; import { AgentHostNewSessionFolderService, IAgentHostNewSessionFolderService } from '../../../browser/agentSessions/agentHost/agentHostNewSessionFolderService.js'; @@ -918,6 +919,10 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv register: () => toDisposable(() => { }), reconcile: async () => { }, } as Partial as IAgentHostSessionWorkingDirectorySynchronizer); + instantiationService.stub(IAgentHostShellInitSynchronizer, { + register: () => toDisposable(() => { }), + reconcile: async () => { }, + }); instantiationService.stub(IWorkbenchEnvironmentService, { isSessionsWindow } as Partial); instantiationService.stub(IWorkbenchAssignmentService, new NullWorkbenchAssignmentService()); instantiationService.stub(IChatInputNotificationService, { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts index b5ee6515c6990..5705113755e0b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts @@ -59,6 +59,7 @@ import { ITerminalChatService } from '../../../../terminal/browser/terminal.js'; import { IAgentHostTerminalService } from '../../../../terminal/browser/agentHostTerminalService.js'; import { IAgentHostSessionWorkingDirectoryResolver } from '../../../browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; import { IAgentHostSessionWorkingDirectorySynchronizer } from '../../../browser/agentSessions/agentHost/agentHostSessionWorkingDirectorySynchronizer.js'; +import { IAgentHostShellInitSynchronizer } from '../../../browser/agentSessions/agentHost/agentHostShellInitSynchronizer.js'; import { IAgentHostUntitledProvisionalSessionService } from '../../../browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; import { ILanguageModelToolsService, IToolData, IToolInvocation, IToolResult, IToolSet, ToolAndToolSetEnablementMap, ToolDataSource, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; import { IChatSessionsService } from '../../../common/chatSessionsService.js'; @@ -874,6 +875,10 @@ suite('AgentHostClientTools', () => { register: () => toDisposable(() => { }), reconcile: async () => { }, } as Partial as IAgentHostSessionWorkingDirectorySynchronizer); + instantiationService.stub(IAgentHostShellInitSynchronizer, { + register: () => toDisposable(() => { }), + reconcile: async () => { }, + }); instantiationService.stub(IWorkbenchAssignmentService, new NullWorkbenchAssignmentService()); instantiationService.stub(IAgentHostUntitledProvisionalSessionService, { onDidChange: Event.None, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts index f645be3abd683..fd3a85f703202 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts @@ -11,7 +11,7 @@ import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IAgentHostEnablementService } from '../../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; -import { AgentHostAutoModeTiersEnabledSettingId, AgentHostCopilotModelCapabilityOverridesSettingId, AgentHostCopilotSdkLogLevelSettingId, AgentHostMultiTurnContextRoutingEnabledSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostReasoningSummaryEnabledSettingId, AgentHostToolSearchDeferThresholdSettingId, AgentHostToolSearchEnabledSettingId, CopilotCliConfigKey, CopilotSubagentModelGuidanceEnabledSettingId } from '../../../../../../platform/agentHost/common/copilotCliConfig.js'; +import { AgentHostAutoModeTiersEnabledSettingId, AgentHostCopilotModelCapabilityOverridesSettingId, AgentHostCopilotSdkLogLevelSettingId, AgentHostMultiTurnContextRoutingEnabledSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostReasoningSummaryEnabledSettingId, AgentHostShellToolInitScriptEnabledSettingId, AgentHostToolSearchDeferThresholdSettingId, AgentHostToolSearchEnabledSettingId, CopilotCliConfigKey, CopilotSubagentModelGuidanceEnabledSettingId } from '../../../../../../platform/agentHost/common/copilotCliConfig.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import type { ClientAnnotationsAction, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { ConfigPropertySchema, RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; @@ -80,6 +80,7 @@ const fullSchema: Record = { [CopilotCliConfigKey.AutoModeTiers]: { type: 'boolean', title: 'Auto Routing Profiles' }, [CopilotCliConfigKey.SubagentModelGuidance]: { type: 'boolean', title: 'Subagent Model Guidance' }, [CopilotCliConfigKey.ModelCapabilityOverrides]: { type: 'object', title: 'Model Capability Overrides' }, + [CopilotCliConfigKey.EnableShellInitScript]: { type: 'boolean', title: 'Shell Init Script' }, }; /** Two microtask hops: one for the await on computeValue, one for the dispatch. */ @@ -125,13 +126,14 @@ suite('AgentHostCopilotCliSettingsContribution', () => { [AgentHostMultiTurnContextRoutingEnabledSettingId]: true, [AgentHostAutoModeTiersEnabledSettingId]: true, [CopilotSubagentModelGuidanceEnabledSettingId]: true, + [AgentHostShellToolInitScriptEnabledSettingId]: true, }); agentHostService.setRootState(makeRootStateWithSchema(fullSchema)); await flush(); // The shared forwarder dispatches one RootConfigChanged per key; merge them // and assert the full forwarded set (order-independent). - assert.strictEqual(agentHostService.dispatchedActions.length, 9); + assert.strictEqual(agentHostService.dispatchedActions.length, 10); const merged = Object.assign({}, ...agentHostService.dispatchedActions.map(a => (a.action as IRootConfigChangedAction).config)); assert.deepStrictEqual(merged, { [CopilotCliConfigKey.CopilotSdkLogLevel]: 'trace', @@ -143,6 +145,7 @@ suite('AgentHostCopilotCliSettingsContribution', () => { [CopilotCliConfigKey.AutoModeTiers]: true, [CopilotCliConfigKey.SubagentModelGuidance]: true, [CopilotCliConfigKey.ModelCapabilityOverrides]: capabilityOverrides, + [CopilotCliConfigKey.EnableShellInitScript]: true, }); }); @@ -200,6 +203,7 @@ suite('AgentHostCopilotCliSettingsContribution', () => { [CopilotCliConfigKey.AutoModeTiers]: false, [CopilotCliConfigKey.SubagentModelGuidance]: false, [CopilotCliConfigKey.ModelCapabilityOverrides]: { 'preview-model-x': { family: 'claude-opus-4-8' } }, + [CopilotCliConfigKey.EnableShellInitScript]: false, })); await flush(); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostShellInitSynchronizer.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostShellInitSynchronizer.test.ts new file mode 100644 index 0000000000000..7098107163100 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostShellInitSynchronizer.test.ts @@ -0,0 +1,328 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { timeout } from '../../../../../../base/common/async.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { isWindows } from '../../../../../../base/common/platform.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { AgentHostShellToolInitScriptEnabledSettingId } from '../../../../../../platform/agentHost/common/copilotCliConfig.js'; +import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; +import { createShellInitScript, type IShellInitScript, type ShellInitScriptShell } from '../../../../../../platform/agentHost/common/shellInitScript.js'; +import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; +import { ActionEnvelope, ActionType } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { SessionState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { IConfigurationService, type IConfigurationOverrides } from '../../../../../../platform/configuration/common/configuration.js'; +import { EnvironmentVariableMutatorType, type IEnvironmentVariableCollection, type IEnvironmentVariableMutator } from '../../../../../../platform/terminal/common/environmentVariable.js'; +import { MergedEnvironmentVariableCollection } from '../../../../../../platform/terminal/common/environmentVariableCollection.js'; +import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; +import { IEnvironmentVariableService } from '../../../../terminal/common/environmentVariable.js'; +import { AgentHostShellInitSynchronizer } from '../../../browser/agentSessions/agentHost/agentHostShellInitSynchronizer.js'; +import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; + +const PYTHON_EXTENSION = 'ms-python.vscode-python-envs'; +const ACTIVATION_VARIABLE = isWindows ? 'VSCODE_PYTHON_PWSH_ACTIVATE' : 'VSCODE_PYTHON_BASH_ACTIVATE'; +const TOOL_SHELL: ShellInitScriptShell = isWindows ? 'powershell' : 'bash'; + +class TestSubscription extends Disposable implements IAgentSubscription { + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + readonly onWillApplyAction = Event.None as Event; + readonly onDidApplyAction = Event.None as Event; + + constructor(private _state: SessionState) { super(); } + get value(): SessionState { return this._state; } + get verifiedValue(): SessionState { return this._state; } + set(state: SessionState): void { this._state = state; this._onDidChange.fire(state); } +} + +suite('AgentHostShellInitSynchronizer', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + const session = URI.parse('copilot:/session'); + const folderA = folder('/workspace/a', 0); + const folderB = folder('/workspace/b', 1); + + function folder(path: string, index: number): IWorkspaceFolder { + const uri = URI.file(path); + return { uri, index, name: path, toResource: relative => URI.joinPath(uri, relative) } as IWorkspaceFolder; + } + + function collection(entries: ReadonlyArray<{ variable: string; value: string; folder: IWorkspaceFolder; extension?: string }>): MergedEnvironmentVariableCollection { + const collections = new Map(); + for (const entry of entries) { + const extension = entry.extension ?? PYTHON_EXTENSION; + const existing = collections.get(extension)?.map as Map | undefined ?? new Map(); + existing.set(`${entry.variable}:${entry.folder.index}`, { + variable: entry.variable, + value: entry.value, + type: EnvironmentVariableMutatorType.Replace, + scope: { workspaceFolder: entry.folder }, + }); + collections.set(extension, { map: existing } as IEnvironmentVariableCollection); + } + return new MergedEnvironmentVariableCollection(collections); + } + + function state(options?: { schema?: boolean; values?: Record; cwd?: URI; project?: URI }): SessionState { + return { + resource: session.toString(), + config: { + schema: { + type: 'object', + properties: options?.schema === false ? {} : { [SessionConfigKey.ShellInitScripts]: { type: 'array' } }, + }, + values: options?.values ?? {}, + }, + workingDirectories: [(options?.cwd ?? folderA.uri).toString()], + ...(options?.project ? { project: { uri: options.project.toString(), displayName: 'project' } } : {}), + } as unknown as SessionState; + } + + function create(options?: { + collection?: MergedEnvironmentVariableCollection; + folders?: readonly IWorkspaceFolder[]; + enabled?: boolean; + sessionsWindow?: boolean; + remoteAuthority?: string; + onDidChangeCollections?: Event; + onDispatch?: (config: Record) => void; + getCollection?: () => MergedEnvironmentVariableCollection; + }) { + const dispatched: Record[] = []; + const agentHostService = new class extends mock() { + override dispatch(_uri: string, action: Parameters[1]): void { + if (action.type === ActionType.SessionConfigChanged) { + dispatched.push(action.config); + options?.onDispatch?.(action.config); + } + } + }; + const configurationService = new class extends mock() { + override readonly onDidChangeConfiguration = Event.None; + override getValue(section?: string | IConfigurationOverrides): T { + return (section === AgentHostShellToolInitScriptEnabledSettingId ? options?.enabled ?? false : undefined) as T; + } + }; + const environmentService = new class extends mock() { + override readonly onDidChangeCollections = options?.onDidChangeCollections ?? Event.None; + override get mergedCollection() { return options?.getCollection?.() ?? options?.collection ?? collection([]); } + }; + const folders = options?.folders ?? [folderA]; + const workspaceService = new class extends mock() { + override readonly onDidChangeWorkspaceFolders = Event.None; + override getWorkspaceFolder(resource: URI): IWorkspaceFolder | null { + return folders.find(candidate => resource.path.startsWith(candidate.uri.path)) ?? null; + } + }; + return { + dispatched, + synchronizer: disposables.add(new AgentHostShellInitSynchronizer( + agentHostService, + configurationService, + environmentService, + workspaceService, + { isSessionsWindow: options?.sessionsWindow === true, remoteAuthority: options?.remoteAuthority } as IWorkbenchEnvironmentService, + )), + }; + } + + async function register(synchronizer: AgentHostShellInitSynchronizer, initial: SessionState): Promise { + const subscription = disposables.add(new TestSubscription(initial)); + disposables.add(synchronizer.register(session, subscription)); + await timeout(0); + return subscription; + } + + function scripts(dispatched: readonly Record[]): readonly IShellInitScript[] { + return dispatched.at(-1)?.[SessionConfigKey.ShellInitScripts] as readonly IShellInitScript[]; + } + + test('publishes one combined script with the folder-scoped Python activation', async () => { + const { synchronizer, dispatched } = create({ + enabled: true, + collection: collection([{ variable: ACTIVATION_VARIABLE, value: 'activate-a', folder: folderA }]), + }); + await register(synchronizer, state()); + const published = scripts(dispatched)[0]; + const profileMarker = isWindows ? '$PROFILE.CurrentUserAllHosts' : '.bashrc'; + const activationMarker = isWindows ? 'FromBase64String' : 'activate-a'; + assert.deepStrictEqual({ + scripts: scripts(dispatched), + profileBeforeActivation: published.script.includes(profileMarker) + && published.script.includes(activationMarker) + && published.script.indexOf(profileMarker) < published.script.indexOf(activationMarker), + }, { + scripts: [createShellInitScript(TOOL_SHELL, 'activate-a')], + profileBeforeActivation: true, + }); + }); + + test('publishes a changed activation when environment collections change', async () => { + let currentCollection = collection([{ variable: ACTIVATION_VARIABLE, value: 'activate-a', folder: folderA }]); + const collectionsChanged = disposables.add(new Emitter()); + const { synchronizer, dispatched } = create({ + enabled: true, + getCollection: () => currentCollection, + onDidChangeCollections: collectionsChanged.event, + }); + const subscription = await register(synchronizer, state()); + subscription.set(state({ values: dispatched[0] })); + await timeout(0); + + currentCollection = collection([{ variable: ACTIVATION_VARIABLE, value: 'activate-b', folder: folderA }]); + collectionsChanged.fire(currentCollection); + await timeout(0); + + assert.deepStrictEqual({ + dispatches: dispatched.length, + scripts: scripts(dispatched), + }, { + dispatches: 2, + scripts: [createShellInitScript(TOOL_SHELL, 'activate-b')], + }); + }); + + test('reconcile publishes synchronously before the first turn', () => { + const { synchronizer, dispatched } = create({ enabled: true }); + const subscription = disposables.add(new TestSubscription(state())); + disposables.add(synchronizer.register(session, subscription)); + + synchronizer.reconcile(session); + + assert.strictEqual(dispatched.length, 1); + }); + + test('uses the session folder in a multi-root workspace and project for worktrees', async () => { + const { synchronizer, dispatched } = create({ + enabled: true, + folders: [folderA, folderB], + collection: collection([ + { variable: ACTIVATION_VARIABLE, value: 'activate-a', folder: folderA }, + { variable: ACTIVATION_VARIABLE, value: 'activate-b', folder: folderB }, + ]), + }); + await register(synchronizer, state({ cwd: URI.file('/tmp/worktree'), project: folderB.uri })); + assert.deepStrictEqual(scripts(dispatched), [createShellInitScript(TOOL_SHELL, 'activate-b')]); + }); + + test('ignores activation published by another extension', async () => { + const { synchronizer, dispatched } = create({ + enabled: true, + collection: collection([{ variable: ACTIVATION_VARIABLE, value: 'unsafe', folder: folderA, extension: 'other.extension' }]), + }); + await register(synchronizer, state()); + assert.ok(!scripts(dispatched)[0].script.includes('unsafe')); + }); + + test('waits for schema hydration and does not redispatch the echoed value', async () => { + const { synchronizer, dispatched } = create({ enabled: true }); + const subscription = await register(synchronizer, state({ schema: false })); + assert.deepStrictEqual(dispatched, []); + + subscription.set(state()); + await timeout(0); + assert.strictEqual(dispatched.length, 1); + + subscription.set(state({ values: dispatched[0] })); + await timeout(0); + assert.strictEqual(dispatched.length, 1); + }); + + test('two same-folder windows with different activation do not ping-pong on echoes', async () => { + const subscriptionA = disposables.add(new TestSubscription(state())); + const subscriptionB = disposables.add(new TestSubscription(state())); + const echo = (config: Record) => { + subscriptionA.set(state({ values: config })); + subscriptionB.set(state({ values: config })); + }; + const windowA = create({ + enabled: true, + collection: collection([{ variable: ACTIVATION_VARIABLE, value: 'activate-a', folder: folderA }]), + onDispatch: echo, + }); + const windowB = create({ + enabled: true, + collection: collection([{ variable: ACTIVATION_VARIABLE, value: 'activate-b', folder: folderA }]), + onDispatch: echo, + }); + disposables.add(windowA.synchronizer.register(session, subscriptionA)); + disposables.add(windowB.synchronizer.register(session, subscriptionB)); + + await timeout(0); + await timeout(0); + + // Each initial local publish may win once. Echoes do not schedule a + // counter-publish, so the count remains bounded and converges. + assert.strictEqual(windowA.dispatched.length + windowB.dispatched.length, 2); + }); + + test('does not publish from a window that does not own the session folder', async () => { + const { synchronizer, dispatched } = create({ enabled: true, folders: [folderB] }); + await register(synchronizer, state()); + assert.deepStrictEqual(dispatched, []); + }); + + test('the single setting clears the script when disabled', async () => { + const { synchronizer, dispatched } = create({ enabled: false }); + await register(synchronizer, state({ + values: { [SessionConfigKey.ShellInitScripts]: [{ shell: 'bash', script: 'old' }] }, + })); + assert.deepStrictEqual(dispatched, [{ [SessionConfigKey.ShellInitScripts]: [] }]); + }); + + test('the experimental setting is disabled by default', async () => { + const { synchronizer, dispatched } = create(); + await register(synchronizer, state()); + assert.deepStrictEqual(dispatched, []); + }); + + test('a non-owning local window can clear the script when disabled', async () => { + const { synchronizer, dispatched } = create({ enabled: false, folders: [folderB] }); + await register(synchronizer, state({ + values: { [SessionConfigKey.ShellInitScripts]: [{ shell: 'bash', script: 'old' }] }, + })); + assert.deepStrictEqual(dispatched, [{ [SessionConfigKey.ShellInitScripts]: [] }]); + }); + + test('does not publish a script from the Agents window even when it owns the session folder', async () => { + // The Agents window mounts the active session folder into its workspace, + // so folder ownership alone would otherwise qualify it as a publisher. + const { synchronizer, dispatched } = create({ + enabled: true, + sessionsWindow: true, + folders: [folderA], + collection: collection([{ variable: ACTIVATION_VARIABLE, value: 'activate-a', folder: folderA }]), + }); + await register(synchronizer, state()); + assert.deepStrictEqual(dispatched, []); + }); + + test('the Agents window can clear a stale script when disabled', async () => { + const { synchronizer, dispatched } = create({ enabled: false, sessionsWindow: true, folders: [] }); + await register(synchronizer, state({ + values: { [SessionConfigKey.ShellInitScripts]: [{ shell: 'bash', script: 'old' }] }, + })); + assert.deepStrictEqual(dispatched, [{ [SessionConfigKey.ShellInitScripts]: [] }]); + }); + + test('does not publish when the Agent Host can run on a remote OS', async () => { + const { synchronizer, dispatched } = create({ enabled: true, remoteAuthority: 'ssh-remote+host' }); + await register(synchronizer, state()); + assert.deepStrictEqual(dispatched, []); + }); + + (isWindows ? test.skip : test)('ignores the zsh activation value under bash', async () => { + const { synchronizer, dispatched } = create({ + enabled: true, + collection: collection([{ variable: 'VSCODE_PYTHON_ZSH_ACTIVATE', value: 'activate-zsh', folder: folderA }]), + }); + await register(synchronizer, state()); + assert.ok(!scripts(dispatched)[0].script.includes('activate-zsh')); + }); +});