mirror of
https://github.com/microsoft/vscode.git
synced 2026-10-28 13:26:52 +08:00
terminal: Keep extension PATH contributions on their originating host (#341925)
* terminal: Keep extension PATH contributions on their originating host Record producer authority, filter before merging, and prevent cached foreign PATH values from being re-tagged on extension-host startup. Preserve shared non-PATH values and historical remote recovery until relaunch. Related to #286553. Live Windows/Linux acceptance remains pending. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * terminal: Handle Windows PATH casing in environment regression test Prefer the exact PATH key, then use the existing case-insensitive lookup for inherited Windows Path. Assert the value is present before checking contributions. Related to #286553. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * terminal: Match PATH locality to backend authority and platform Normalize authority casing consistently with terminal backend routing. Preserve POSIX Path/path variables while matching all PATH spellings on Windows, using explicit target OS data for selection and cached extension-host initialization. Related to #286553. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * terminal: Revert PATH documentation changes in vscode.d.ts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
1 parent
c5f8b150e4
commit
e8314212e7
21 files changed
+870
-43
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<IWorkbenchEnvironmentService>() {
|
||||
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<Pick<ExtHostTerminalServiceShape, '$initEnvironmentVariableCollections'>>({
|
||||
$initEnvironmentVariableCollections: onInitialCollections
|
||||
});
|
||||
const extHostContext = new class extends mock<IExtHostContext>() {
|
||||
override readonly remoteAuthority = remoteAuthority;
|
||||
override readonly remoteOS = remoteOS;
|
||||
override getProxy<T>(identifier: ProxyIdentifier<T>) {
|
||||
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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce
|
||||
readonly onProcessReplayComplete = this._onProcessReplayComplete.event;
|
||||
private readonly _onDidChangeProperty = this._register(new Emitter<IProcessProperty>());
|
||||
readonly onDidChangeProperty = this._onDidChangeProperty.event;
|
||||
private readonly _onEnvironmentVariableInfoChange = this._register(new Emitter<IEnvironmentVariableInfo>());
|
||||
private readonly _onEnvironmentVariableInfoChange = this._register(new Emitter<IEnvironmentVariableInfo | undefined>());
|
||||
readonly onEnvironmentVariableInfoChanged = this._onEnvironmentVariableInfoChange.event;
|
||||
private readonly _onProcessExit = this._register(new Emitter<number | undefined>());
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, IEnvironmentVariableCollectionWithPersistence> = new Map();
|
||||
mergedCollection: IMergedEnvironmentVariableCollection;
|
||||
private readonly _mergedCollectionsByAuthority = new Map<OperatingSystem, Map<string | null, IMergedEnvironmentVariableCollection>>();
|
||||
|
||||
private readonly _onDidChangeCollections = this._register(new Emitter<IMergedEnvironmentVariableCollection>());
|
||||
get onDidChangeCollections(): Event<IMergedEnvironmentVariableCollection> { 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<string, IEnvironmentVariableCollectionWithPersistence>();
|
||||
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
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -127,7 +127,8 @@ export class RemoteTerminalChannelClient implements IPtyHostController {
|
||||
shouldPersistTerminal: boolean,
|
||||
cols: number,
|
||||
rows: number,
|
||||
unicodeVersion: '6' | '11'
|
||||
unicodeVersion: '6' | '11',
|
||||
os: OperatingSystem
|
||||
): Promise<ICreateTerminalProcessResult> {
|
||||
// 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);
|
||||
|
||||
@@ -294,7 +294,7 @@ export interface ITerminalProcessManager extends IDisposable, ITerminalProcessIn
|
||||
readonly onBeforeProcessData: Event<IBeforeProcessDataEvent>;
|
||||
readonly onProcessData: Event<IProcessDataEvent>;
|
||||
readonly onProcessReplayComplete: Event<void>;
|
||||
readonly onEnvironmentVariableInfoChanged: Event<IEnvironmentVariableInfo>;
|
||||
readonly onEnvironmentVariableInfoChanged: Event<IEnvironmentVariableInfo | undefined>;
|
||||
readonly onDidChangeProperty: Event<IProcessProperty>;
|
||||
readonly onProcessExit: Event<number | undefined>;
|
||||
readonly onRestoreCommands: Event<ISerializedCommandDetectionCapability>;
|
||||
|
||||
@@ -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<void>(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<IProcessEnvironment> {
|
||||
private async _resolveEnvironmentForRevive(variableResolver: terminalEnvironment.VariableResolver | undefined, shellLaunchConfig: IShellLaunchConfig): Promise<{ env: IProcessEnvironment; environmentVariableCollections: ReturnType<typeof serializeEnvironmentVariableCollections> | undefined }> {
|
||||
const platformKey = isWindows ? 'windows' : (isMacintosh ? 'osx' : 'linux');
|
||||
const envFromConfigValue = this._configurationService.getValue<ITerminalEnvironment | undefined>(`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<typeof serializeEnvironmentVariableCollections> | 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 {
|
||||
|
||||
@@ -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<unknown>): number {
|
||||
@@ -64,6 +69,7 @@ class TestTerminalInstanceService implements Partial<ITerminalInstanceService> {
|
||||
readonly ptyHostRestartEmitter = new Emitter<void>();
|
||||
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<ITerminalInstanceService> {
|
||||
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<typeof workbenchInstantiationService>;
|
||||
|
||||
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<unknown> })._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<IRemoteAgentEnvironment>({ 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<unknown> })._onDidChangeCollections;
|
||||
const initialListenerCount = listenerCount(changeCollectionsEmitter);
|
||||
|
||||
@@ -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<void>;
|
||||
let warnings: string[];
|
||||
|
||||
setup(() => {
|
||||
changeExtensionsEvent = store.add(new Emitter<void>());
|
||||
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<string, IEnvironmentVariableMutator>();
|
||||
@@ -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\'.']);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<T>(command: string, arg?: unknown): Promise<T> {
|
||||
if (command === RemoteTerminalChannelRequest.CreateProcess && isCreateTerminalProcessArguments(arg)) {
|
||||
this.createRequest = arg;
|
||||
}
|
||||
return Promise.resolve(undefined!);
|
||||
}
|
||||
|
||||
listen<T>(): Event<T> {
|
||||
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, []);
|
||||
});
|
||||
});
|
||||
+125
-2
@@ -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<IExtensionDescription>({
|
||||
identifier: new ExtensionIdentifier('foreign.extension')
|
||||
})
|
||||
];
|
||||
instantiationService.stub(IWorkspaceContextService, new TestContextService());
|
||||
instantiationService.stub(ITerminalLogService, logService);
|
||||
instantiationService.stub(IStorageService, storageService);
|
||||
instantiationService.stub(IExtensionService, upcastPartial<IExtensionService>({
|
||||
whenInstalledExtensionsRegistered: async () => true,
|
||||
onDidChangeExtensions: Event.None,
|
||||
extensions: registeredExtensions
|
||||
}));
|
||||
instantiationService.stub(IConfigurationService, new TestConfigurationService());
|
||||
instantiationService.stub(IConfigurationResolverService, upcastPartial<IConfigurationResolverService>({
|
||||
resolveWithEnvironment: async (_environment, _folder, value) => value
|
||||
}));
|
||||
instantiationService.stub(IHistoryService, new TestHistoryService());
|
||||
instantiationService.stub(IProductService, upcastPartial<IProductService>({ version: 'test' }));
|
||||
instantiationService.stub(ITerminalProfileResolverService, upcastPartial<ITerminalProfileResolverService>({
|
||||
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<ILocalPtyService>({
|
||||
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 }]],
|
||||
[]
|
||||
]]
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -261,6 +261,7 @@ export class ExtensionHostManager extends Disposable implements IExtensionHostMa
|
||||
let mainProxyIdentifiers: ProxyIdentifier<any>[] = [];
|
||||
const extHostContext: IInternalExtHostContext = {
|
||||
remoteAuthority: this._extensionHost.remoteAuthority,
|
||||
remoteOS: this._extensionHost.remoteOS,
|
||||
extensionHostKind: this.kind,
|
||||
getProxy: <T>(identifier: ProxyIdentifier<T>): Proxied<T> => this._rpcProtocol!.getProxy(identifier),
|
||||
set: <T, R extends T>(identifier: ProxyIdentifier<T>, instance: R): R => this._rpcProtocol!.set(identifier, instance),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<IExtensionHostInitData> {
|
||||
const remoteInitData = await this._initDataProvider.getInitData();
|
||||
this.remoteOS = remoteInitData.os;
|
||||
this.extensions = remoteInitData.extensions;
|
||||
const workspace = this._contextService.getWorkspace();
|
||||
return {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user