diff --git a/copilot_shim/REQUIREMENTS.md b/copilot_shim/REQUIREMENTS.md index a5feb8304d7..f1d4a1d5efb 100644 --- a/copilot_shim/REQUIREMENTS.md +++ b/copilot_shim/REQUIREMENTS.md @@ -112,6 +112,25 @@ usable after a failed refresh, but the extension does not advertise a folder with surviving legacy wrappers that could shadow the native executable. The terminal link provider and Copilot session data are unchanged. +### Terminal PATH locality and recovery + +Authority matching follows terminal backend routing and is case-insensitive. Variable names follow the terminal's OS: Windows treats all PATH spellings alike; on macOS and Linux only `PATH` is subject to locality filtering, while `Path` and `path` remain distinct ordinary variables. + +The extension's terminal PATH contribution is applied only to terminals on the +same computer as the producing extension host. A local contribution applies to +local terminals, including local terminals in remote windows; a remote +contribution applies only to terminals on that same remote authority. Other +environment variables retain their existing cross-host sharing behavior, and +the terminal's base PATH and independently installed commands are not changed. + +Saved PATH contributions with an unknown origin, or an origin on another +computer, are absent from the extension's initial cached collection until the +extension refreshes them. Correctly tagged same-computer saved contributions +remain usable before activation. This cache migration is distinct from remote +process recovery: a recovered pre-fix remote process retains its complete +historical environment until normal relaunch, when the current filtered +contribution is applied. + ### Ownership VS Code owns only the shim, the launcher that gets Copilot CLI onto the device. diff --git a/src/vs/workbench/api/browser/mainThreadTerminalService.ts b/src/vs/workbench/api/browser/mainThreadTerminalService.ts index 8283ecceec9..4fbd1c7eec8 100644 --- a/src/vs/workbench/api/browser/mainThreadTerminalService.ts +++ b/src/vs/workbench/api/browser/mainThreadTerminalService.ts @@ -61,7 +61,7 @@ export class MainThreadTerminalService extends Disposable implements MainThreadT private _os: OperatingSystem = OS; constructor( - _extHostContext: IExtHostContext, + private readonly _extHostContext: IExtHostContext, @ITerminalService private readonly _terminalService: ITerminalService, @ITerminalLinkProviderService private readonly _terminalLinkProviderService: ITerminalLinkProviderService, @ITerminalQuickFixService private readonly _terminalQuickFixService: ITerminalQuickFixService, @@ -109,7 +109,11 @@ export class MainThreadTerminalService extends Disposable implements MainThreadT this._proxy.$acceptActiveTerminalChanged(activeInstance.instanceId); } if (this._environmentVariableService.collections.size > 0) { - const collectionAsArray = [...this._environmentVariableService.collections.entries()]; + const hostOS = _extHostContext.remoteAuthority === null ? OS : _extHostContext.remoteOS; + if (hostOS === undefined) { + throw new Error('Remote extension host operating system is required to restore terminal environment collections.'); + } + const collectionAsArray = [...this._environmentVariableService.getMergedCollection(_extHostContext.remoteAuthority, hostOS).collections.entries()]; const serializedCollections: [string, ISerializableEnvironmentVariableCollection][] = collectionAsArray.map(e => { return [e[0], serializeEnvironmentVariableCollection(e[1].map)]; }); @@ -467,7 +471,8 @@ export class MainThreadTerminalService extends Disposable implements MainThreadT const translatedCollection = { persistent, map: deserializeEnvironmentVariableCollection(collection), - descriptionMap: deserializeEnvironmentDescriptionMap(descriptionMap) + descriptionMap: deserializeEnvironmentDescriptionMap(descriptionMap), + remoteAuthority: this._extHostContext.remoteAuthority?.toLowerCase() ?? null }; this._environmentVariableService.set(extensionIdentifier, translatedCollection); } else { diff --git a/src/vs/workbench/api/test/browser/mainThreadTerminalService.test.ts b/src/vs/workbench/api/test/browser/mainThreadTerminalService.test.ts index aad5c2b009d..0cfc2d9ae4d 100644 --- a/src/vs/workbench/api/test/browser/mainThreadTerminalService.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadTerminalService.test.ts @@ -4,21 +4,29 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { OperatingSystem } from '../../../../base/common/platform.js'; import { mainWindow } from '../../../../base/browser/window.js'; import { timeout } from '../../../../base/common/async.js'; import { Event } from '../../../../base/common/event.js'; +import { mock } from '../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { TestConfigurationService } from '../../../../platform/configuration/test/common/testConfigurationService.js'; +import { TestInstantiationService } from '../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { EnvironmentVariableMutatorType, IEnvironmentVariableMutator, ISerializableEnvironmentVariableCollection } from '../../../../platform/terminal/common/environmentVariable.js'; import { IViewDescriptorService } from '../../../common/views.js'; import { ITerminalGroupService, ITerminalInstanceService, ITerminalService } from '../../../contrib/terminal/browser/terminal.js'; import { TerminalGroupService } from '../../../contrib/terminal/browser/terminalGroupService.js'; import { TerminalInstanceService } from '../../../contrib/terminal/browser/terminalInstanceService.js'; import { TerminalService } from '../../../contrib/terminal/browser/terminalService.js'; +import { IEnvironmentVariableCollectionWithPersistence, IEnvironmentVariableService } from '../../../contrib/terminal/common/environmentVariable.js'; import { ITerminalProfileService } from '../../../contrib/terminal/common/terminal.js'; import { TestViewDescriptorService } from '../../../contrib/terminal/test/browser/xterm/xtermTestUtils.js'; import { ITerminalLinkProviderService } from '../../../contrib/terminalContrib/links/browser/links.js'; import { ITerminalQuickFixService } from '../../../contrib/terminalContrib/quickFix/browser/quickFix.js'; import { ITerminalCompletionService } from '../../../contrib/terminalContrib/suggest/browser/terminalCompletionService.js'; +import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; +import { IExtHostContext } from '../../../services/extensions/common/extHostCustomers.js'; +import { ProxyIdentifier } from '../../../services/extensions/common/proxyIdentifier.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; import { TestTerminalProfileService, workbenchInstantiationService } from '../../../test/browser/workbenchTestServices.js'; import { MainThreadTerminalService } from '../../browser/mainThreadTerminalService.js'; @@ -31,11 +39,13 @@ suite('MainThreadTerminalService', () => { let service: MainThreadTerminalService; let terminals: TerminalService; let groups: TerminalGroupService; + let environmentVariableService: IEnvironmentVariableService; + let instantiationService: TestInstantiationService; let input: string[]; setup(async () => { input = []; - const instantiationService = workbenchInstantiationService({ + instantiationService = workbenchInstantiationService({ configurationService: () => new TestConfigurationService({ files: {}, terminal: { @@ -66,6 +76,11 @@ suite('MainThreadTerminalService', () => { terminals = store.add(instantiationService.createInstance(TerminalService)); terminals.registerProcessSupport(true); instantiationService.set(ITerminalService, terminals); + // The window is remote, while the default extension host below is local. + instantiationService.stub(IWorkbenchEnvironmentService, new class extends mock() { + override readonly remoteAuthority = 'ssh-remote+linux'; + }); + environmentVariableService = instantiationService.get(IEnvironmentVariableService); // These provider registries are not used by terminal creation or disposal. instantiationService.stub(ITerminalLinkProviderService, {}); instantiationService.stub(ITerminalQuickFixService, {}); @@ -81,6 +96,133 @@ suite('MainThreadTerminalService', () => { await timeout(0); }); + function createBridge(remoteAuthority: string | null, onInitialCollections: (collections: [string, ISerializableEnvironmentVariableCollection][]) => void, remoteOS = OperatingSystem.Linux): MainThreadTerminalService { + const rpc = AnyCallRPCProtocol>({ + $initEnvironmentVariableCollections: onInitialCollections + }); + const extHostContext = new class extends mock() { + override readonly remoteAuthority = remoteAuthority; + override readonly remoteOS = remoteOS; + override getProxy(identifier: ProxyIdentifier) { + return rpc.getProxy(identifier); + } + }; + return store.add(instantiationService.createInstance(MainThreadTerminalService, extHostContext)); + } + + function collection(entries: ISerializableEnvironmentVariableCollection): IEnvironmentVariableCollectionWithPersistence { + return { + persistent: true, + map: new Map(entries), + descriptionMap: new Map() + }; + } + + test('records a local extension host origin in a remote window', () => { + const pathCollection: ISerializableEnvironmentVariableCollection = [[ + 'PATH', + { variable: 'PATH', value: 'C:\\copilot', type: EnvironmentVariableMutatorType.Prepend } + ]]; + + service.$setEnvironmentVariableCollection('publisher.local', true, pathCollection, []); + + assert.deepStrictEqual(environmentVariableService.collections.get('publisher.local'), { + ...collection(pathCollection), + remoteAuthority: null + }); + }); + + test('does not bootstrap a foreign PATH into a remote extension host and retains non-PATH values', () => { + const foreignCollection: ISerializableEnvironmentVariableCollection = [ + ['PATH', { variable: 'PATH', value: 'C:\\copilot', type: EnvironmentVariableMutatorType.Prepend }], + ['EDITOR', { variable: 'EDITOR', value: 'code', type: EnvironmentVariableMutatorType.Replace }] + ]; + const persistedCollection = { + ...collection(foreignCollection), + remoteAuthority: 'ssh-remote+windows' + }; + const initialCollections: [string, ISerializableEnvironmentVariableCollection][][] = []; + environmentVariableService.set('publisher.extension', persistedCollection); + + const remoteBridge = createBridge('ssh-remote+linux', collections => initialCollections.push(collections)); + + assert.deepStrictEqual(initialCollections, [[['publisher.extension', [ + ['EDITOR', { variable: 'EDITOR', value: 'code', type: EnvironmentVariableMutatorType.Replace }] + ]]]]); + + const resyncedCollection = initialCollections[0][0][1].map<[string, IEnvironmentVariableMutator]>(([key, mutator]) => [ + key, + mutator.variable === 'EDITOR' ? { ...mutator, value: 'code-insiders' } : mutator + ]); + remoteBridge.$setEnvironmentVariableCollection('publisher.extension', true, resyncedCollection, []); + assert.deepStrictEqual(environmentVariableService.collections.get('publisher.extension'), { + ...collection([['EDITOR', { variable: 'EDITOR', value: 'code-insiders', type: EnvironmentVariableMutatorType.Replace }]]), + remoteAuthority: 'ssh-remote+linux' + }); + }); + + test('bootstraps same-host persisted PATH into its extension host', () => { + const pathCollection: ISerializableEnvironmentVariableCollection = [[ + 'PATH', + { variable: 'PATH', value: ':/copilot', type: EnvironmentVariableMutatorType.Prepend } + ]]; + const persistedCollection: IEnvironmentVariableCollectionWithPersistence = { + ...collection(pathCollection), + remoteAuthority: 'ssh-remote+Linux' + }; + environmentVariableService.set('publisher.extension', persistedCollection); + const initialCollections: [string, ISerializableEnvironmentVariableCollection][][] = []; + + createBridge('ssh-remote+linux', collections => initialCollections.push(collections)); + + assert.deepStrictEqual(initialCollections, [[['publisher.extension', pathCollection]]]); + }); + + test('normalizes the producing authority when an extension publishes its collection', () => { + const bridge = createBridge('wsl+Ubuntu', () => { }); + bridge.$setEnvironmentVariableCollection('publisher.extension', true, [], []); + assert.deepStrictEqual(environmentVariableService.collections.get('publisher.extension'), { + ...collection([]), + remoteAuthority: 'wsl+ubuntu' + }); + }); + + for (const os of [OperatingSystem.Windows, OperatingSystem.Linux, OperatingSystem.Macintosh]) { + test(`uses extension-host OS ${os} synchronously for cached PATH spelling`, () => { + const entries: ISerializableEnvironmentVariableCollection = ['PATH', 'Path', 'path', 'EDITOR'].map(variable => [ + variable, { variable, value: 'value', type: EnvironmentVariableMutatorType.Replace } + ]); + environmentVariableService.set('publisher.legacy', collection(entries)); + const initial: [string, ISerializableEnvironmentVariableCollection][][] = []; + + createBridge('ssh-remote+target', collections => initial.push(collections), os); + + assert.deepStrictEqual(initial, [[['publisher.legacy', entries.filter(([, mutator]) => + os === OperatingSystem.Windows ? mutator.variable === 'EDITOR' : mutator.variable !== 'PATH' + )]]]); + }); + } + + test('initializes an empty filtered snapshot and permits empty sync and delete', () => { + environmentVariableService.set('publisher.legacy', collection([[ + 'PATH', + { variable: 'PATH', value: 'C:\\copilot', type: EnvironmentVariableMutatorType.Prepend } + ]])); + const initialCollections: [string, ISerializableEnvironmentVariableCollection][][] = []; + const remoteBridge = createBridge('ssh-remote+linux', collections => initialCollections.push(collections)); + + assert.deepStrictEqual(initialCollections, [[]]); + + remoteBridge.$setEnvironmentVariableCollection('publisher.legacy', false, [], []); + assert.deepStrictEqual(environmentVariableService.collections.get('publisher.legacy'), { + ...collection([]), + persistent: false, + remoteAuthority: 'ssh-remote+linux' + }); + remoteBridge.$setEnvironmentVariableCollection('publisher.legacy', false, undefined, []); + assert.strictEqual(environmentVariableService.collections.has('publisher.legacy'), false); + }); + test('releases the launch request after an API terminal closes', async function () { if (typeof globalThis.gc !== 'function') { this.skip(); // Run the Electron suite with --js-flags=--expose-gc. diff --git a/src/vs/workbench/contrib/terminal/browser/remoteTerminalBackend.ts b/src/vs/workbench/contrib/terminal/browser/remoteTerminalBackend.ts index b03a5f2aa9a..cfee6ba7ebf 100644 --- a/src/vs/workbench/contrib/terminal/browser/remoteTerminalBackend.ts +++ b/src/vs/workbench/contrib/terminal/browser/remoteTerminalBackend.ts @@ -205,7 +205,8 @@ class RemoteTerminalBackend extends BaseTerminalBackend implements ITerminalBack shouldPersist, cols, rows, - unicodeVersion + unicodeVersion, + remoteEnv.os ); const pty = this._instantiationService.createInstance(RemotePty, result.persistentTerminalId, shouldPersist, this._remoteTerminalChannel); this._ptys.set(result.persistentTerminalId, pty); diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index 4c79ff0e300..c172c177b9e 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -2353,9 +2353,11 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { this._shellLaunchConfig.env = shellLaunchConfig.env; } - private _onEnvironmentVariableInfoChanged(info: IEnvironmentVariableInfo): void { - if (info.requiresAction) { + private _onEnvironmentVariableInfoChanged(info: IEnvironmentVariableInfo | undefined): void { + if (info?.requiresAction) { this.xterm?.raw.textarea?.setAttribute('aria-label', nls.localize('terminalStaleTextBoxAriaLabel', "Terminal {0} environment is stale, run the 'Show Environment Information' command for more information", this._instanceId)); + } else { + this._setAriaLabel(this.xterm?.raw, this._instanceId, this._title); } this._refreshEnvironmentVariableInfoWidgetState(info); } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts index f560f283566..92972805dd5 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts @@ -121,7 +121,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce readonly onProcessReplayComplete = this._onProcessReplayComplete.event; private readonly _onDidChangeProperty = this._register(new Emitter()); readonly onDidChangeProperty = this._onDidChangeProperty.event; - private readonly _onEnvironmentVariableInfoChange = this._register(new Emitter()); + private readonly _onEnvironmentVariableInfoChange = this._register(new Emitter()); readonly onEnvironmentVariableInfoChanged = this._onEnvironmentVariableInfoChange.event; private readonly _onProcessExit = this._register(new Emitter()); readonly onProcessExit = this._onProcessExit.event; @@ -187,7 +187,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce if (environmentVariableCollections) { this._extEnvironmentVariableCollection = new MergedEnvironmentVariableCollection(environmentVariableCollections); - this._environmentVariableCollectionListener.value = this._environmentVariableService.onDidChangeCollections(newCollection => this._onEnvironmentVariableCollectionChange(newCollection)); + this._environmentVariableCollectionListener.value = this._environmentVariableService.onDidChangeCollections(() => this._onEnvironmentVariableCollectionChange()); this.environmentVariableInfo = this._instantiationService.createInstance(EnvironmentVariableInfoChangesActive, this._extEnvironmentVariableCollection); this._onEnvironmentVariableInfoChange.fire(this.environmentVariableInfo); } @@ -284,13 +284,14 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce this.userHome = remoteEnv.userHome.path; this.os = remoteEnv.os; - // this is a copy of what the merged environment collection is on the remote side - const env = await this._resolveEnvironment(backend, variableResolver, shellLaunchConfig); const shouldPersist = ((this._configurationService.getValue(TaskSettingId.Reconnection) && shellLaunchConfig.reconnectionProperties) || !shellLaunchConfig.isFeatureTerminal) && this._terminalConfigurationService.config.enablePersistentSessions && !shellLaunchConfig.isTransient; if (shellLaunchConfig.attachPersistentProcess) { const result = await backend.attachToProcess(shellLaunchConfig.attachPersistentProcess.id); if (result) { newProcess = result; + if (this._extEnvironmentVariableCollection) { + this._onEnvironmentVariableCollectionChange(); + } } else { // Warn and just create a new terminal if attach failed for some reason this._logService.warn(`Attach to process failed for terminal`, shellLaunchConfig.attachPersistentProcess); @@ -298,6 +299,10 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce } } if (!newProcess) { + // This is a copy of what the merged environment collection is on the remote side. + // Do not resolve it until attachment has failed: an attached process retains its + // actual historical collection as the diff baseline until it is relaunched. + const env = await this._resolveEnvironment(backend, variableResolver, shellLaunchConfig); await this._terminalProfileResolverService.resolveShellLaunchConfig(shellLaunchConfig, { remoteAuthority: this.remoteAuthority, os: this.os @@ -309,7 +314,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce nonce: this.shellIntegrationNonce }, windowsUseConptyDll: this._terminalConfigurationService.config.windowsUseConptyDll ?? false, - environmentVariableCollections: this._extEnvironmentVariableCollection?.collections ? serializeEnvironmentVariableCollections(this._extEnvironmentVariableCollection.collections) : undefined, + environmentVariableCollections: shouldUseEnvironmentVariableCollection(shellLaunchConfig) && this._extEnvironmentVariableCollection?.collections ? serializeEnvironmentVariableCollections(this._extEnvironmentVariableCollection.collections) : undefined, workspaceFolder: this._cwdWorkspaceFolder, isScreenReaderOptimized: this._accessibilityService.isScreenReaderOptimized() }; @@ -341,6 +346,9 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce const result = findRevivedId ? await backend.attachToRevivedProcess(id, pid) : await backend.attachToProcess(id); if (result) { newProcess = result; + if (this._extEnvironmentVariableCollection) { + this._onEnvironmentVariableCollectionChange(); + } } else { // Warn and just create a new terminal if attach failed for some reason this._logService.warn(`Attach to process failed for terminal`, shellLaunchConfig.attachPersistentProcess); @@ -475,9 +483,9 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce this._logService.debug(`Terminal environment created with ${Object.keys(env).length} variables: ${Object.keys(env).sort().join(', ')}`); this._environmentVariableCollectionListener.clear(); if (!this._isDisposed && shouldUseEnvironmentVariableCollection(shellLaunchConfig)) { - this._extEnvironmentVariableCollection = this._environmentVariableService.mergedCollection; + this._extEnvironmentVariableCollection = this._environmentVariableService.getMergedCollection(this.remoteAuthority, this.os ?? await this.getBackendOS()); - this._environmentVariableCollectionListener.value = this._environmentVariableService.onDidChangeCollections(newCollection => this._onEnvironmentVariableCollectionChange(newCollection)); + this._environmentVariableCollectionListener.value = this._environmentVariableService.onDidChangeCollections(() => this._onEnvironmentVariableCollectionChange()); // For remote terminals, this is a copy of the mergedEnvironmentCollection created on // the remote side. Since the environment collection is synced between the remote and // local sides immediately this is a fairly safe way of enabling the env var diffing and @@ -488,11 +496,23 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce if (this._extEnvironmentVariableCollection.getVariableMap({ workspaceFolder }).size) { this.environmentVariableInfo = this._instantiationService.createInstance(EnvironmentVariableInfoChangesActive, this._extEnvironmentVariableCollection); this._onEnvironmentVariableInfoChange.fire(this.environmentVariableInfo); + } else { + this._clearEnvironmentVariableInfo(); } + } else { + this._extEnvironmentVariableCollection = undefined; + this._clearEnvironmentVariableInfo(); } return env; } + private _clearEnvironmentVariableInfo(): void { + if (this.environmentVariableInfo !== undefined) { + this.environmentVariableInfo = undefined; + this._onEnvironmentVariableInfoChange.fire(undefined); + } + } + private async _launchLocalProcess( backend: ITerminalBackend, shellLaunchConfig: IShellLaunchConfig, @@ -525,7 +545,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce nonce: this.shellIntegrationNonce }, windowsUseConptyDll: this._terminalConfigurationService.config.windowsUseConptyDll ?? false, - environmentVariableCollections: this._extEnvironmentVariableCollection ? serializeEnvironmentVariableCollections(this._extEnvironmentVariableCollection.collections) : undefined, + environmentVariableCollections: shouldUseEnvironmentVariableCollection(shellLaunchConfig) && this._extEnvironmentVariableCollection ? serializeEnvironmentVariableCollections(this._extEnvironmentVariableCollection.collections) : undefined, workspaceFolder: this._cwdWorkspaceFolder, isScreenReaderOptimized: this._accessibilityService.isScreenReaderOptimized() }; @@ -723,7 +743,11 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce this._onProcessStateChange.fire(); } - private _onEnvironmentVariableCollectionChange(newCollection: IMergedEnvironmentVariableCollection): void { + private _onEnvironmentVariableCollectionChange(): void { + if (this.os === undefined) { + return; // Process creation will compare collections once the backend OS is known. + } + const newCollection = this._environmentVariableService.getMergedCollection(this.remoteAuthority, this.os); const diff = this._extEnvironmentVariableCollection!.diff(newCollection, { workspaceFolder: this._cwdWorkspaceFolder }); if (diff === undefined) { // If there are no longer differences, remove the stale info indicator diff --git a/src/vs/workbench/contrib/terminal/common/environmentVariable.ts b/src/vs/workbench/contrib/terminal/common/environmentVariable.ts index 5d894d89797..65158fb42e0 100644 --- a/src/vs/workbench/contrib/terminal/common/environmentVariable.ts +++ b/src/vs/workbench/contrib/terminal/common/environmentVariable.ts @@ -5,6 +5,7 @@ import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { Event } from '../../../../base/common/event.js'; +import { OperatingSystem } from '../../../../base/common/platform.js'; import { EnvironmentVariableScope, IEnvironmentVariableCollection, IMergedEnvironmentVariableCollection } from '../../../../platform/terminal/common/environmentVariable.js'; import { ITerminalStatus } from './terminal.js'; @@ -28,6 +29,12 @@ export interface IEnvironmentVariableService { */ readonly mergedCollection: IMergedEnvironmentVariableCollection; + /** + * Gets a collection suitable for a terminal running on the provided authority. PATH + * contributions are restricted to the extension host that produced them. + */ + getMergedCollection(remoteAuthority: string | null | undefined, os: OperatingSystem): IMergedEnvironmentVariableCollection; + /** * An event that is fired when an extension's environment variable collection changes, the event * provides the new merged collection. @@ -47,6 +54,11 @@ export interface IEnvironmentVariableService { export interface IEnvironmentVariableCollectionWithPersistence extends IEnvironmentVariableCollection { readonly persistent: boolean; + /** + * The extension host that produced this collection. An absent value is a legacy, + * unknown origin and must not be treated as local. + */ + readonly remoteAuthority?: string | null; } export interface IEnvironmentVariableInfo { diff --git a/src/vs/workbench/contrib/terminal/common/environmentVariableService.ts b/src/vs/workbench/contrib/terminal/common/environmentVariableService.ts index 0e5a7436dc8..6d26721d190 100644 --- a/src/vs/workbench/contrib/terminal/common/environmentVariableService.ts +++ b/src/vs/workbench/contrib/terminal/common/environmentVariableService.ts @@ -13,11 +13,14 @@ import { IEnvironmentVariableCollectionWithPersistence, IEnvironmentVariableServ import { TerminalStorageKeys } from './terminalStorageKeys.js'; import { IMergedEnvironmentVariableCollection, ISerializableEnvironmentDescriptionMap, ISerializableEnvironmentVariableCollection } from '../../../../platform/terminal/common/environmentVariable.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { OperatingSystem } from '../../../../base/common/platform.js'; interface ISerializableExtensionEnvironmentVariableCollection { extensionIdentifier: string; collection: ISerializableEnvironmentVariableCollection; description?: ISerializableEnvironmentDescriptionMap; + remoteAuthority?: string | null; } /** @@ -28,13 +31,15 @@ export class EnvironmentVariableService extends Disposable implements IEnvironme collections: Map = new Map(); mergedCollection: IMergedEnvironmentVariableCollection; + private readonly _mergedCollectionsByAuthority = new Map>(); private readonly _onDidChangeCollections = this._register(new Emitter()); get onDidChangeCollections(): Event { return this._onDidChangeCollections.event; } constructor( @IExtensionService private readonly _extensionService: IExtensionService, - @IStorageService private readonly _storageService: IStorageService + @IStorageService private readonly _storageService: IStorageService, + @ILogService private readonly _logService: ILogService ) { super(); @@ -45,7 +50,8 @@ export class EnvironmentVariableService extends Disposable implements IEnvironme collectionsJson.forEach(c => this.collections.set(c.extensionIdentifier, { persistent: true, map: deserializeEnvironmentVariableCollection(c.collection), - descriptionMap: deserializeEnvironmentDescriptionMap(c.description) + descriptionMap: deserializeEnvironmentDescriptionMap(c.description), + remoteAuthority: c.remoteAuthority })); // Asynchronously invalidate collections where extensions have been uninstalled, this is @@ -69,8 +75,50 @@ export class EnvironmentVariableService extends Disposable implements IEnvironme this._updateCollections(); } + getMergedCollection(remoteAuthority: string | null | undefined, os: OperatingSystem): IMergedEnvironmentVariableCollection { + const targetAuthority = remoteAuthority?.toLowerCase() ?? null; + let targetCollections = this._mergedCollectionsByAuthority.get(os); + if (!targetCollections) { + targetCollections = new Map(); + this._mergedCollectionsByAuthority.set(os, targetCollections); + } + let mergedCollection = targetCollections.get(targetAuthority); + if (!mergedCollection) { + const collections = new Map(); + this.collections.forEach((collection, extensionIdentifier) => { + const isKnownOrigin = collection.remoteAuthority === null || (typeof collection.remoteAuthority === 'string' && collection.remoteAuthority.length > 0); + if (!isKnownOrigin && collection.remoteAuthority !== undefined) { + this._logService.warn(`Ignoring terminal PATH contributions with an invalid origin for extension '${extensionIdentifier}'.`); + } + const originAuthority = typeof collection.remoteAuthority === 'string' ? collection.remoteAuthority.toLowerCase() : collection.remoteAuthority; + const map = new Map(collection.map); + let filteredPath = false; + for (const [key, mutator] of map) { + const isPath = os === OperatingSystem.Windows ? mutator.variable.toLowerCase() === 'path' : mutator.variable === 'PATH'; + if (isPath && (!isKnownOrigin || originAuthority !== targetAuthority)) { + map.delete(key); + filteredPath = true; + } + } + if (filteredPath && map.size === 0) { + return; + } + if (filteredPath && collection.descriptionMap) { + // PATH-specific descriptions are not truthful after filtering. + collections.set(extensionIdentifier, { ...collection, map, descriptionMap: undefined }); + } else { + collections.set(extensionIdentifier, { ...collection, map }); + } + }); + mergedCollection = new MergedEnvironmentVariableCollection(collections); + targetCollections.set(targetAuthority, mergedCollection); + } + return mergedCollection; + } + private _updateCollections(): void { this._persistCollectionsEventually(); + this._mergedCollectionsByAuthority.clear(); this.mergedCollection = this._resolveMergedCollection(); this._notifyCollectionUpdatesEventually(); } @@ -87,7 +135,8 @@ export class EnvironmentVariableService extends Disposable implements IEnvironme collectionsJson.push({ extensionIdentifier, collection: serializeEnvironmentVariableCollection(this.collections.get(extensionIdentifier)!.map), - description: serializeEnvironmentDescriptionMap(collection.descriptionMap) + description: serializeEnvironmentDescriptionMap(collection.descriptionMap), + remoteAuthority: collection.remoteAuthority }); } }); diff --git a/src/vs/workbench/contrib/terminal/common/remote/remoteTerminalChannel.ts b/src/vs/workbench/contrib/terminal/common/remote/remoteTerminalChannel.ts index 6cdd98743ab..78c5b9a84c6 100644 --- a/src/vs/workbench/contrib/terminal/common/remote/remoteTerminalChannel.ts +++ b/src/vs/workbench/contrib/terminal/common/remote/remoteTerminalChannel.ts @@ -127,7 +127,8 @@ export class RemoteTerminalChannelClient implements IPtyHostController { shouldPersistTerminal: boolean, cols: number, rows: number, - unicodeVersion: '6' | '11' + unicodeVersion: '6' | '11', + os: OperatingSystem ): Promise { // Be sure to first wait for the remote configuration await this._configurationService.whenRemoteConfigurationLoaded(); @@ -149,8 +150,10 @@ export class RemoteTerminalChannelClient implements IPtyHostController { } const envVariableCollections: ITerminalEnvironmentVariableCollections = []; - for (const [k, v] of this._environmentVariableService.collections.entries()) { - envVariableCollections.push([k, serializeEnvironmentVariableCollection(v.map), serializeEnvironmentDescriptionMap(v.descriptionMap)]); + if (options.environmentVariableCollections !== undefined) { + for (const [k, v] of this._environmentVariableService.getMergedCollection(this._remoteAuthority, os).collections.entries()) { + envVariableCollections.push([k, serializeEnvironmentVariableCollection(v.map), serializeEnvironmentDescriptionMap(v.descriptionMap)]); + } } const resolverResult = await this._remoteAuthorityResolverService.resolveAuthority(this._remoteAuthority); diff --git a/src/vs/workbench/contrib/terminal/common/terminal.ts b/src/vs/workbench/contrib/terminal/common/terminal.ts index 0532a06259c..629dc80350e 100644 --- a/src/vs/workbench/contrib/terminal/common/terminal.ts +++ b/src/vs/workbench/contrib/terminal/common/terminal.ts @@ -294,7 +294,7 @@ export interface ITerminalProcessManager extends IDisposable, ITerminalProcessIn readonly onBeforeProcessData: Event; readonly onProcessData: Event; readonly onProcessReplayComplete: Event; - readonly onEnvironmentVariableInfoChanged: Event; + readonly onEnvironmentVariableInfoChanged: Event; readonly onDidChangeProperty: Event; readonly onProcessExit: Event; readonly onRestoreCommands: Event; diff --git a/src/vs/workbench/contrib/terminal/electron-browser/localTerminalBackend.ts b/src/vs/workbench/contrib/terminal/electron-browser/localTerminalBackend.ts index 5f1d3e270f4..1e24b60ef57 100644 --- a/src/vs/workbench/contrib/terminal/electron-browser/localTerminalBackend.ts +++ b/src/vs/workbench/contrib/terminal/electron-browser/localTerminalBackend.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter } from '../../../../base/common/event.js'; -import { IProcessEnvironment, isMacintosh, isWindows, OperatingSystem } from '../../../../base/common/platform.js'; +import { IProcessEnvironment, isMacintosh, isWindows, OperatingSystem, OS } from '../../../../base/common/platform.js'; import { URI } from '../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; @@ -40,6 +40,7 @@ import { IRemoteAgentService } from '../../../services/remote/common/remoteAgent import { INativeWorkbenchEnvironmentService } from '../../../services/environment/electron-browser/environmentService.js'; import { shouldUseEnvironmentVariableCollection } from '../../../../platform/terminal/common/terminalEnvironment.js'; import { DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { serializeEnvironmentVariableCollections } from '../../../../platform/terminal/common/environmentVariableShared.js'; export class LocalTerminalBackendContribution implements IWorkbenchContribution { @@ -343,12 +344,11 @@ export class LocalTerminalBackend extends BaseTerminalBackend implements ITermin // Re-resolve the environments and replace it on the state so local terminals use a fresh // environment mark('code/terminal/willGetReviveEnvironments'); - await Promise.all(reviveBufferState.map(state => new Promise(r => { - this._resolveEnvironmentForRevive(variableResolver, state.shellLaunchConfig).then(freshEnv => { - state.processLaunchConfig.env = freshEnv; - r(); - }); - }))); + await Promise.all(reviveBufferState.map(async state => { + const { env, environmentVariableCollections } = await this._resolveEnvironmentForRevive(variableResolver, state.shellLaunchConfig); + state.processLaunchConfig.env = env; + state.processLaunchConfig.options.environmentVariableCollections = environmentVariableCollections; + })); mark('code/terminal/didGetReviveEnvironments'); mark('code/terminal/willReviveTerminalProcesses'); @@ -372,16 +372,19 @@ export class LocalTerminalBackend extends BaseTerminalBackend implements ITermin return this._proxy.getTerminalLayoutInfo(layoutArgs); } - private async _resolveEnvironmentForRevive(variableResolver: terminalEnvironment.VariableResolver | undefined, shellLaunchConfig: IShellLaunchConfig): Promise { + private async _resolveEnvironmentForRevive(variableResolver: terminalEnvironment.VariableResolver | undefined, shellLaunchConfig: IShellLaunchConfig): Promise<{ env: IProcessEnvironment; environmentVariableCollections: ReturnType | undefined }> { const platformKey = isWindows ? 'windows' : (isMacintosh ? 'osx' : 'linux'); const envFromConfigValue = this._configurationService.getValue(`terminal.integrated.env.${platformKey}`); const baseEnv = await (shellLaunchConfig.useShellEnvironment ? this.getShellEnvironment() : this.getEnvironment()); const env = await terminalEnvironment.createTerminalEnvironment(shellLaunchConfig, envFromConfigValue, variableResolver, this._productService.version, this._configurationService.getValue(TerminalSettingId.DetectLocale), baseEnv); + let environmentVariableCollections: ReturnType | undefined; if (shouldUseEnvironmentVariableCollection(shellLaunchConfig)) { const workspaceFolder = terminalEnvironment.getWorkspaceForTerminal(shellLaunchConfig.cwd, this._workspaceContextService, this._historyService); - await this._environmentVariableService.mergedCollection.applyToProcessEnvironment(env, { workspaceFolder }, variableResolver); + const collection = this._environmentVariableService.getMergedCollection(this.remoteAuthority, OS); + await collection.applyToProcessEnvironment(env, { workspaceFolder }, variableResolver); + environmentVariableCollections = serializeEnvironmentVariableCollections(collection.collections); } - return env; + return { env, environmentVariableCollections }; } private _getWorkspaceName(): string { diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalProcessManager.test.ts b/src/vs/workbench/contrib/terminal/test/browser/terminalProcessManager.test.ts index f42e1e377ab..6dc7b356a39 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/terminalProcessManager.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/terminalProcessManager.test.ts @@ -3,18 +3,23 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { deepStrictEqual, strictEqual } from 'assert'; +import { deepStrictEqual, ok, strictEqual } from 'assert'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { Schemas } from '../../../../../base/common/network.js'; -import { OperatingSystem } from '../../../../../base/common/platform.js'; +import { getCaseInsensitive } from '../../../../../base/common/objects.js'; +import { OperatingSystem, type IProcessEnvironment } from '../../../../../base/common/platform.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { upcastPartial } from '../../../../../base/test/common/mock.js'; import { IConfigurationService, type IConfigurationChangeEvent } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; -import { ITerminalChildProcess, TitleEventSource, type IProcessReadyEvent, type ITerminalBackend } from '../../../../../platform/terminal/common/terminal.js'; +import { EnvironmentVariableMutatorType, type IEnvironmentVariableCollection } from '../../../../../platform/terminal/common/environmentVariable.js'; +import { ITerminalChildProcess, TitleEventSource, type IProcessReadyEvent, type ITerminalBackend, type ITerminalProcessOptions } from '../../../../../platform/terminal/common/terminal.js'; import { ITerminalInstanceService, ITerminalService } from '../../browser/terminal.js'; import { TerminalProcessManager } from '../../browser/terminalProcessManager.js'; import { IEnvironmentVariableService } from '../../common/environmentVariable.js'; +import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js'; +import { IRemoteAgentEnvironment } from '../../../../../platform/remote/common/remoteAgentEnvironment.js'; import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; function listenerCount(emitter: Emitter): number { @@ -64,6 +69,7 @@ class TestTerminalInstanceService implements Partial { readonly ptyHostRestartEmitter = new Emitter(); readonly revivedAttachArgs: [id: number, pid: number][] = []; readonly createdProcesses: TestTerminalChildProcess[] = []; + readonly createProcessRequests: { env: IProcessEnvironment; options: ITerminalProcessOptions }[] = []; refuseRevivedAttach = false; async getBackend() { return { @@ -79,10 +85,11 @@ class TestTerminalInstanceService implements Partial { cols: number, rows: number, unicodeVersion: '6' | '11', - env: any, - options: any, + env: IProcessEnvironment, + options: ITerminalProcessOptions, shouldPersist: boolean ) => { + this.createProcessRequests.push({ env: { ...env }, options }); const childProcess = new TestTerminalChildProcess(shouldPersist); childProcess.id = this.createdProcesses.push(childProcess); return childProcess; @@ -101,11 +108,12 @@ suite('Workbench - TerminalProcessManager', () => { let manager: TerminalProcessManager; let terminalInstanceService: TestTerminalInstanceService; let environmentVariableService: IEnvironmentVariableService; + let instantiationService: ReturnType; const store = ensureNoDisposablesAreLeakedInTestSuite(); setup(async () => { - const instantiationService = workbenchInstantiationService(undefined, store); + instantiationService = workbenchInstantiationService(undefined, store); const configurationService = instantiationService.get(IConfigurationService) as TestConfigurationService; await configurationService.setUserConfiguration('editor', { fontFamily: 'foo' }); await configurationService.setUserConfiguration('terminal', { @@ -129,6 +137,135 @@ suite('Workbench - TerminalProcessManager', () => { manager = store.add(instantiationService.createInstance(TerminalProcessManager, 1, undefined, undefined, undefined)); }); + interface IEnvironmentVariableCollectionWithProducer extends IEnvironmentVariableCollection { + readonly remoteAuthority?: string | null; + } + + function setCollection(extensionIdentifier: string, collection: IEnvironmentVariableCollectionWithProducer): void { + environmentVariableService.set(extensionIdentifier, collection); + } + + function serializedVariables(options: ITerminalProcessOptions): string[] { + return options.environmentVariableCollections?.flatMap(([, collection]) => collection.map(([, mutator]) => mutator.variable)) ?? []; + } + + test('does not apply or serialize PATH from a different terminal host while retaining non-PATH variables', async () => { + setCollection('foreign.extension', { + remoteAuthority: 'ssh-remote+foreign-host', + map: new Map([ + ['foreign-path', { variable: 'PATH', value: ':/foreign/bin', type: EnvironmentVariableMutatorType.Append }], + ['shared-value', { variable: 'SHARED_VALUE', value: 'available', type: EnvironmentVariableMutatorType.Replace }] + ]) + }); + setCollection('local.extension', { + remoteAuthority: null, + map: new Map([ + ['local-path', { variable: 'PATH', value: ':/local/bin', type: EnvironmentVariableMutatorType.Append }] + ]) + }); + + await manager.createProcess({}, 80, 24, false); + + const request = terminalInstanceService.createProcessRequests.at(-1)!; + const path = request.env.PATH ?? getCaseInsensitive(request.env, 'PATH'); + ok(typeof path === 'string'); + strictEqual(path.includes('/foreign/bin'), false); + strictEqual(path.includes('/local/bin'), true); + strictEqual(request.env.SHARED_VALUE, 'available'); + deepStrictEqual(serializedVariables(request.options), ['SHARED_VALUE', 'PATH']); + }); + + test('does not carry a historical collection into a strict-environment launch', async () => { + const historicalManager = store.add(instantiationService.createInstance(TerminalProcessManager, 2, undefined, new Map([ + ['historical.extension', { + map: new Map([ + ['historical-path', { variable: 'PATH', value: ':/historical/bin', type: EnvironmentVariableMutatorType.Append }] + ]) + }] + ]), undefined)); + const environmentInfoChanges: (typeof historicalManager.environmentVariableInfo)[] = []; + store.add(historicalManager.onEnvironmentVariableInfoChanged(info => environmentInfoChanges.push(info))); + + await historicalManager.createProcess({ strictEnv: true, env: { PATH: '/strict/bin' } }, 80, 24, false); + + const request = terminalInstanceService.createProcessRequests.at(-1)!; + strictEqual(request.env.PATH, '/strict/bin'); + strictEqual(request.options.environmentVariableCollections, undefined); + strictEqual(historicalManager.environmentVariableInfo, undefined); + deepStrictEqual(environmentInfoChanges, [undefined]); + }); + + test('clears historical PATH status when attachment falls back to a fresh launch with no eligible collection', async () => { + const historicalManager = store.add(instantiationService.createInstance(TerminalProcessManager, 3, undefined, new Map([ + ['historical.extension', { + map: new Map([ + ['historical-path', { variable: 'PATH', value: ':/historical/bin', type: EnvironmentVariableMutatorType.Append }] + ]) + }] + ]), undefined)); + terminalInstanceService.refuseRevivedAttach = true; + const environmentInfoChanges: (typeof historicalManager.environmentVariableInfo)[] = []; + store.add(historicalManager.onEnvironmentVariableInfoChanged(info => environmentInfoChanges.push(info))); + + await historicalManager.createProcess({ + attachPersistentProcess: { id: 21, pid: 72265, findRevivedId: true, title: 'zsh', titleSource: TitleEventSource.Process, cwd: '', shellIntegrationNonce: '' } + }, 80, 24, false); + + strictEqual(historicalManager.environmentVariableInfo, undefined); + deepStrictEqual(environmentInfoChanges, [undefined]); + }); + + test('ignores collection changes that affect only another terminal host', async () => { + setCollection('foreign.extension', { + remoteAuthority: 'ssh-remote+foreign-host', + map: new Map([ + ['foreign-path', { variable: 'PATH', value: ':/foreign/bin', type: EnvironmentVariableMutatorType.Append }] + ]) + }); + await manager.createProcess({}, 80, 24, false); + + const changeCollectionsEmitter = (environmentVariableService as unknown as { _onDidChangeCollections: Emitter })._onDidChangeCollections; + changeCollectionsEmitter.fire(environmentVariableService.mergedCollection); + + strictEqual(manager.environmentVariableInfo, undefined); + }); + + for (const os of [OperatingSystem.Windows, OperatingSystem.Linux, OperatingSystem.Macintosh]) { + test(`uses remote OS ${os} and case-insensitive authority for process options and updates`, async () => { + instantiationService.stub(IRemoteAgentService, { + getEnvironment: async () => upcastPartial({ os, userHome: URI.file('/home/test') }) + }); + setCollection('remote.extension', { + remoteAuthority: 'wsl+ubuntu', + map: new Map([['PATH', { variable: 'PATH', value: '/tools:', type: EnvironmentVariableMutatorType.Prepend }]]) + }); + setCollection('local.extension', { + remoteAuthority: null, + map: new Map(['Path', 'path'].map(variable => [variable, { + variable, value: 'value', type: EnvironmentVariableMutatorType.Replace + }])) + }); + const remoteManager = store.add(instantiationService.createInstance(TerminalProcessManager, 3, + URI.from({ scheme: Schemas.vscodeRemote, authority: 'wsl+Ubuntu', path: '/workspace' }), undefined, undefined)); + await remoteManager.createProcess({}, 80, 24, false); + const variables = serializedVariables(terminalInstanceService.createProcessRequests.at(-1)!.options); + const beforeUpdate = remoteManager.environmentVariableInfo?.requiresAction; + + const changed = Event.toPromise(environmentVariableService.onDidChangeCollections); + setCollection('remote.extension', { + remoteAuthority: 'WSL+UBUNTU', + map: new Map([['PATH', { variable: 'PATH', value: '/updated:', type: EnvironmentVariableMutatorType.Prepend }]]) + }); + await changed; + + deepStrictEqual({ variables, beforeUpdate, afterUpdate: remoteManager.environmentVariableInfo?.requiresAction }, { + variables: os === OperatingSystem.Windows ? ['PATH'] : ['PATH', 'Path', 'path'], + beforeUpdate: false, + afterUpdate: true + }); + }); + } + test('does not accumulate environment variable collection listeners when relaunching', async () => { const changeCollectionsEmitter = (environmentVariableService as unknown as { _onDidChangeCollections: Emitter })._onDidChangeCollections; const initialListenerCount = listenerCount(changeCollectionsEmitter); diff --git a/src/vs/workbench/contrib/terminal/test/common/environmentVariableService.test.ts b/src/vs/workbench/contrib/terminal/test/common/environmentVariableService.test.ts index 738dde85d98..c72230c2484 100644 --- a/src/vs/workbench/contrib/terminal/test/common/environmentVariableService.test.ts +++ b/src/vs/workbench/contrib/terminal/test/common/environmentVariableService.test.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { deepStrictEqual } from 'assert'; +import { deepStrictEqual, notStrictEqual, strictEqual } from 'assert'; import { TestExtensionService, TestHistoryService, TestStorageService } from '../../../../test/common/workbenchTestServices.js'; import { EnvironmentVariableService } from '../../common/environmentVariableService.js'; import { EnvironmentVariableMutatorType, IEnvironmentVariableMutator } from '../../../../../platform/terminal/common/environmentVariable.js'; @@ -11,10 +11,12 @@ import { IStorageService } from '../../../../../platform/storage/common/storage. import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; import { Emitter } from '../../../../../base/common/event.js'; -import { IProcessEnvironment } from '../../../../../base/common/platform.js'; +import { IProcessEnvironment, OperatingSystem } from '../../../../../base/common/platform.js'; import { IHistoryService } from '../../../../services/history/common/history.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IEnvironmentVariableCollectionWithPersistence } from '../../common/environmentVariable.js'; class TestEnvironmentVariableService extends EnvironmentVariableService { persistCollections(): void { this._persistCollections(); } @@ -27,13 +29,16 @@ suite('EnvironmentVariable - EnvironmentVariableService', () => { let instantiationService: TestInstantiationService; let environmentVariableService: TestEnvironmentVariableService; let changeExtensionsEvent: Emitter; + let warnings: string[]; setup(() => { changeExtensionsEvent = store.add(new Emitter()); + warnings = []; instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(IExtensionService, TestExtensionService); instantiationService.stub(IStorageService, store.add(new TestStorageService())); + instantiationService.stub(ILogService, { warn(message: string) { warnings.push(message); } }); instantiationService.stub(IHistoryService, new TestHistoryService()); instantiationService.stub(IExtensionService, TestExtensionService); instantiationService.stub(IExtensionService, 'onDidChangeExtensions', changeExtensionsEvent.event); @@ -68,6 +73,23 @@ suite('EnvironmentVariable - EnvironmentVariableService', () => { ]); }); + test('preserves a known local origin distinctly from a legacy unknown origin', () => { + environmentVariableService.set('local', { + persistent: true, + remoteAuthority: null, + map: new Map([['PATH', { value: ':/local', type: EnvironmentVariableMutatorType.Append, variable: 'PATH' }]]) + }); + environmentVariableService.set('legacy', { + persistent: true, + map: new Map([['PATH', { value: ':/legacy', type: EnvironmentVariableMutatorType.Append, variable: 'PATH' }]]) + }); + environmentVariableService.persistCollections(); + + const restored = store.add(instantiationService.createInstance(TestEnvironmentVariableService)); + deepStrictEqual(restored.collections.get('local')!.remoteAuthority, null); + deepStrictEqual(restored.collections.get('legacy')!.remoteAuthority, undefined); + }); + suite('mergedCollection', () => { test('should overwrite any other variable with the first extension that replaces', () => { const collection1 = new Map(); @@ -143,5 +165,129 @@ suite('EnvironmentVariable - EnvironmentVariableService', () => { await environmentVariableService.mergedCollection.applyToProcessEnvironment(env, scope1); deepStrictEqual(env, { A: 'a2:foo:a1' }); }); + + suite('getMergedCollection', () => { + test('shares a cached view across authority casing, including restored origins', () => { + environmentVariableService.set('ext1', { + persistent: true, + remoteAuthority: 'wsl+Ubuntu', + map: new Map([['PATH', { variable: 'PATH', value: '/tools:', type: EnvironmentVariableMutatorType.Prepend }]]) + }); + environmentVariableService.persistCollections(); + const restored = store.add(instantiationService.createInstance(TestEnvironmentVariableService)); + const lower = restored.getMergedCollection('wsl+ubuntu', OperatingSystem.Linux); + const mixed = restored.getMergedCollection('wsl+Ubuntu', OperatingSystem.Linux); + deepStrictEqual([...lower.getVariableMap(undefined).keys()], ['PATH']); + strictEqual(lower, mixed); + deepStrictEqual([...restored.getMergedCollection('wsl+other', OperatingSystem.Linux).getVariableMap(undefined).keys()], []); + }); + + test('preserves foreign POSIX Path and path variables independently of PATH', () => { + environmentVariableService.set('ext1', { + persistent: true, + remoteAuthority: null, + map: new Map(['PATH', 'Path', 'path'].map(variable => [variable, { + variable, value: 'value', type: EnvironmentVariableMutatorType.Replace + }])) + }); + const collection = environmentVariableService.getMergedCollection('ssh-remote+linux', OperatingSystem.Linux); + deepStrictEqual([...collection.getVariableMap(undefined).keys()], ['Path', 'path']); + const mac = environmentVariableService.getMergedCollection('ssh-remote+linux', OperatingSystem.Macintosh); + deepStrictEqual([...mac.getVariableMap(undefined).keys()], ['Path', 'path']); + const windows = environmentVariableService.getMergedCollection('ssh-remote+linux', OperatingSystem.Windows); + deepStrictEqual([...windows.getVariableMap(undefined).keys()], []); + notStrictEqual(windows, collection); + strictEqual(environmentVariableService.getMergedCollection('SSH-REMOTE+LINUX', OperatingSystem.Linux), collection); + }); + + test('filters PATH by producer authority before merging and preserves non-PATH mutations', () => { + environmentVariableService.set('foreignReplace', { + persistent: true, + remoteAuthority: 'ssh-remote+windows', + map: new Map([ + ['opaque-path-key', { variable: 'Path', value: 'C:\\copilot;', type: EnvironmentVariableMutatorType.Replace }], + ['editor-key', { variable: 'EDITOR', value: 'code', type: EnvironmentVariableMutatorType.Replace }] + ]), + descriptionMap: new Map([['description', { description: 'Copilot command' }]]) + }); + environmentVariableService.set('localAppend', { + persistent: true, + remoteAuthority: null, + map: new Map([['scoped-path-key', { + variable: 'PATH', + value: ':/copilot', + type: EnvironmentVariableMutatorType.Append, + scope: { workspaceFolder: { uri: URI.file('workspace'), name: 'workspace', index: 0 } }, + options: { applyAtProcessCreation: true, applyAtShellIntegration: true } + }]]), + descriptionMap: new Map() + }); + + const local = environmentVariableService.getMergedCollection(undefined, OperatingSystem.Windows); + deepStrictEqual([...local.getVariableMap(undefined).entries()], [ + ['EDITOR', [{ extensionIdentifier: 'foreignReplace', type: EnvironmentVariableMutatorType.Replace, value: 'code', variable: 'EDITOR', options: undefined }]] + ]); + deepStrictEqual([...local.getVariableMap({ workspaceFolder: { uri: URI.file('workspace'), name: 'workspace', index: 0 } }).entries()], [ + ['EDITOR', [{ extensionIdentifier: 'foreignReplace', type: EnvironmentVariableMutatorType.Replace, value: 'code', variable: 'EDITOR', options: undefined }]], + ['PATH', [{ extensionIdentifier: 'localAppend', type: EnvironmentVariableMutatorType.Append, value: ':/copilot', variable: 'PATH', scope: { workspaceFolder: { uri: URI.file('workspace'), name: 'workspace', index: 0 } }, options: { applyAtProcessCreation: true, applyAtShellIntegration: true } }]] + ]); + deepStrictEqual([...local.getDescriptionMap(undefined).entries()], []); + deepStrictEqual([...environmentVariableService.getMergedCollection('ssh-remote+windows', OperatingSystem.Windows).getVariableMap(undefined).entries()], [ + ['Path', [{ extensionIdentifier: 'foreignReplace', type: EnvironmentVariableMutatorType.Replace, value: 'C:\\copilot;', variable: 'Path', options: undefined }]], + ['EDITOR', [{ extensionIdentifier: 'foreignReplace', type: EnvironmentVariableMutatorType.Replace, value: 'code', variable: 'EDITOR', options: undefined }]] + ]); + }); + + test('withholds unknown origins and caches target views until collections change', () => { + environmentVariableService.set('legacy', { + persistent: true, + map: new Map([['PATH', { variable: 'PATH', value: 'C:\\copilot;', type: EnvironmentVariableMutatorType.Prepend }]]) + }); + const local = environmentVariableService.getMergedCollection(null, OperatingSystem.Linux); + deepStrictEqual([...local.getVariableMap(undefined).entries()], []); + strictEqual(environmentVariableService.getMergedCollection(undefined, OperatingSystem.Linux), local); + + environmentVariableService.set('local', { + persistent: true, + remoteAuthority: null, + map: new Map([['PATH', { variable: 'PATH', value: ':/copilot', type: EnvironmentVariableMutatorType.Append }]]) + }); + const updated = environmentVariableService.getMergedCollection(null, OperatingSystem.Linux); + notStrictEqual(updated, local); + deepStrictEqual([...updated.getVariableMap(undefined).keys()], ['PATH']); + }); + + test('filters a foreign PATH Replace before it can suppress a local PATH Append', async () => { + environmentVariableService.set('foreignReplace', { + persistent: true, + remoteAuthority: 'ssh-remote+windows', + map: new Map([['PATH', { variable: 'PATH', value: 'C:\\copilot;', type: EnvironmentVariableMutatorType.Replace }]]) + }); + environmentVariableService.set('localAppend', { + persistent: true, + remoteAuthority: null, + map: new Map([['PATH', { variable: 'PATH', value: ':/copilot', type: EnvironmentVariableMutatorType.Append }]]) + }); + + const local = environmentVariableService.getMergedCollection(null, OperatingSystem.Linux); + deepStrictEqual([...local.getVariableMap(undefined).entries()], [ + ['PATH', [{ extensionIdentifier: 'localAppend', type: EnvironmentVariableMutatorType.Append, value: ':/copilot', variable: 'PATH', options: undefined }]] + ]); + const env: IProcessEnvironment = { PATH: '/base' }; + await local.applyToProcessEnvironment(env, undefined); + deepStrictEqual(env, { PATH: '/base:/copilot' }); + }); + + test('withholds malformed origins and logs without exposing environment values', () => { + environmentVariableService.set('invalid', { + persistent: true, + remoteAuthority: '', + map: new Map([['PATH', { variable: 'PATH', value: '/secret/path', type: EnvironmentVariableMutatorType.Append }]]) + } satisfies IEnvironmentVariableCollectionWithPersistence); + + deepStrictEqual([...environmentVariableService.getMergedCollection(null, OperatingSystem.Linux).getVariableMap(undefined).entries()], []); + deepStrictEqual(warnings, ['Ignoring terminal PATH contributions with an invalid origin for extension \'invalid\'.']); + }); + }); }); }); diff --git a/src/vs/workbench/contrib/terminal/test/common/remote/remoteTerminalChannel.test.ts b/src/vs/workbench/contrib/terminal/test/common/remote/remoteTerminalChannel.test.ts new file mode 100644 index 00000000000..085d6edd90e --- /dev/null +++ b/src/vs/workbench/contrib/terminal/test/common/remote/remoteTerminalChannel.test.ts @@ -0,0 +1,150 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { deepStrictEqual } from 'assert'; +import { Event } from '../../../../../../base/common/event.js'; +import { OperatingSystem } from '../../../../../../base/common/platform.js'; +import { IChannel } from '../../../../../../base/parts/ipc/common/ipc.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { TestContextService, TestExtensionService, TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; +import { IExtensionService } from '../../../../../services/extensions/common/extensions.js'; +import { EnvironmentVariableMutatorType, type IEnvironmentVariableCollection } from '../../../../../../platform/terminal/common/environmentVariable.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IStorageService } from '../../../../../../platform/storage/common/storage.js'; +import { IWorkbenchConfigurationService } from '../../../../../services/configuration/common/configuration.js'; +import { IConfigurationResolverService } from '../../../../../services/configurationResolver/common/configurationResolver.js'; +import { IEditorService } from '../../../../../services/editor/common/editorService.js'; +import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; +import { IRemoteAuthorityResolverService } from '../../../../../../platform/remote/common/remoteAuthorityResolver.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { IEnvironmentVariableService } from '../../../common/environmentVariable.js'; +import { EnvironmentVariableService } from '../../../common/environmentVariableService.js'; +import { ITerminalLogService, type ITerminalProcessOptions } from '../../../../../../platform/terminal/common/terminal.js'; +import { RemoteTerminalChannelClient, type ICreateTerminalProcessArguments } from '../../../common/remote/remoteTerminalChannel.js'; +import { RemoteTerminalChannelRequest } from '../../../common/remote/terminal.js'; +import type { ICompleteTerminalConfiguration } from '../../../common/terminal.js'; + +class CapturingChannel implements IChannel { + createRequest: ICreateTerminalProcessArguments | undefined; + + call(command: string, arg?: unknown): Promise { + if (command === RemoteTerminalChannelRequest.CreateProcess && isCreateTerminalProcessArguments(arg)) { + this.createRequest = arg; + } + return Promise.resolve(undefined!); + } + + listen(): Event { + return Event.None; + } +} + +function isCreateTerminalProcessArguments(value: unknown): value is ICreateTerminalProcessArguments { + return typeof value === 'object' && value !== null && 'envVariableCollections' in value; +} + +suite('RemoteTerminalChannelClient', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('serializes only target-eligible PATH mutations while retaining non-PATH mutations', async () => { + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IExtensionService, new TestExtensionService()); + instantiationService.stub(IStorageService, store.add(new TestStorageService())); + instantiationService.stub(IWorkbenchConfigurationService, { + whenRemoteConfigurationLoaded: async () => { } + }); + instantiationService.stub(IWorkspaceContextService, new TestContextService()); + instantiationService.stub(IConfigurationResolverService, { + resolveAsync: async () => { + throw new Error('No configuration variables are resolved by this focused request test'); + } + }); + const environmentVariableService = store.add(instantiationService.createInstance(EnvironmentVariableService)); + const foreignCollection: IEnvironmentVariableCollection & { readonly remoteAuthority: string; readonly persistent: boolean } = { + remoteAuthority: 'ssh-remote+other-host', + persistent: false, + map: new Map([ + ['foreign-path', { variable: 'PATH', value: ':/foreign/bin', type: EnvironmentVariableMutatorType.Append }], + ['foreign-mixed', { variable: 'Path', value: 'mixed', type: EnvironmentVariableMutatorType.Replace }], + ['foreign-lower', { variable: 'path', value: 'lower', type: EnvironmentVariableMutatorType.Replace }], + ['shared-value', { variable: 'SHARED_VALUE', value: 'available', type: EnvironmentVariableMutatorType.Replace }] + ]) + }; + environmentVariableService.set('foreign.extension', foreignCollection); + environmentVariableService.set('target.extension', { + remoteAuthority: 'SSH-REMOTE+TARGET-HOST', + persistent: false, + map: new Map([ + ['target-path', { variable: 'PATH', value: ':/target/bin', type: EnvironmentVariableMutatorType.Append }], + ['target-value', { variable: 'TARGET_VALUE', value: 'available', type: EnvironmentVariableMutatorType.Replace }] + ]) + }); + instantiationService.stub(IEnvironmentVariableService, environmentVariableService); + instantiationService.stub(IRemoteAuthorityResolverService, { + resolveAuthority: async () => ({ + authority: { + authority: 'ssh-remote+target-host', + connectTo: { type: 0, host: 'target-host', port: 22 }, + connectionToken: undefined + } + }) + }); + instantiationService.stub(ITerminalLogService, { + error: () => { } + }); + instantiationService.stub(IEditorService, { + activeEditor: undefined + }); + instantiationService.stub(ILabelService, { + getWorkspaceLabel: () => 'workspace' + }); + instantiationService.stub(IWorkbenchEnvironmentService, { + debugExtensionHost: { port: null, break: false, env: {} } + }); + + const channel = new CapturingChannel(); + const client = instantiationService.createInstance(RemoteTerminalChannelClient, 'ssh-remote+target-host', channel); + const configuration: ICompleteTerminalConfiguration = { + 'terminal.integrated.env.windows': {}, + 'terminal.integrated.env.osx': {}, + 'terminal.integrated.env.linux': {}, + 'terminal.integrated.cwd': '', + 'terminal.integrated.detectLocale': 'auto' + }; + const options: ITerminalProcessOptions = { + shellIntegration: { enabled: false, suggestEnabled: false, nonce: '' }, + windowsUseConptyDll: false, + environmentVariableCollections: [], + workspaceFolder: undefined, + isScreenReaderOptimized: false + }; + + await client.createProcess({}, configuration, undefined, options, false, 80, 24, '6', OperatingSystem.Linux); + + deepStrictEqual(channel.createRequest?.envVariableCollections, [[ + 'foreign.extension', + [ + ['foreign-mixed', { variable: 'Path', value: 'mixed', type: EnvironmentVariableMutatorType.Replace }], + ['foreign-lower', { variable: 'path', value: 'lower', type: EnvironmentVariableMutatorType.Replace }], + ['shared-value', { variable: 'SHARED_VALUE', value: 'available', type: EnvironmentVariableMutatorType.Replace }] + ], + [] + ], [ + 'target.extension', + [ + ['target-path', { variable: 'PATH', value: ':/target/bin', type: EnvironmentVariableMutatorType.Append }], + ['target-value', { variable: 'TARGET_VALUE', value: 'available', type: EnvironmentVariableMutatorType.Replace }] + ], + [] + ]]); + + await client.createProcess({}, configuration, undefined, options, false, 80, 24, '6', OperatingSystem.Windows); + deepStrictEqual(channel.createRequest?.envVariableCollections[0][1].map(([, mutator]) => mutator.variable), ['SHARED_VALUE']); + + await client.createProcess({}, configuration, undefined, { ...options, environmentVariableCollections: undefined }, false, 80, 24, '6', OperatingSystem.Linux); + deepStrictEqual(channel.createRequest?.envVariableCollections, []); + }); +}); diff --git a/src/vs/workbench/contrib/terminal/test/electron-browser/localTerminalBackend.test.ts b/src/vs/workbench/contrib/terminal/test/electron-browser/localTerminalBackend.test.ts index 7ffe6497613..bfd6ecb454c 100644 --- a/src/vs/workbench/contrib/terminal/test/electron-browser/localTerminalBackend.test.ts +++ b/src/vs/workbench/contrib/terminal/test/electron-browser/localTerminalBackend.test.ts @@ -10,12 +10,33 @@ import { upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; -import { ILocalPtyService, ITerminalChildProcess, ITerminalLogService } from '../../../../../platform/terminal/common/terminal.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ExtensionIdentifier, type IExtensionDescription } from '../../../../../platform/extensions/common/extensions.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { EnvironmentVariableMutatorType, type ISerializableEnvironmentVariableCollections } from '../../../../../platform/terminal/common/environmentVariable.js'; +import { ILocalPtyService, ITerminalChildProcess, ITerminalLogService, type ISerializedTerminalState } from '../../../../../platform/terminal/common/terminal.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; -import { TestContextService } from '../../../../test/common/workbenchTestServices.js'; +import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; +import { IConfigurationResolverService } from '../../../../services/configurationResolver/common/configurationResolver.js'; +import { IHistoryService } from '../../../../services/history/common/history.js'; +import { IEnvironmentVariableService } from '../../common/environmentVariable.js'; +import { EnvironmentVariableService } from '../../common/environmentVariableService.js'; +import { ITerminalProfileResolverService } from '../../common/terminal.js'; +import { TerminalStorageKeys } from '../../common/terminalStorageKeys.js'; +import { TestContextService, TestHistoryService, TestStorageService } from '../../../../test/common/workbenchTestServices.js'; import { LocalPty } from '../../electron-browser/localPty.js'; import { LocalTerminalBackend } from '../../electron-browser/localTerminalBackend.js'; +class RecordingLogService extends NullLogService { + readonly warnings: { message: string; args: unknown[] }[] = []; + + override warn(message: string, ...args: unknown[]): void { + this.warnings.push({ message, args }); + } +} + suite('LocalTerminalBackend', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -82,4 +103,106 @@ suite('LocalTerminalBackend', () => { attachById: { attachedId: 3, requests: [[3, undefined]] }, }); }); + + test('revival refreshes both the environment and collection options before sending persisted state to the pty host', async () => { + let revivedState: ISerializedTerminalState[] | undefined; + const instantiationService = store.add(new TestInstantiationService()); + const storageService = store.add(new TestStorageService()); + const logService = new RecordingLogService(); + const registeredExtensions: readonly IExtensionDescription[] = [ + upcastPartial({ + identifier: new ExtensionIdentifier('foreign.extension') + }) + ]; + instantiationService.stub(IWorkspaceContextService, new TestContextService()); + instantiationService.stub(ITerminalLogService, logService); + instantiationService.stub(IStorageService, storageService); + instantiationService.stub(IExtensionService, upcastPartial({ + whenInstalledExtensionsRegistered: async () => true, + onDidChangeExtensions: Event.None, + extensions: registeredExtensions + })); + instantiationService.stub(IConfigurationService, new TestConfigurationService()); + instantiationService.stub(IConfigurationResolverService, upcastPartial({ + resolveWithEnvironment: async (_environment, _folder, value) => value + })); + instantiationService.stub(IHistoryService, new TestHistoryService()); + instantiationService.stub(IProductService, upcastPartial({ version: 'test' })); + instantiationService.stub(ITerminalProfileResolverService, upcastPartial({ + getEnvironment: async () => ({ PATH: '/base/bin' }) + })); + const environmentVariableService = store.add(instantiationService.createInstance(EnvironmentVariableService)); + const foreignCollection = { + remoteAuthority: 'ssh-remote+foreign-host', + map: new Map([ + ['foreign-path', { variable: 'PATH', value: '/foreign/bin:', type: EnvironmentVariableMutatorType.Prepend }], + ['shared-value', { variable: 'SHARED_VALUE', value: 'available', type: EnvironmentVariableMutatorType.Replace }] + ]) + }; + environmentVariableService.set('foreign.extension', { ...foreignCollection, persistent: false }); + instantiationService.stub(IEnvironmentVariableService, environmentVariableService); + instantiationService.stub(ILocalPtyService, upcastPartial({ + onPtyHostExit: Event.None, + onPtyHostStart: Event.None, + onPtyHostUnresponsive: Event.None, + onPtyHostResponsive: Event.None, + onPtyHostRequestResolveVariables: Event.None, + getEnvironment: async () => ({ PATH: '/base/bin' }), + reviveTerminalProcesses: async (_workspaceId, state) => { + revivedState = state; + }, + getTerminalLayoutInfo: async () => undefined + })); + + const staleOptions: ISerializableEnvironmentVariableCollections = [[ + 'foreign.extension', + [['foreign-path', { variable: 'PATH', value: '/foreign/bin:', type: EnvironmentVariableMutatorType.Prepend }]], + [] + ]]; + storageService.store(TerminalStorageKeys.TerminalBufferState, JSON.stringify({ + version: 1, + state: [{ + id: 1, + shellLaunchConfig: {}, + processLaunchConfig: { + env: { PATH: '/stale/bin:/foreign/bin' }, + executableEnv: {}, + options: { + shellIntegration: { enabled: false, suggestEnabled: false, nonce: '' }, + windowsUseConptyDll: false, + environmentVariableCollections: staleOptions, + isScreenReaderOptimized: false + } + } + }] + }), StorageScope.WORKSPACE, StorageTarget.MACHINE); + + const backend = store.add(instantiationService.createInstance(LocalTerminalBackend)); + await backend.getTerminalLayoutInfo(); + deepStrictEqual({ + revivedStateLength: revivedState?.length, + warnings: logService.warnings + }, { + revivedStateLength: 1, + warnings: [] + }); + if (!revivedState) { + throw new Error('Expected persisted terminal state to be revived'); + } + + deepStrictEqual({ + env: { + PATH: revivedState[0].processLaunchConfig.env.PATH, + SHARED_VALUE: revivedState[0].processLaunchConfig.env.SHARED_VALUE + }, + collections: revivedState[0].processLaunchConfig.options.environmentVariableCollections + }, { + env: { PATH: '/base/bin', SHARED_VALUE: 'available' }, + collections: [[ + 'foreign.extension', + [['shared-value', { variable: 'SHARED_VALUE', value: 'available', type: EnvironmentVariableMutatorType.Replace }]], + [] + ]] + }); + }); }); diff --git a/src/vs/workbench/services/extensions/browser/extensionService.ts b/src/vs/workbench/services/extensions/browser/extensionService.ts index b4f6cf16e7b..531e1adffd6 100644 --- a/src/vs/workbench/services/extensions/browser/extensionService.ts +++ b/src/vs/workbench/services/extensions/browser/extensionService.ts @@ -298,6 +298,7 @@ class BrowserExtensionHostFactory implements IExtensionHostFactory { return { connectionData: this._remoteAuthorityResolverService.getConnectionData(remoteAuthority), pid: remoteEnv.pid, + os: remoteEnv.os, appRoot: remoteEnv.appRoot, extensionHostLogsPath: remoteEnv.extensionHostLogsPath, globalStorageHome: remoteEnv.globalStorageHome, diff --git a/src/vs/workbench/services/extensions/common/extHostCustomers.ts b/src/vs/workbench/services/extensions/common/extHostCustomers.ts index 763dbdc6ec1..c1e55e7a4e6 100644 --- a/src/vs/workbench/services/extensions/common/extHostCustomers.ts +++ b/src/vs/workbench/services/extensions/common/extHostCustomers.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { OperatingSystem } from '../../../../base/common/platform.js'; import { BrandedService, IConstructorSignature } from '../../../../platform/instantiation/common/instantiation.js'; import { ExtensionHostKind } from './extensionHostKind.js'; import { IExtensionHostProxy } from './extensionHostProxy.js'; @@ -12,6 +13,7 @@ import { IRPCProtocol, ProxyIdentifier } from './proxyIdentifier.js'; export interface IExtHostContext extends IRPCProtocol { readonly remoteAuthority: string | null; + readonly remoteOS?: OperatingSystem; readonly extensionHostKind: ExtensionHostKind; } diff --git a/src/vs/workbench/services/extensions/common/extensionHostManager.ts b/src/vs/workbench/services/extensions/common/extensionHostManager.ts index c7e1f8b6261..4c249c2abb6 100644 --- a/src/vs/workbench/services/extensions/common/extensionHostManager.ts +++ b/src/vs/workbench/services/extensions/common/extensionHostManager.ts @@ -261,6 +261,7 @@ export class ExtensionHostManager extends Disposable implements IExtensionHostMa let mainProxyIdentifiers: ProxyIdentifier[] = []; const extHostContext: IInternalExtHostContext = { remoteAuthority: this._extensionHost.remoteAuthority, + remoteOS: this._extensionHost.remoteOS, extensionHostKind: this.kind, getProxy: (identifier: ProxyIdentifier): Proxied => this._rpcProtocol!.getProxy(identifier), set: (identifier: ProxyIdentifier, instance: R): R => this._rpcProtocol!.set(identifier, instance), diff --git a/src/vs/workbench/services/extensions/common/extensions.ts b/src/vs/workbench/services/extensions/common/extensions.ts index c7fee71a2e2..cd1f98379b9 100644 --- a/src/vs/workbench/services/extensions/common/extensions.ts +++ b/src/vs/workbench/services/extensions/common/extensions.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../../base/common/event.js'; +import { OperatingSystem } from '../../../../base/common/platform.js'; import Severity from '../../../../base/common/severity.js'; import { URI } from '../../../../base/common/uri.js'; import { IMessagePassingProtocol } from '../../../../base/parts/ipc/common/ipc.js'; @@ -123,6 +124,8 @@ export interface IExtensionHost { readonly pid: number | null; readonly runningLocation: ExtensionRunningLocation; readonly remoteAuthority: string | null; + /** Available after start resolves for a remote host. */ + readonly remoteOS?: OperatingSystem; readonly startup: ExtensionHostStartup; /** * A collection of extensions which includes information about which diff --git a/src/vs/workbench/services/extensions/common/remoteExtensionHost.ts b/src/vs/workbench/services/extensions/common/remoteExtensionHost.ts index cc716aa469f..2749e1f3af8 100644 --- a/src/vs/workbench/services/extensions/common/remoteExtensionHost.ts +++ b/src/vs/workbench/services/extensions/common/remoteExtensionHost.ts @@ -30,6 +30,7 @@ import { RemoteRunningLocation } from './extensionRunningLocation.js'; import { ExtensionHostExtensions, ExtensionHostStartup, IExtensionHost } from './extensions.js'; export interface IRemoteExtensionHostInitData { + readonly os: platform.OperatingSystem; readonly connectionData: IRemoteConnectionData | null; readonly pid: number; readonly appRoot: URI; @@ -48,6 +49,7 @@ export class RemoteExtensionHost extends Disposable implements IExtensionHost { public readonly pid = null; public readonly remoteAuthority: string; + public remoteOS: platform.OperatingSystem | undefined; public readonly startup = ExtensionHostStartup.EagerAutoStart; public extensions: ExtensionHostExtensions | null = null; @@ -205,6 +207,7 @@ export class RemoteExtensionHost extends Disposable implements IExtensionHost { private async _createExtHostInitData(isExtensionDevelopmentDebug: boolean): Promise { const remoteInitData = await this._initDataProvider.getInitData(); + this.remoteOS = remoteInitData.os; this.extensions = remoteInitData.extensions; const workspace = this._contextService.getWorkspace(); return { diff --git a/src/vs/workbench/services/extensions/electron-browser/nativeExtensionService.ts b/src/vs/workbench/services/extensions/electron-browser/nativeExtensionService.ts index 615f2e59bb2..6093c984b21 100644 --- a/src/vs/workbench/services/extensions/electron-browser/nativeExtensionService.ts +++ b/src/vs/workbench/services/extensions/electron-browser/nativeExtensionService.ts @@ -653,6 +653,7 @@ class NativeExtensionHostFactory implements IExtensionHostFactory { return { connectionData: this._remoteAuthorityResolverService.getConnectionData(remoteAuthority), pid: remoteEnv.pid, + os: remoteEnv.os, appRoot: remoteEnv.appRoot, extensionHostLogsPath: remoteEnv.extensionHostLogsPath, globalStorageHome: remoteEnv.globalStorageHome,