From 0e70246f3f15c9307f85c62ef680587d51ac50bc Mon Sep 17 00:00:00 2001 From: Julia Silge Date: Fri, 9 Oct 2026 14:55:39 -0600 Subject: [PATCH] Stop the Python extension from writing files into the app bundle --- .../creation/common/installCheckUtils.ts | 12 ++- .../deactivateService.ts | 45 +++++++++- .../common/installCheckUtils.unit.test.ts | 15 ++++ .../deactivateService.unit.test.ts | 85 +++++++++++++++++++ 4 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 extensions/positron-python/src/test/terminals/envCollectionActivation/deactivateService.unit.test.ts diff --git a/extensions/positron-python/src/client/pythonEnvironments/creation/common/installCheckUtils.ts b/extensions/positron-python/src/client/pythonEnvironments/creation/common/installCheckUtils.ts index 2d8925cc05f6..e6fa09a29f50 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/creation/common/installCheckUtils.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/creation/common/installCheckUtils.ts @@ -66,7 +66,17 @@ export async function getInstalledPackagesDiagnostics( const scriptPath = installedCheckScript(); try { traceInfo('Running installed packages checker: ', interpreter, scriptPath, doc.uri.fsPath); - const envCopy = { ...process.env, VSCODE_MISSING_PGK_SEVERITY: `${getMissingPackageSeverity(doc)}` }; + // --- Start Positron --- + // const envCopy = { ...process.env, VSCODE_MISSING_PGK_SEVERITY: `${getMissingPackageSeverity(doc)}` }; + // The script imports libraries that ship with the extension. Do not + // write their bytecode into the extension folder, which can be inside + // a signed or read-only app bundle. + const envCopy = { + ...process.env, + VSCODE_MISSING_PGK_SEVERITY: `${getMissingPackageSeverity(doc)}`, + PYTHONDONTWRITEBYTECODE: '1', + }; + // --- End Positron --- const result = await plainExec(interpreter.path, [scriptPath, doc.uri.fsPath], { env: envCopy, }); diff --git a/extensions/positron-python/src/client/terminals/envCollectionActivation/deactivateService.ts b/extensions/positron-python/src/client/terminals/envCollectionActivation/deactivateService.ts index 0758f3e22311..b2e246aa0b10 100644 --- a/extensions/positron-python/src/client/terminals/envCollectionActivation/deactivateService.ts +++ b/extensions/positron-python/src/client/terminals/envCollectionActivation/deactivateService.ts @@ -5,11 +5,17 @@ import { inject, injectable } from 'inversify'; import * as path from 'path'; import { ITerminalManager } from '../../common/application/types'; -import { pathExists } from '../../common/platform/fs-paths'; +// --- Start Positron --- +// import { pathExists } from '../../common/platform/fs-paths'; +import { copyFile, pathExists } from '../../common/platform/fs-paths'; +// --- End Positron --- import { _SCRIPTS_DIR } from '../../common/process/internal/scripts/constants'; import { identifyShellFromShellPath } from '../../common/terminal/shellDetectors/baseShellDetector'; import { ITerminalHelper, TerminalShellType } from '../../common/terminal/types'; -import { Resource } from '../../common/types'; +// --- Start Positron --- +// import { Resource } from '../../common/types'; +import { IExtensionContext, Resource } from '../../common/types'; +// --- End Positron --- import { waitForCondition } from '../../common/utils/async'; import { cache } from '../../common/utils/decorators'; import { StopWatch } from '../../common/utils/stopWatch'; @@ -34,11 +40,19 @@ const ShellIntegrationShells = [ export class TerminalDeactivateService implements ITerminalDeactivateService { private readonly envVarScript = path.join(_SCRIPTS_DIR, 'printEnvVariablesToFile.py'); + // --- Start Positron --- + // constructor( + // @inject(ITerminalManager) private readonly terminalManager: ITerminalManager, + // @inject(IInterpreterService) private readonly interpreterService: IInterpreterService, + // @inject(ITerminalHelper) private readonly terminalHelper: ITerminalHelper, + // ) {} constructor( @inject(ITerminalManager) private readonly terminalManager: ITerminalManager, @inject(IInterpreterService) private readonly interpreterService: IInterpreterService, @inject(ITerminalHelper) private readonly terminalHelper: ITerminalHelper, + @inject(IExtensionContext) private readonly context: IExtensionContext, ) {} + // --- End Positron --- @cache(-1, true) public async initializeScriptParams(shell: string): Promise { @@ -47,6 +61,16 @@ export class TerminalDeactivateService implements ITerminalDeactivateService { return; } const shellType = identifyShellFromShellPath(shell); + // --- Start Positron --- + // The script reads envVars.txt from its own folder, and the extension + // folder can be inside a signed or read-only app bundle. Copy the + // script to global storage so that both files are written there. + const scriptName = this.getScriptName(shellType); + await copyFile( + path.join(_SCRIPTS_DIR, 'deactivate', this.getShellFolderName(shellType), scriptName), + path.join(location, scriptName), + ); + // --- End Positron --- const terminal = this.terminalManager.createTerminal({ name: `Python ${shellType} Deactivate`, shellPath: shell, @@ -81,9 +105,24 @@ export class TerminalDeactivateService implements ITerminalDeactivateService { if (!ShellIntegrationShells.includes(shellType)) { return undefined; } - return path.join(_SCRIPTS_DIR, 'deactivate', this.getShellFolderName(shellType)); + // --- Start Positron --- + // return path.join(_SCRIPTS_DIR, 'deactivate', this.getShellFolderName(shellType)); + return path.join(this.context.globalStorageUri.fsPath, 'deactivate', this.getShellFolderName(shellType)); + // --- End Positron --- } + // --- Start Positron --- + private getScriptName(shellType: TerminalShellType): string { + switch (shellType) { + case TerminalShellType.powershell: + case TerminalShellType.powershellCore: + return 'deactivate.ps1'; + default: + return 'deactivate'; + } + } + // --- End Positron --- + private getShellFolderName(shellType: TerminalShellType): string { switch (shellType) { case TerminalShellType.powershell: diff --git a/extensions/positron-python/src/test/pythonEnvironments/creation/common/installCheckUtils.unit.test.ts b/extensions/positron-python/src/test/pythonEnvironments/creation/common/installCheckUtils.unit.test.ts index a71fd16f7433..4cfffd876ccf 100644 --- a/extensions/positron-python/src/test/pythonEnvironments/creation/common/installCheckUtils.unit.test.ts +++ b/extensions/positron-python/src/test/pythonEnvironments/creation/common/installCheckUtils.unit.test.ts @@ -89,6 +89,21 @@ suite('Install check diagnostics tests', () => { configMock.verifyAll(); }); + // --- Start Positron --- + test('Does not write bytecode into the extension folder', async () => { + configMock.setup((c) => c.get('missingPackage.severity', 'Hint')).returns(() => 'Hint'); + let dontWriteBytecode: string | undefined; + plainExecStub.callsFake((_cmd: string, _args: string[], options: SpawnOptions) => { + dontWriteBytecode = options.env?.PYTHONDONTWRITEBYTECODE; + return { stdout: '', stderr: '' }; + }); + const someFile = getSomeRequirementFile(); + await getInstalledPackagesDiagnostics(interpreterService.object, someFile.object); + + assert.deepStrictEqual(dontWriteBytecode, '1'); + }); + // --- End Positron --- + [ ['Error', '0'], ['Warning', '1'], diff --git a/extensions/positron-python/src/test/terminals/envCollectionActivation/deactivateService.unit.test.ts b/extensions/positron-python/src/test/terminals/envCollectionActivation/deactivateService.unit.test.ts new file mode 100644 index 000000000000..9a6da6465346 --- /dev/null +++ b/extensions/positron-python/src/test/terminals/envCollectionActivation/deactivateService.unit.test.ts @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { assert } from 'chai'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as typemoq from 'typemoq'; +import { Terminal, Uri } from 'vscode'; +import { ITerminalManager } from '../../../client/common/application/types'; +import * as fsPaths from '../../../client/common/platform/fs-paths'; +import { _SCRIPTS_DIR } from '../../../client/common/process/internal/scripts/constants'; +import { ITerminalHelper } from '../../../client/common/terminal/types'; +import { IExtensionContext } from '../../../client/common/types'; +import { IInterpreterService } from '../../../client/interpreter/contracts'; +import { PythonEnvType } from '../../../client/pythonEnvironments/base/info'; +import { PythonEnvironment } from '../../../client/pythonEnvironments/info'; +import { TerminalDeactivateService } from '../../../client/terminals/envCollectionActivation/deactivateService'; + +suite('Terminal deactivate service', () => { + const storageDir = path.join('global', 'storage'); + let copyFileStub: sinon.SinonStub; + let terminalManager: typemoq.IMock; + let interpreterService: typemoq.IMock; + let service: TerminalDeactivateService; + + setup(() => { + copyFileStub = sinon.stub(fsPaths, 'copyFile').resolves(); + sinon.stub(fsPaths, 'pathExists').resolves(true); + + terminalManager = typemoq.Mock.ofType(); + const terminal = typemoq.Mock.ofType(); + terminalManager.setup((t) => t.createTerminal(typemoq.It.isAny())).returns(() => terminal.object); + + interpreterService = typemoq.Mock.ofType(); + interpreterService.setup((i) => i.getInterpreters()).returns(() => []); + interpreterService + .setup((i) => i.getActiveInterpreter(typemoq.It.isAny())) + .returns(() => Promise.resolve({ type: PythonEnvType.Virtual } as PythonEnvironment)); + + const terminalHelper = typemoq.Mock.ofType(); + terminalHelper + .setup((t) => t.buildCommandForTerminal(typemoq.It.isAny(), typemoq.It.isAny(), typemoq.It.isAny())) + .returns(() => 'command'); + + const context = typemoq.Mock.ofType(); + context.setup((c) => c.globalStorageUri).returns(() => Uri.file(storageDir)); + + service = new TerminalDeactivateService( + terminalManager.object, + interpreterService.object, + terminalHelper.object, + context.object, + ); + }); + + teardown(() => { + sinon.restore(); + }); + + test('Script location is in global storage, not the extension folder', async () => { + const location = await service.getScriptLocation('/bin/zsh', undefined); + + assert.deepStrictEqual(location, path.join(Uri.file(storageDir).fsPath, 'deactivate', 'zsh')); + }); + + [ + ['/bin/zsh', 'zsh', 'deactivate'], + ['pwsh', 'powershell', 'deactivate.ps1'], + ].forEach(([shell, folder, script]) => { + test(`Copies the ${folder} script to global storage and runs the terminal there`, async () => { + await service.initializeScriptParams(shell); + + const location = path.join(Uri.file(storageDir).fsPath, 'deactivate', folder); + assert.deepStrictEqual(copyFileStub.args, [ + [path.join(_SCRIPTS_DIR, 'deactivate', folder, script), path.join(location, script)], + ]); + terminalManager.verify( + (t) => t.createTerminal(typemoq.It.is((options) => options.cwd === location)), + typemoq.Times.once(), + ); + }); + }); +});