microsoft/vscode · #332593

Activate Python environments in Agent Host shell commands with shell init scripts

anthonykim1 · merged Sep 2, 202628 files · 1804 + / 32
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- and
src/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_turn
src/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'));+	});+});