Enable auto-update for built-in extensions

Add autoUpdateBuiltinExtensions product config to define which built-in
extensions should auto-update. Version-scoped to current VS Code release
(same major.minor). Includes gallery version validation, scanner dedup
filtering, stale version cleanup, and application-scoped installs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Sandeep Somavarapu
2026-03-31 13:04:47 +02:00
parent 778b040ec1
commit 143b631d0c
11 changed files with 288 additions and 6 deletions

View File

@@ -207,6 +207,7 @@ export interface IProductConfiguration {
readonly excludeVersionRange?: string;
}>;
readonly extensionsForceVersionByQuality?: readonly string[];
readonly autoUpdateBuiltinExtensions?: readonly string[];
readonly msftInternalDomains?: string[];
readonly linkProtectionTrustedDomains?: readonly string[];

View File

@@ -331,11 +331,16 @@ export abstract class AbstractExtensionManagementService extends CommontExtensio
};
try {
const systemExtensions = await this.getInstalled(ExtensionType.System);
// Start installing extensions
for (const { manifest, extension, options } of extensions) {
const isApplicationScoped = options.isApplicationScoped || options.isBuiltin || isApplicationScopedExtension(manifest);
const extensionId = getGalleryExtensionId(manifest.publisher, manifest.name);
const isSystemExtension = systemExtensions.some(e => areSameExtensions(e.identifier, { id: extensionId }));
const isBuiltin = options.isBuiltin || isSystemExtension;
const isApplicationScoped = options.isApplicationScoped || isBuiltin || isApplicationScopedExtension(manifest);
const installExtensionTaskOptions: InstallExtensionTaskOptions = {
...options,
isBuiltin,
isApplicationScoped,
profileLocation: isApplicationScoped ? this.userDataProfilesService.defaultProfile.extensionsResource : options.profileLocation ?? this.getCurrentExtensionsManifestLocation(),
productVersion: options.productVersion ?? { version: this.productService.version, date: this.productService.date }

View File

@@ -1000,6 +1000,15 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle
return false;
}
// For auto-update extensions defined in product, only allow versions with same major.minor as the product version
if (this.productService.autoUpdateBuiltinExtensions?.some(id => id.toLowerCase() === extension.id.toLowerCase())) {
const productMajorMinor = `${semver.major(productVersion.version)}.${semver.minor(productVersion.version)}`;
const extensionMajorMinor = `${semver.major(extension.version)}.${semver.minor(extension.version)}`;
if (productMajorMinor !== extensionMajorMinor) {
return false;
}
}
// Specific version
if (isString(version)) {
if (extension.version !== version) {

View File

@@ -286,6 +286,7 @@ export interface ILocalExtension extends IExtension {
preRelease: boolean;
updated: boolean;
pinned: boolean;
autoUpdate: boolean;
source: InstallSource;
size: number;
}

View File

@@ -55,6 +55,7 @@ interface IRelaxedScannedExtension {
isValid: boolean;
validations: readonly [Severity, string][];
preRelease: boolean;
autoUpdate: boolean;
}
export type IScannedExtension = Readonly<IRelaxedScannedExtension> & { manifest: IExtensionManifest };
@@ -351,6 +352,9 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem
}
private dedupExtensions(system: IScannedExtension[] | undefined, user: IScannedExtension[] | undefined, development: IScannedExtension[] | undefined, targetPlatform: TargetPlatform, pickLatest: boolean): IScannedExtension[] {
const autoUpdateBuiltinExtensions = this.productService.autoUpdateBuiltinExtensions;
const productVersion = autoUpdateBuiltinExtensions?.length ? this.getProductVersion() : undefined;
const productMajorMinor = productVersion ? `${semver.major(productVersion.version)}.${semver.minor(productVersion.version)}` : undefined;
const pick = (existing: IScannedExtension, extension: IScannedExtension, isDevelopment: boolean): boolean => {
if (!isDevelopment) {
if (existing.metadata?.isApplicationScoped && !extension.metadata?.isApplicationScoped) {
@@ -399,7 +403,18 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem
this.logService.debug(`Skipping obsolete system extension ${extension.location.path}.`);
return;
}
if (productMajorMinor && autoUpdateBuiltinExtensions?.some(id => id.toLowerCase() === extension.identifier.id.toLowerCase())) {
const extensionMajorMinor = `${semver.major(extension.manifest.version)}.${semver.minor(extension.manifest.version)}`;
if (productMajorMinor !== extensionMajorMinor) {
this.logService.info(`Skipping auto-update builtin extension ${extension.identifier.id} with version ${extension.manifest.version} because it does not match the product version ${productVersion.version}`);
return;
}
}
if (!existing || pick(existing, extension, false)) {
// Mark as builtin when extension is an auto-update builtin extension
if (autoUpdateBuiltinExtensions?.some(id => id.toLowerCase() === extension.identifier.id.toLowerCase())) {
extension = { ...extension, isBuiltin: true };
}
result.set(extension.identifier.id, extension);
}
});
@@ -563,6 +578,8 @@ type NlsConfiguration = {
class ExtensionsScanner extends Disposable {
private readonly extensionsEnabledWithApiProposalVersion: string[];
private readonly productQuality: string | undefined;
private readonly autoUpdateBuiltinExtensions: ReadonlySet<string>;
constructor(
@IExtensionsProfileScannerService protected readonly extensionsProfileScannerService: IExtensionsProfileScannerService,
@@ -574,6 +591,8 @@ class ExtensionsScanner extends Disposable {
) {
super();
this.extensionsEnabledWithApiProposalVersion = productService.extensionsEnabledWithApiProposalVersion?.map(id => id.toLowerCase()) ?? [];
this.productQuality = productService.quality;
this.autoUpdateBuiltinExtensions = new Set(productService.autoUpdateBuiltinExtensions?.map(id => id.toLowerCase()) ?? []);
}
async scanExtensions(input: ExtensionScannerInput): Promise<IRelaxedScannedExtension[]> {
@@ -712,6 +731,7 @@ class ExtensionsScanner extends Disposable {
isValid,
validations,
preRelease: !!metadata?.preRelease,
autoUpdate: type === ExtensionType.System && this.autoUpdateBuiltinExtensions.has(id.toLowerCase()) && this.productQuality === 'stable',
};
if (input.validate) {
extension = this.validate(extension, input);

View File

@@ -549,6 +549,8 @@ export class ExtensionsScanner extends Disposable {
@IExtensionsProfileScannerService private readonly extensionsProfileScannerService: IExtensionsProfileScannerService,
@IUriIdentityService private readonly uriIdentityService: IUriIdentityService,
@ITelemetryService private readonly telemetryService: ITelemetryService,
@IProductService private readonly productService: IProductService,
@IUserDataProfilesService private readonly userDataProfilesService: IUserDataProfilesService,
@ILogService private readonly logService: ILogService,
) {
super();
@@ -558,6 +560,7 @@ export class ExtensionsScanner extends Disposable {
async cleanUp(): Promise<void> {
await this.removeTemporarilyDeletedFolders();
await this.removeStaleAutoUpdateBuiltinExtensions();
await this.deleteExtensionsMarkedForRemoval();
//TODO: Remove this initiialization after coupe of releases
await this.initializeExtensionSize();
@@ -914,6 +917,7 @@ export class ExtensionsScanner extends Disposable {
installedTimestamp: extension.metadata?.installedTimestamp,
updated: !!extension.metadata?.updated,
pinned: !!extension.metadata?.pinned,
autoUpdate: extension.autoUpdate,
private: !!extension.metadata?.private,
isWorkspaceScoped: false,
source: extension.metadata?.source ?? (extension.identifier.uuid ? 'gallery' : 'vsix'),
@@ -932,6 +936,28 @@ export class ExtensionsScanner extends Disposable {
}));
}
private async removeStaleAutoUpdateBuiltinExtensions(): Promise<void> {
const autoUpdateBuiltinExtensions = this.productService.autoUpdateBuiltinExtensions;
if (!autoUpdateBuiltinExtensions?.length) {
return;
}
const productVersion = this.productService.version;
const productMajorMinor = `${semver.major(productVersion)}.${semver.minor(productVersion)}`;
const extensions = await this.extensionsScannerService.scanAllUserExtensions();
const staleExtensions = extensions.filter(extension => {
if (!autoUpdateBuiltinExtensions.some(id => id.toLowerCase() === extension.identifier.id.toLowerCase())) {
return false;
}
const extensionMajorMinor = `${semver.major(extension.manifest.version)}.${semver.minor(extension.manifest.version)}`;
return productMajorMinor !== extensionMajorMinor;
});
if (staleExtensions.length) {
this.logService.info('Removing stale auto-update builtin extensions:', staleExtensions.map(e => `${e.identifier.id}@${e.manifest.version}`).join(', '));
await this.extensionsProfileScannerService.removeExtensionsFromProfile(staleExtensions.map(e => e.identifier), this.userDataProfilesService.defaultProfile.extensionsResource);
await Promise.allSettled(staleExtensions.map(e => this.deleteExtension(e, 'stale auto-update builtin')));
}
}
private async deleteExtensionsMarkedForRemoval(): Promise<void> {
let removed: IStringDictionary<boolean>;
try {

View File

@@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { newWriteableBufferStream } from '../../../../base/common/buffer.js';
import { joinPath } from '../../../../base/common/resources.js';
import { URI } from '../../../../base/common/uri.js';
import { isUUID } from '../../../../base/common/uuid.js';
@@ -11,16 +12,20 @@ import { mock } from '../../../../base/test/common/mock.js';
import { IConfigurationService } from '../../../configuration/common/configuration.js';
import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js';
import { IEnvironmentService } from '../../../environment/common/environment.js';
import { IRawGalleryExtensionVersion, sortExtensionVersions, filterLatestExtensionVersionsForTargetPlatform } from '../../common/extensionGalleryService.js';
import { IAllowedExtensionsService, IGalleryExtension } from '../../common/extensionManagement.js';
import { IExtensionGalleryManifestService } from '../../common/extensionGalleryManifest.js';
import { AbstractExtensionGalleryService, ExtensionGalleryServiceWithNoStorageService, IRawGalleryExtensionVersion, sortExtensionVersions, filterLatestExtensionVersionsForTargetPlatform } from '../../common/extensionGalleryService.js';
import { IFileService } from '../../../files/common/files.js';
import { FileService } from '../../../files/common/fileService.js';
import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js';
import { NullLogService } from '../../../log/common/log.js';
import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js';
import { ILogService, NullLogService } from '../../../log/common/log.js';
import product from '../../../product/common/product.js';
import { IProductService } from '../../../product/common/productService.js';
import { IRequestService } from '../../../request/common/request.js';
import { resolveMarketplaceHeaders } from '../../../externalServices/common/marketplace.js';
import { InMemoryStorageService, IStorageService } from '../../../storage/common/storage.js';
import { TelemetryConfiguration, TELEMETRY_SETTING_ID } from '../../../telemetry/common/telemetry.js';
import { ITelemetryService, TelemetryConfiguration, TELEMETRY_SETTING_ID } from '../../../telemetry/common/telemetry.js';
import { TargetPlatform } from '../../../extensions/common/extensions.js';
import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
@@ -459,3 +464,103 @@ suite('Extension Gallery Service', () => {
});
});
suite('Extension Gallery Service - Auto Update Builtin Extensions', () => {
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
function createGalleryService(autoUpdateBuiltinExtensions: string[], productVersion: string = '1.66.0'): AbstractExtensionGalleryService {
const instantiationService = disposables.add(new TestInstantiationService());
const logService = new NullLogService();
const fileService = disposables.add(new FileService(logService));
const fileSystemProvider = disposables.add(new InMemoryFileSystemProvider());
disposables.add(fileService.registerProvider('vscode-tests', fileSystemProvider));
instantiationService.stub(ILogService, logService);
instantiationService.stub(IFileService, fileService);
instantiationService.stub(IRequestService, { async request() { return { res: { statusCode: 200 }, stream: newWriteableBufferStream() }; } });
instantiationService.stub(IEnvironmentService, new EnvironmentServiceMock(joinPath(URI.file('tests').with({ scheme: 'vscode-tests' }), 'machineid')));
instantiationService.stub(ITelemetryService, NullTelemetryService);
instantiationService.stub(IConfigurationService, new TestConfigurationService());
instantiationService.stub(IAllowedExtensionsService, { isAllowed: () => true });
instantiationService.stub(IExtensionGalleryManifestService, { extensionGalleryManifest: undefined, extensionGalleryManifestStatus: 0 });
instantiationService.stub(IProductService, {
_serviceBrand: undefined,
version: productVersion,
autoUpdateBuiltinExtensions,
});
return disposables.add(instantiationService.createInstance(ExtensionGalleryServiceWithNoStorageService));
}
function aGalleryExtension(id: string, version: string): IGalleryExtension {
const [publisher, name] = id.split('.');
return {
identifier: { id, uuid: id },
name,
version,
publisher,
publisherDisplayName: publisher,
allTargetPlatforms: [TargetPlatform.UNDEFINED],
properties: {
isPreReleaseVersion: false,
targetPlatform: TargetPlatform.UNDEFINED,
engine: undefined,
enabledApiProposals: undefined,
},
assets: { manifest: null },
} as unknown as IGalleryExtension;
}
test('extension with matching major.minor is compatible', async () => {
const galleryService = createGalleryService(['pub.name'], '1.66.0');
const extension = aGalleryExtension('pub.name', '1.66.1');
const result = await galleryService.isExtensionCompatible(extension, false, TargetPlatform.UNDEFINED, { version: '1.66.0' });
assert.strictEqual(result, true);
});
test('extension with mismatched major.minor is not compatible', async () => {
const galleryService = createGalleryService(['pub.name'], '1.66.0');
const extension = aGalleryExtension('pub.name', '1.67.0');
const result = await galleryService.isExtensionCompatible(extension, false, TargetPlatform.UNDEFINED, { version: '1.66.0' });
assert.strictEqual(result, false);
});
test('extension not in autoUpdateBuiltinExtensions is not version-restricted', async () => {
const galleryService = createGalleryService(['pub.other'], '1.66.0');
const extension = aGalleryExtension('pub.name', '1.67.0');
const result = await galleryService.isExtensionCompatible(extension, false, TargetPlatform.UNDEFINED, { version: '1.66.0' });
assert.strictEqual(result, true);
});
test('extension with same major but different minor is not compatible', async () => {
const galleryService = createGalleryService(['pub.name'], '1.66.0');
const extension = aGalleryExtension('pub.name', '1.65.5');
const result = await galleryService.isExtensionCompatible(extension, false, TargetPlatform.UNDEFINED, { version: '1.66.0' });
assert.strictEqual(result, false);
});
test('version check is case insensitive for extension id', async () => {
const galleryService = createGalleryService(['Pub.Name'], '1.66.0');
const extension = aGalleryExtension('pub.name', '1.67.0');
const result = await galleryService.isExtensionCompatible(extension, false, TargetPlatform.UNDEFINED, { version: '1.66.0' });
assert.strictEqual(result, false);
});
test('no version restriction when autoUpdateBuiltinExtensions is empty', async () => {
const galleryService = createGalleryService([], '1.66.0');
const extension = aGalleryExtension('pub.name', '1.67.0');
const result = await galleryService.isExtensionCompatible(extension, false, TargetPlatform.UNDEFINED, { version: '1.66.0' });
assert.strictEqual(result, true);
});
});

View File

@@ -340,6 +340,114 @@ suite('NativeExtensionsScanerService Test', () => {
function anExtensionManifest(manifest: Partial<IScannedExtensionManifest>): Partial<IExtensionManifest> {
return { engines: { vscode: '^1.66.0' }, version: '1.0.0', main: 'main.js', activationEvents: ['*'], ...manifest };
}
suite('auto update builtin extensions', () => {
test('scan user extension with matching product version is included', async () => {
instantiationService.stub(IProductService, { version: '1.66.0', quality: 'stable', autoUpdateBuiltinExtensions: ['pub.name'] });
await aUserExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub', version: '1.66.1' }));
const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService));
const actual = await testObject.scanAllUserExtensions({ includeAllVersions: false, includeInvalid: false });
assert.deepStrictEqual(actual.length, 1);
assert.deepStrictEqual(actual[0].manifest.version, '1.66.1');
});
test('scan user extension with mismatched product version is excluded', async () => {
instantiationService.stub(IProductService, { version: '1.66.0', quality: 'stable', autoUpdateBuiltinExtensions: ['pub.name'] });
await aUserExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub', version: '1.67.0' }));
const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService));
const actual = await testObject.scanAllUserExtensions({ includeAllVersions: false, includeInvalid: false });
assert.deepStrictEqual(actual.length, 0);
});
test('scan user extension not in autoUpdateBuiltinExtensions is not filtered', async () => {
instantiationService.stub(IProductService, { version: '1.66.0', quality: 'stable', autoUpdateBuiltinExtensions: ['pub.other'] });
await aUserExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub', version: '1.67.0' }));
const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService));
const actual = await testObject.scanAllUserExtensions({ includeAllVersions: false, includeInvalid: false });
assert.deepStrictEqual(actual.length, 1);
});
test('scan picks matching version when multiple versions exist', async () => {
instantiationService.stub(IProductService, { version: '1.66.0', quality: 'stable', autoUpdateBuiltinExtensions: ['pub.name'] });
await aUserExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub', version: '1.66.1' }));
await aUserExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub', version: '1.67.0' }));
const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService));
const actual = await testObject.scanAllUserExtensions({ includeAllVersions: false, includeInvalid: false });
assert.deepStrictEqual(actual.length, 1);
assert.deepStrictEqual(actual[0].manifest.version, '1.66.1');
});
test('scan all extensions prefers matching user extension over system extension', async () => {
instantiationService.stub(IProductService, { version: '1.66.0', quality: 'stable', autoUpdateBuiltinExtensions: ['pub.name'] });
await aSystemExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub', version: '1.66.0' }));
await aUserExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub', version: '1.66.1' }));
const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService));
const actual = await testObject.scanAllExtensions({}, { profileLocation: instantiationService.get(IUserDataProfilesService).defaultProfile.extensionsResource, includeInvalid: false });
const extension = actual.find(e => e.identifier.id === 'pub.name');
assert.ok(extension);
assert.deepStrictEqual(extension.manifest.version, '1.66.1');
assert.deepStrictEqual(extension.isBuiltin, true);
});
test('scan all extensions falls back to system extension when user extension has mismatched version', async () => {
instantiationService.stub(IProductService, { version: '1.66.0', quality: 'stable', autoUpdateBuiltinExtensions: ['pub.name'] });
await aSystemExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub', version: '1.66.0' }));
await aUserExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub', version: '1.67.0' }));
const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService));
const actual = await testObject.scanAllExtensions({}, { profileLocation: instantiationService.get(IUserDataProfilesService).defaultProfile.extensionsResource, includeInvalid: false });
const extension = actual.find(e => e.identifier.id === 'pub.name');
assert.ok(extension);
assert.deepStrictEqual(extension.manifest.version, '1.66.0');
assert.deepStrictEqual(extension.type, ExtensionType.System);
});
test('system extension has autoUpdate set to true when in autoUpdateBuiltinExtensions and quality is stable', async () => {
instantiationService.stub(IProductService, { version: '1.66.0', quality: 'stable', autoUpdateBuiltinExtensions: ['pub.name'] });
await aSystemExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub' }));
const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService));
const actual = await testObject.scanSystemExtensions({});
assert.deepStrictEqual(actual.length, 1);
assert.deepStrictEqual(actual[0].autoUpdate, true);
});
test('system extension has autoUpdate set to false when not in autoUpdateBuiltinExtensions', async () => {
instantiationService.stub(IProductService, { version: '1.66.0', quality: 'stable', autoUpdateBuiltinExtensions: ['pub.other'] });
await aSystemExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub' }));
const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService));
const actual = await testObject.scanSystemExtensions({});
assert.deepStrictEqual(actual.length, 1);
assert.deepStrictEqual(actual[0].autoUpdate, false);
});
test('system extension has autoUpdate set to false when quality is not stable', async () => {
instantiationService.stub(IProductService, { version: '1.66.0', quality: 'insider', autoUpdateBuiltinExtensions: ['pub.name'] });
await aSystemExtension(anExtensionManifest({ 'name': 'name', 'publisher': 'pub' }));
const testObject: IExtensionsScannerService = disposables.add(instantiationService.createInstance(ExtensionsScannerService));
const actual = await testObject.scanSystemExtensions({});
assert.deepStrictEqual(actual.length, 1);
assert.deepStrictEqual(actual[0].autoUpdate, false);
});
});
});
suite('ExtensionScannerInput', () => {

View File

@@ -1959,8 +1959,8 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension
// Skip if check updates only for builtin extensions and current extension is not builtin.
continue;
}
if (installed.isBuiltin && !installed.local?.pinned && (installed.type === ExtensionType.System || !installed.local?.identifier.uuid)) {
// Skip checking updates for a builtin extension if it is a system extension or if it does not has Marketplace identifier
if (!installed.local?.autoUpdate && installed.isBuiltin && !installed.local?.pinned && (installed.type === ExtensionType.System || !installed.local?.identifier.uuid)) {
// Skip checking updates for a builtin extension if it is a system extension or if it does not have a Marketplace identifier
continue;
}
if (installed.local?.source === 'resource') {
@@ -2235,6 +2235,11 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension
return false;
}
if (extension.local?.autoUpdate) {
// Extensions marked for auto-update are always auto-updated
return true;
}
const autoUpdateValue = this.getAutoUpdateValue();
if (autoUpdateValue === false) {

View File

@@ -1412,6 +1412,7 @@ class WorkspaceExtensionsManagementService extends Disposable {
installedTimestamp: extension.metadata?.installedTimestamp,
updated: !!extension.metadata?.updated,
pinned: !!extension.metadata?.pinned,
autoUpdate: false,
isWorkspaceScoped: true,
private: false,
source: 'resource',

View File

@@ -261,6 +261,7 @@ function toLocalExtension(extension: IExtension): ILocalExtension {
targetPlatform: TargetPlatform.WEB,
updated: !!metadata.updated,
pinned: !!metadata?.pinned,
autoUpdate: false,
private: !!metadata.private,
isWorkspaceScoped: false,
source: metadata?.source ?? (extension.identifier.uuid ? 'gallery' : 'resource'),