microsoft/vscode · #332593
Activate Python environments in Agent Host shell commands with shell init scripts
src/vs/platform/agentHost/common/agentHostSchema.ts37 + / 0 −
@@ -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<IPermissionsValue>({ 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<readonly IShellInitScript[]>({+ 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, }); /**src/vs/platform/agentHost/common/copilotCliConfig.ts11 + / 0 −
@@ -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<boolean>({+ 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<CopilotSdkLogLevelSetting>({ type: 'string', title: localize('agentHost.config.copilotSdkLogLevel.title', "Copilot SDK Log Level"),src/vs/platform/agentHost/common/sessionConfigKeys.ts12 + / 0 −
@@ -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<string> = new Set(['default' * property: the agent execution mode axis. */ export const KNOWN_MODE_VALUES: ReadonlySet<string> = 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<T>(values: Record<string, T>): Record<string, T> {+ const result = { ...values };+ delete result[SessionConfigKey.ShellInitScripts];+ return result;+}src/vs/platform/agentHost/common/shellInitScript.tsadded127 + / 0 −
@@ -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<IShellInitScript>;+ return (script.shell === 'bash' || script.shell === 'powershell')+ && typeof script.script === 'string'+ && script.script.length > 0+ && script.script.length <= MAX_SHELL_INIT_SCRIPT_LENGTH;+ });+}src/vs/platform/agentHost/node/agentService.ts11 + / 5 −
@@ -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)}`); }src/vs/platform/agentHost/node/chatContributions/sessionFlags/sessionFlagsContribution.ts2 + / 1 −
@@ -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- andsrc/vs/platform/agentHost/node/copilot/copilotAgentSession.ts186 + / 6 −
@@ -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<AgentSignal>; 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<void> { 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<void> {+ if (this._shellInitScriptDisposing) {+ return Promise.resolve();+ }+ return this._shellInitScriptSequencer.queue(() => this._applyEffectiveShellInitScripts());+ }++ private _disposeShellInitScript(): Promise<void> {+ this._shellInitScriptDisposing = true;+ return this._shellInitScriptSequencer.queue(() => this._clearShellInitScript());+ }+ private async _syncPermissionModeAfterConfigChange(): Promise<void> { 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<void> {+ 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<void> {+ 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. *src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts16 + / 0 −
@@ -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;src/vs/platform/agentHost/test/common/agentHostSchema.test.ts35 + / 0 −
@@ -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 ----------------------------------------src/vs/platform/agentHost/test/node/agentService.test.ts13 + / 2 −
@@ -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, }); }); src/vs/platform/agentHost/test/node/chatContributions.test.ts7 + / 2 −
@@ -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' }), }); }); src/vs/platform/agentHost/test/node/copilotAgent.test.ts1 + / 0 −
@@ -953,6 +953,7 @@ function createTestAgentContext(disposables: Pick<DisposableStore, 'add'>, 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); }src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts404 + / 10 −
@@ -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<void> | undefined; readonly compactCalls: unknown[] = [];@@ -434,14 +436,19 @@ 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); } 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<string, unknown>; fileContents?: Record<string, string>; fileReadErrors?: readonly string[];+ shellInitWriteFailures?: number;+ fileAtomicWrite?: boolean;+ shellInitWriteGate?: Promise<void>;+ onShellInitWrite?: () => void;+ /** Values visible only through `getEffectiveValue`, as if inherited from root or a parent session. */+ inheritedConfigValues?: Record<string, unknown>; 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<AgentSignal>; terminalManager: TestAgentHostTerminalManager;+ storedFileContents: ReadonlyMap<string, string>;+ fileWriteOptions: ReadonlyMap<string, IWriteFileOptions | undefined>; dispatchedActions: readonly StateAction[]; sessionConfigUpdates: ReadonlyArray<{ session: string; patch: Record<string, unknown> }>; 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<string, IWriteFileOptions | undefined>();+ 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<ReturnType<IFileService['writeFile']>>; },- 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<IFileService> as IFileService);+ } as unknown as IFileService); services.set(ISessionDataService, createSessionDataService(options?.sessionDatabase)); services.set(IDiffComputeService, createZeroDiffComputeService()); const sessionConfigUpdates: Array<{ session: string; patch: Record<string, unknown> }> = [];@@ -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<typeof createAgentSession>[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<void>();+ const writeGate = new DeferredPromise<void>();+ 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/')));+ });+ }); });src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md1 + / 0 −
@@ -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 "<exact test title>"` and temporarily remove the platform gate to reevaluate a row. src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-shell-init-script-runs-before-the-shell-command.yamladded47 + / 0 −
@@ -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+ <shellId: 0 completed with exit code 0>+ response:+ content: marker=init_marker_91+ stopReason: end_turnsrc/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_shell_init_script_runs_before_the_shell_command.traffic.ahp.yamladded57 + / 0 −
@@ -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}src/vs/platform/agentHost/test/node/e2e/suites/fileOperationsSuite.ts56 + / 0 −
@@ -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<string, unknown> }).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 () {src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts34 + / 0 −
@@ -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);+ });+ }); });src/vs/platform/agentHost/test/node/shellInitScript.test.tsadded187 + / 0 −
@@ -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\('(?<encoded>[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<string[]> {+ 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',+ ]);+ });+ });+});src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts5 + / 1 −
@@ -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<string> = new Set<string>([ SessionConfigKey.Mode,@@ -300,6 +303,7 @@ export const WELL_KNOWN_PICKER_PROPERTIES: ReadonlySet<string> = new Set<string> SessionConfigKey.WorktreeBranchTrack, SessionConfigKey.WorktreeCreateNewBranch, SessionConfigKey.WorktreeIncludeFiles,+ SessionConfigKey.ShellInitScripts, ClaudeSessionConfigKey.PermissionMode, CodexSessionConfigKey.PermissionsPreset, ]);src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCopilotCliSettingsContribution.ts9 + / 1 −
@@ -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<boolean>(AgentHostShellToolInitScriptEnabledSettingId) === true,+ registerTriggers: (store, push) => this._pushOnSettingChange(store, push, AgentHostShellToolInitScriptEnabledSettingId),+ }, ]; this._forwarder = this._register(new AgentHostRootConfigForwarder(keys, agentHostService)); src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts11 + / 1 −
@@ -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<string>());+ private readonly _shellInitRegistrations = this._register(new DisposableMap<string>()); /** * 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) {src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostShellInitSynchronizer.tsadded183 + / 0 −
@@ -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<IAgentHostShellInitSynchronizer>('agentHostShellInitSynchronizer');++export interface IAgentHostShellInitSynchronizer {+ readonly _serviceBrand: undefined;+ register(session: URI, subscription: IAgentSubscription<SessionState>): 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<SessionState>;+ 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<string, IRegistration>();++ 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<SessionState>): 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<boolean>(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);src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts8 + / 1 −
@@ -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],src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts5 + / 0 −
@@ -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<IAgentHostSessionWorkingDirectorySynchronizer> as IAgentHostSessionWorkingDirectorySynchronizer);+ instantiationService.stub(IAgentHostShellInitSynchronizer, {+ register: () => toDisposable(() => { }),+ reconcile: async () => { },+ }); instantiationService.stub(IWorkbenchEnvironmentService, { isSessionsWindow } as Partial<IWorkbenchEnvironmentService>); instantiationService.stub(IWorkbenchAssignmentService, new NullWorkbenchAssignmentService()); instantiationService.stub(IChatInputNotificationService, {src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts5 + / 0 −
@@ -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<IAgentHostSessionWorkingDirectorySynchronizer> as IAgentHostSessionWorkingDirectorySynchronizer);+ instantiationService.stub(IAgentHostShellInitSynchronizer, {+ register: () => toDisposable(() => { }),+ reconcile: async () => { },+ }); instantiationService.stub(IWorkbenchAssignmentService, new NullWorkbenchAssignmentService()); instantiationService.stub(IAgentHostUntitledProvisionalSessionService, { onDidChange: Event.None,src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts6 + / 2 −
@@ -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<string, ConfigPropertySchema> = { [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(); src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostShellInitSynchronizer.test.tsadded328 + / 0 −
@@ -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<SessionState> {+ private readonly _onDidChange = this._register(new Emitter<SessionState>());+ readonly onDidChange = this._onDidChange.event;+ readonly onWillApplyAction = Event.None as Event<ActionEnvelope>;+ readonly onDidApplyAction = Event.None as Event<ActionEnvelope>;++ 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<string, IEnvironmentVariableCollection>();+ for (const entry of entries) {+ const extension = entry.extension ?? PYTHON_EXTENSION;+ const existing = collections.get(extension)?.map as Map<string, IEnvironmentVariableMutator> | 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<string, unknown>; 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<MergedEnvironmentVariableCollection>;+ onDispatch?: (config: Record<string, unknown>) => void;+ getCollection?: () => MergedEnvironmentVariableCollection;+ }) {+ const dispatched: Record<string, unknown>[] = [];+ const agentHostService = new class extends mock<IAgentHostService>() {+ override dispatch(_uri: string, action: Parameters<IAgentHostService['dispatch']>[1]): void {+ if (action.type === ActionType.SessionConfigChanged) {+ dispatched.push(action.config);+ options?.onDispatch?.(action.config);+ }+ }+ };+ const configurationService = new class extends mock<IConfigurationService>() {+ override readonly onDidChangeConfiguration = Event.None;+ override getValue<T>(section?: string | IConfigurationOverrides): T {+ return (section === AgentHostShellToolInitScriptEnabledSettingId ? options?.enabled ?? false : undefined) as T;+ }+ };+ const environmentService = new class extends mock<IEnvironmentVariableService>() {+ 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<IWorkspaceContextService>() {+ 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<TestSubscription> {+ const subscription = disposables.add(new TestSubscription(initial));+ disposables.add(synchronizer.register(session, subscription));+ await timeout(0);+ return subscription;+ }++ function scripts(dispatched: readonly Record<string, unknown>[]): 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<MergedEnvironmentVariableCollection>());+ 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<string, unknown>) => {+ 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'));+ });+});