diff --git a/AGENTS.md b/AGENTS.md index ba4a0e22cb7..08eb6ccc830 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,6 +160,8 @@ Some Codex patches modify files that earlier patches also touch. When this happe |-------|-----------| | `feat-cli-pinning.patch` | `binary-name.patch` (both modify `nativeHostMainService.ts`) | | `feat-codex-sideloader.patch` | `feat-codex-conductor.patch` (both add imports to `workbench.common.main.ts`) | +| `fix-conductor-extension-read.patch` | `disable-signature-verification.patch` (both modify `extensionManagementService.ts`; the prerequisite shifts the file by -1 line, so the conductor patch must be generated against the post-prerequisite tree) | +| `fix-extension-scope-inheritance.patch` | `disable-signature-verification.patch`, `fix-conductor-extension-read.patch` (all three modify `extensionManagementService.ts`; regenerate with both prerequisites listed first) | If a patch fails to apply with "patch does not apply", check whether a prerequisite patch changed the same file. Regenerate using `dev/patch.sh` with the prerequisite listed first. diff --git a/patches/fix-conductor-extension-read.patch b/patches/fix-conductor-extension-read.patch new file mode 100644 index 00000000000..50a05c70def --- /dev/null +++ b/patches/fix-conductor-extension-read.patch @@ -0,0 +1,13 @@ +diff --git a/src/vs/platform/extensionManagement/node/extensionManagementService.ts b/src/vs/platform/extensionManagement/node/extensionManagementService.ts +index cdf0c67..73897eb 100644 +--- a/src/vs/platform/extensionManagement/node/extensionManagementService.ts ++++ b/src/vs/platform/extensionManagement/node/extensionManagementService.ts +@@ -864,7 +863,7 @@ export class ExtensionsScanner extends Disposable { + async scanLocalExtension(location: URI, type: ExtensionType, profileLocation?: URI): Promise { + try { + if (profileLocation) { +- const scannedExtensions = await this.extensionsScannerService.scanUserExtensions({ profileLocation }); ++ const scannedExtensions = await this.extensionsScannerService.scanUserExtensions({ profileLocation, includeInvalid: true }); + const scannedExtension = scannedExtensions.find(e => this.uriIdentityService.extUri.isEqual(e.location, location)); + if (scannedExtension) { + return await this.toLocalExtension(scannedExtension); diff --git a/patches/fix-extension-scope-inheritance.patch b/patches/fix-extension-scope-inheritance.patch new file mode 100644 index 00000000000..dc8c1eb8783 --- /dev/null +++ b/patches/fix-extension-scope-inheritance.patch @@ -0,0 +1,9 @@ +diff --git a/src/vs/platform/extensionManagement/node/extensionManagementService.ts b/src/vs/platform/extensionManagement/node/extensionManagementService.ts +index 84bbc73..322a6bb 100644 +--- a/src/vs/platform/extensionManagement/node/extensionManagementService.ts ++++ b/src/vs/platform/extensionManagement/node/extensionManagementService.ts +@@ -1046,3 +1046,3 @@ class InstallExtensionInProfileTask extends AbstractExtensionTask; -const CODEX_EDITOR_EXTENSION_ID = 'project-accelerate.codex-editor-extension'; const CIRCUIT_BREAKER_KEY = 'codex.conductor.enforcementAttempts'; const CIRCUIT_BREAKER_MAX = 3; const CIRCUIT_BREAKER_WINDOW_MS = 30_000; const CONDUCTOR_PROFILE_ICON = 'repo-pinned'; -const FRONTIER_EXTENSION_ID = 'frontier-rnd.frontier-authentication'; const PROFILE_ASSOCIATIONS_KEY = 'codex.conductor.profileAssociations'; const LAST_CLEANUP_KEY = 'codex.conductor.lastCleanup'; const CLEANUP_INTERVAL_MS = 14 * 24 * 60 * 60 * 1000; // 14 days @@ -66,6 +66,7 @@ export class CodexConductorContribution extends Disposable implements IWorkbench @IUserDataProfileService private readonly userDataProfileService: IUserDataProfileService, @IUserDataProfilesService private readonly userDataProfilesService: IUserDataProfilesService, @IWorkbenchExtensionManagementService private readonly extensionManagementService: IWorkbenchExtensionManagementService, + @IExtensionGalleryService private readonly extensionGalleryService: IExtensionGalleryService, @IStorageService private readonly storageService: IStorageService, @INotificationService private readonly notificationService: INotificationService, @IHostService private readonly hostService: IHostService, @@ -125,6 +126,14 @@ export class CodexConductorContribution extends Disposable implements IWorkbench this.initialize(); } + private getCoreExtensionEntries(): SideloadEntry[] { + const configured = (this.productService as unknown as Record)['codexSideloadExtensions']; + if (!Array.isArray(configured)) { + return []; + } + return parseSideloadEntries(configured); + } + private async initialize(): Promise { if (this.workspaceContextService.getWorkbenchState() !== WorkbenchState.FOLDER) { this.metadataUri = undefined; @@ -259,6 +268,7 @@ export class CodexConductorContribution extends Disposable implements IWorkbench try { await this.installPinnedExtensions(pins, profile); + await this.backfillCoreExtensions(profile, new Set(Object.keys(pins).map(id => id.toLowerCase()))); } catch (e: unknown) { // Installation failed after all retries — cleanup the incomplete profile // (only if it's not the current profile, which cannot be deleted). @@ -275,9 +285,12 @@ export class CodexConductorContribution extends Disposable implements IWorkbench handle.close(); + // forceReload: we just installed/backfilled into `profile`. If the + // user happens to already be on it, the default skip-when-IDs-match + // path would leave the new extensions inactive until manual reload. if (reloadWhenReady) { // User already opted in — reload immediately - await this.switchProfileAndReload(profile); + await this.switchProfileAndReload(profile, true); } else { // Show completion notification with reload button this.notificationService.prompt( @@ -285,7 +298,7 @@ export class CodexConductorContribution extends Disposable implements IWorkbench 'Pinned extension installed. Reload to apply.', [{ label: 'Reload Codex', - run: () => this.switchProfileAndReload(profile) + run: () => this.switchProfileAndReload(profile, true) }] ); } @@ -310,15 +323,35 @@ export class CodexConductorContribution extends Disposable implements IWorkbench // networking which handles redirects without CORS restrictions. const channel = this.sharedProcessService.getChannel('extensions'); + // Inspect current state of the profile to avoid redundant/failing reinstalls + const installed = await this.extensionManagementService.getInstalled(undefined, profile.extensionsResource); + for (const [id, pin] of Object.entries(pins)) { + const existing = installed.find(e => e.identifier.id.toLowerCase() === id.toLowerCase()); + if (existing && existing.manifest.version === pin.version) { + this.logService.info(`[CodexConductor] Pinned extension "${id}" v${pin.version} already installed in profile "${profile.name}" — skipping VSIX download`); + continue; + } + let lastError: Error | undefined; for (let attempt = 1; attempt <= 3; attempt++) { try { this.logService.info(`[CodexConductor] Installing pinned VSIX for "${id}" v${pin.version} from ${pin.url} (attempt ${attempt}/3)`); + // `isApplicationScoped: false` is explicit (not omitted) + // because the upstream install task inherits this flag from + // any existing installation of the same extension — see the + // patched line at extensionManagementService.ts:1047 (`??` + // semantics). If a sideloaded copy in the Default profile + // were marked app-scoped, the conductor's pin would inherit + // that flag, get filtered out of its own profile by the + // scanner, and post-install verification would fail with + // "Cannot read the extension from …". Forcing `false` keeps + // the pin scoped to its conductor profile. await channel.call('install', [URI.parse(pin.url), { installGivenVersion: true, pinned: true, + isApplicationScoped: false, profileLocation: profile.extensionsResource }]); lastError = undefined; @@ -364,14 +397,18 @@ export class CodexConductorContribution extends Disposable implements IWorkbench private async logStartupExtensionState(): Promise { const installed = await this.extensionManagementService.getInstalled(); - const codexEditorVersion = installed.find(e => e.identifier.id.toLowerCase() === CODEX_EDITOR_EXTENSION_ID)?.manifest.version ?? 'not installed'; - const frontierAuthVersion = installed.find(e => e.identifier.id.toLowerCase() === FRONTIER_EXTENSION_ID)?.manifest.version ?? 'not installed'; + const coreVersions = this.getCoreExtensionEntries().map(entry => { + const id = typeof entry === 'string' ? entry : entry.id; + const version = installed.find(e => e.identifier.id.toLowerCase() === id.toLowerCase())?.manifest.version ?? 'not installed'; + return `${id}=${version}`; + }).join(', '); + const currentProfileName = this.userDataProfileService.currentProfile.name; const requiredExtensions = await this.readRequiredExtensionsFromMetadata(); const pinnedExtensions = await this.readEffectivePinnedExtensions(); this.logService.info( - `[CodexConductor] Startup extension state — profile=${currentProfileName}, ${CODEX_EDITOR_EXTENSION_ID}=${codexEditorVersion}, ${FRONTIER_EXTENSION_ID}=${frontierAuthVersion}, pinnedExtensions=${this.formatObjectForLog(pinnedExtensions)}, requiredExtensions=${this.formatObjectForLog(requiredExtensions)}` + `[CodexConductor] Startup extension state — profile=${currentProfileName}, ${coreVersions}, pinnedExtensions=${this.formatObjectForLog(pinnedExtensions)}, requiredExtensions=${this.formatObjectForLog(requiredExtensions)}` ); } @@ -496,6 +533,21 @@ export class CodexConductorContribution extends Disposable implements IWorkbench } } + // Core suite must be present even if unpinned + const pinnedLower = new Set(Object.keys(pins).map(id => id.toLowerCase())); + for (const entry of this.getCoreExtensionEntries()) { + const id = typeof entry === 'string' ? entry : entry.id; + if (pinnedLower.has(id.toLowerCase())) { + continue; + } + const installedExt = installed.find(e => e.identifier.id.toLowerCase() === id.toLowerCase()); + if (!installedExt) { + mismatches.push(`${id}: core extension missing`); + } else if (typeof entry !== 'string' && installedExt.manifest.version !== entry.version) { + mismatches.push(`${id}: core extension version mismatch (expected ${entry.version}, found ${installedExt.manifest.version})`); + } + } + if (mismatches.length === 0) { return; } @@ -539,9 +591,18 @@ export class CodexConductorContribution extends Disposable implements IWorkbench const profile = existingProfile ?? await this.userDataProfilesService.createNamedProfile(targetProfileName, { icon: CONDUCTOR_PROFILE_ICON }); + const progressHandle = this.notificationService.notify({ + severity: Severity.Info, + message: 'Installing pinned extension…', + progress: { infinite: true } + }); + try { await this.installPinnedExtensions(pins, profile); + await this.backfillCoreExtensions(profile, new Set(Object.keys(pins).map(id => id.toLowerCase()))); } catch (e: unknown) { + progressHandle.close(); + // Installation failed after all retries — cleanup the incomplete profile // (only if it's not the current profile, which cannot be deleted). if (profile.id !== this.userDataProfileService.currentProfile.id) { @@ -567,7 +628,14 @@ export class CodexConductorContribution extends Disposable implements IWorkbench return; } - await this.switchProfileAndReload(profile); + progressHandle.close(); + + // forceReload: we installed/backfilled extensions into `profile`. If + // `profile` is the current profile (repair path — existing profile with + // missing extensions), the default "skip-when-IDs-match" reload behaviour + // would leave the new extensions on disk but unactivated until the next + // manual reload. + await this.switchProfileAndReload(profile, true); } private async revertIfPatchBuild(): Promise { @@ -801,6 +869,23 @@ export class CodexConductorContribution extends Disposable implements IWorkbench return false; } } + + // Core suite must be present even if unpinned + const pinnedLower = new Set(Object.keys(pins).map(id => id.toLowerCase())); + for (const entry of this.getCoreExtensionEntries()) { + const id = typeof entry === 'string' ? entry : entry.id; + if (pinnedLower.has(id.toLowerCase())) { + continue; + } + const installedExt = installed.find(e => e.identifier.id.toLowerCase() === id.toLowerCase()); + if (!installedExt) { + return false; + } + if (typeof entry !== 'string' && installedExt.manifest.version !== entry.version) { + return false; + } + } + return true; } catch { return false; @@ -915,14 +1000,20 @@ export class CodexConductorContribution extends Disposable implements IWorkbench * to make the switch effective. If the extension host restart is vetoed (e.g. * a custom editor like Startup Flow is open), switchProfile() throws * CancellationError and reverts the association — reload handles that too. + * + * Pass `forceReload = true` when extensions were installed/updated in the + * target profile during this call, even if the user is already on it. Without + * a reload, freshly installed extensions whose activation events have already + * passed (e.g. `onStartupFinished`) won't activate until the next manual + * reload, leaving the profile silently degraded. */ - private async switchProfileAndReload(profile: IUserDataProfile): Promise { + private async switchProfileAndReload(profile: IUserDataProfile, forceReload = false): Promise { const workspace = this.workspaceContextService.getWorkspace(); const workspaceIdentifier = toWorkspaceIdentifier(workspace); const originalProfileId = this.userDataProfileService.currentProfile.id; const currentProfileName = this.userDataProfileService.currentProfile.name; - this.logService.info(`[CodexConductor] switchProfileAndReload: current=${currentProfileName}, target=${profile.name}`); + this.logService.info(`[CodexConductor] switchProfileAndReload: current=${currentProfileName}, target=${profile.name}, forceReload=${forceReload}`); // Ensure the target conductor profile has update-checks disabled before // the reload commits. Harmless (no-op) for the default profile. @@ -946,8 +1037,11 @@ export class CodexConductorContribution extends Disposable implements IWorkbench // setProfileForWorkspace may internally trigger changeCurrentProfile which // updates currentProfile even if the extension host vetos the switch. Using // the post-call currentProfile.id would incorrectly skip the reload. - if (originalProfileId !== profile.id) { - this.logService.info(`[CodexConductor] Profile mismatch (${currentProfileName} != ${profile.name}) — triggering authoritative reload`); + if (forceReload || originalProfileId !== profile.id) { + const reason = originalProfileId !== profile.id + ? `profile mismatch (${currentProfileName} != ${profile.name})` + : `extensions installed in current profile`; + this.logService.info(`[CodexConductor] ${reason} — triggering authoritative reload`); this.hostService.reload({ forceProfile: profile.name }); } else { this.logService.info(`[CodexConductor] Already on target profile ${profile.name} — no reload needed`); @@ -960,4 +1054,99 @@ export class CodexConductorContribution extends Disposable implements IWorkbench await this.switchProfileAndReload(profile); } } + + /** + * Installs core (sideload) extensions into a conductor-managed profile. + * + * Fail-fast by design: the sideloader's best-effort behaviour (warn + skip on + * any failure) is wrong here because the resulting profile would be silently + * incomplete, and the user couldn't sync, edit, or otherwise function. By + * throwing, we let the caller's catch block remove the half-built profile; + * the next window open will re-attempt enforcement, and the integrity check + * in `validateProfileExtensions` will detect any residual incompleteness. + */ + private async backfillCoreExtensions(profile: IUserDataProfile, pinnedIds: Set): Promise { + const installed = await this.extensionManagementService.getInstalled(undefined, profile.extensionsResource); + const coreEntries = this.getCoreExtensionEntries(); + + const missingGallery: string[] = []; + const missingVsix: SideloadVsixEntry[] = []; + + for (const entry of coreEntries) { + const id = typeof entry === 'string' ? entry : entry.id; + if (pinnedIds.has(id.toLowerCase())) { + continue; + } + + const installedExt = installed.find(e => e.identifier.id.toLowerCase() === id.toLowerCase()); + if (typeof entry === 'string') { + if (!installedExt) { + missingGallery.push(entry); + } + } else { + if (!installedExt || installedExt.manifest.version !== entry.version) { + missingVsix.push(entry); + } + } + } + + if (missingGallery.length === 0 && missingVsix.length === 0) { + return; + } + + this.logService.info(`[CodexConductor] Backfilling core extension(s) into profile "${profile.name}": ${missingGallery.length} from gallery, ${missingVsix.length} from VSIX`); + + if (missingGallery.length > 0) { + if (!this.extensionGalleryService.isEnabled()) { + throw new Error('Extension gallery is disabled — cannot backfill core extensions from gallery'); + } + + const galleryExtensions = await this.extensionGalleryService.getExtensions( + missingGallery.map(id => ({ id })), + CancellationToken.None + ); + + for (const id of missingGallery) { + const galleryExt = galleryExtensions.find(e => e.identifier.id.toLowerCase() === id.toLowerCase()); + if (!galleryExt) { + throw new Error(`Core extension "${id}" not found in gallery`); + } + + try { + // See installPinnedExtensions for why + // `isApplicationScoped: false` is explicit. + await this.extensionManagementService.installFromGallery(galleryExt, { + profileLocation: profile.extensionsResource, + isApplicationScoped: false, + isMachineScoped: true + }); + this.logService.info(`[CodexConductor] Backfilled "${galleryExt.identifier.id}" v${galleryExt.version} from gallery`); + } catch (err) { + this.logService.error(`[CodexConductor] Failed to backfill "${galleryExt.identifier.id}" from gallery`, err); + throw err; + } + } + } + + if (missingVsix.length > 0) { + const channel = this.sharedProcessService.getChannel('extensions'); + for (const entry of missingVsix) { + try { + // See installPinnedExtensions for why + // `isApplicationScoped: false` is explicit. + await channel.call('install', [URI.parse(entry.vsix), { + installGivenVersion: true, + pinned: true, + isApplicationScoped: false, + isMachineScoped: true, + profileLocation: profile.extensionsResource, + }]); + this.logService.info(`[CodexConductor] Backfilled "${entry.id}" v${entry.version} from VSIX ${entry.vsix}`); + } catch (err) { + this.logService.error(`[CodexConductor] Failed to backfill "${entry.id}" from VSIX ${entry.vsix}`, err); + throw err; + } + } + } + } } diff --git a/src/stable/src/vs/workbench/contrib/codexConductor/browser/codexTypes.ts b/src/stable/src/vs/workbench/contrib/codexConductor/browser/codexTypes.ts index db995306a3c..b8fe20dfac0 100644 --- a/src/stable/src/vs/workbench/contrib/codexConductor/browser/codexTypes.ts +++ b/src/stable/src/vs/workbench/contrib/codexConductor/browser/codexTypes.ts @@ -43,3 +43,29 @@ export function parsePinnedExtensions(value: unknown): PinnedExtensions | undefi } return Object.keys(result).length > 0 ? result : undefined; } + +/** A string means "install from gallery by ID". An object with `vsix` means "install directly from URL". */ +export interface SideloadVsixEntry { + id: string; + vsix: string; + version: string; +} + +export type SideloadEntry = string | SideloadVsixEntry; + +export function parseSideloadEntries(raw: unknown[]): SideloadEntry[] { + const entries: SideloadEntry[] = []; + for (const item of raw) { + if (typeof item === 'string') { + entries.push(item); + } else if ( + item && typeof item === 'object' && + typeof (item as Record).id === 'string' && + typeof (item as Record).vsix === 'string' && + typeof (item as Record).version === 'string' + ) { + entries.push(item as SideloadVsixEntry); + } + } + return entries; +} diff --git a/src/stable/src/vs/workbench/contrib/codexSideloader/browser/codexSideloader.ts b/src/stable/src/vs/workbench/contrib/codexSideloader/browser/codexSideloader.ts index ce293f9faf1..b028691b2c3 100644 --- a/src/stable/src/vs/workbench/contrib/codexSideloader/browser/codexSideloader.ts +++ b/src/stable/src/vs/workbench/contrib/codexSideloader/browser/codexSideloader.ts @@ -16,35 +16,54 @@ import { IUserDataProfilesService } from '../../../../platform/userDataProfile/c import { CancellationToken } from '../../../../base/common/cancellation.js'; import { ExtensionType } from '../../../../platform/extensions/common/extensions.js'; import { URI } from '../../../../base/common/uri.js'; +import { SideloadEntry, SideloadVsixEntry, parseSideloadEntries } from '../../codexConductor/browser/codexTypes.js'; const TAG = '[CodexSideloader]'; -/** A string means "install from gallery by ID". An object with `vsix` means "install directly from URL". */ -interface SideloadVsixEntry { - id: string; - vsix: string; - version: string; -} - -type SideloadEntry = string | SideloadVsixEntry; - -function parseSideloadEntries(raw: unknown[]): SideloadEntry[] { - const entries: SideloadEntry[] = []; - for (const item of raw) { - if (typeof item === 'string') { - entries.push(item); - } else if ( - item && typeof item === 'object' && - typeof (item as Record).id === 'string' && - typeof (item as Record).vsix === 'string' && - typeof (item as Record).version === 'string' - ) { - entries.push(item as SideloadVsixEntry); - } - } - return entries; -} - +/** + * Why sideloaded extensions are NOT marked `isApplicationScoped` + * -------------------------------------------------------------- + * An earlier iteration of this contribution (commit d9f6ee6, "feat: ensure + * sideloaded extensions are application and machine scoped") set + * `isApplicationScoped: true` on every sideloaded install. The motivation was + * to make these extensions visible inside conductor-managed profiles — + * VS Code's profile-extension scanner (`scanExtensionsFromProfile`) bridges + * application-scoped entries from the Default profile into every other profile + * automatically. + * + * That bridge has two problems for our use case: + * + * 1. When the Conductor later installs a *pinned* VSIX of the same extension + * into a conductor profile, the install task reads the existing app-scoped + * entry from the Default profile and inherits its `isApplicationScoped` + * flag (extensionManagementService.ts line 1047, `||` semantics). The new + * pinned install ends up app-scoped too, gets filtered out of the + * conductor profile by the scanner, and post-install verification throws + * "Cannot read the extension from …". + * + * 2. Even if the install succeeded, `dedupExtensions` prefers app-scoped over + * non-app-scoped, so the Default-profile sideloaded version would shadow + * the conductor's pin at runtime — the wrong version would actually run. + * + * The Conductor's `backfillCoreExtensions` (added in 5fd88fa) makes the bridge + * unnecessary: every conductor profile now gets its *own* copies of the + * sideloaded extensions, with full per-profile metadata. So we leave + * `isApplicationScoped` unset (false) on sideloaded installs and rely on + * backfill for cross-profile visibility. + * + * `isMachineScoped: true` is unrelated and stays — it suppresses Settings Sync + * "Sync this extension?" prompts for product-managed extensions. + * + * Migration: legacy installs from before this fix have `isApplicationScoped: + * true` in their metadata. `migrateLegacyAppScope()` runs on every window + * (including conductor profiles, where the full sideload is skipped) and uses + * `updateMetadata` to clear the flag in-place. This is faster and more reliable + * than reinstall-based migration, and it runs in non-default profile windows + * too — important because the user may never see a default-profile window + * once the Conductor switches them to a pinned profile, and stale app-scope + * data in the Default profile would otherwise still bleed into conductor + * profiles via the cross-profile bridge. + */ export class CodexSideloaderContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.codexSideloader'; @@ -61,17 +80,11 @@ export class CodexSideloaderContribution extends Disposable implements IWorkbenc ) { super(); - // Only run sideload in the default profile. All sideload installs - // target the global extension location (defaultProfile.extensionsResource), - // which is visible in all profiles, so there is no benefit to running - // again in a pin-profile window. - if (!this.userDataProfileService.currentProfile.isDefault) { - return; - } - const configured = (this.productService as unknown as Record)['codexSideloadExtensions']; if (!Array.isArray(configured) || configured.length === 0) { - this.logService.info(`${TAG} No sideload extensions configured in product.json`); + if (this.userDataProfileService.currentProfile.isDefault) { + this.logService.info(`${TAG} No sideload extensions configured in product.json`); + } return; } @@ -80,11 +93,61 @@ export class CodexSideloaderContribution extends Disposable implements IWorkbenc return; } + // Migration runs in every window (including conductor profiles). + // Idempotent — clears `isApplicationScoped` from sideloaded extensions + // in the Default profile, which is the legacy-data source that breaks + // pinned-VSIX installs. See top-of-file comment for the full story. + this.migrateLegacyAppScope(entries).catch(err => { + this.logService.warn(`${TAG} Migration of legacy app-scope failed`, err); + }); + + // Sideload installs only run in the default profile. All sideload + // installs target the global extension location, which the conductor's + // `backfillCoreExtensions` later copies into each pin profile. + if (!this.userDataProfileService.currentProfile.isDefault) { + return; + } + this.ensureExtensions(entries).catch(err => { this.logService.error(`${TAG} Unhandled error during sideload`, err); }); } + private async migrateLegacyAppScope(entries: SideloadEntry[]): Promise { + const defaultLoc = this.userDataProfilesService.defaultProfile.extensionsResource; + const installed = await this.extensionManagementService.getInstalled(ExtensionType.User, defaultLoc); + + const sideloadIds = new Set(); + for (const entry of entries) { + sideloadIds.add((typeof entry === 'string' ? entry : entry.id).toLowerCase()); + } + + for (const ext of installed) { + if (!sideloadIds.has(ext.identifier.id.toLowerCase())) { + continue; + } + if (!ext.isApplicationScoped) { + continue; + } + // Defensive: if the manifest itself is application-scoped (a real + // language pack), leave it alone. We only clear the flag we set + // ourselves on extensions whose manifests don't require it. + if (ext.manifest.contributes?.localizations?.length) { + continue; + } + + try { + // IWorkbenchExtensionManagementService.updateMetadata routes to + // the default profile automatically because ext.isApplicationScoped + // is currently true (see extensionManagementService.ts:287). + await this.extensionManagementService.updateMetadata(ext, { isApplicationScoped: false }); + this.logService.info(`${TAG} Migrated legacy app-scope on "${ext.identifier.id}"`); + } catch (err) { + this.logService.warn(`${TAG} Failed to migrate app-scope on "${ext.identifier.id}"`, err); + } + } + } + private async ensureExtensions(entries: SideloadEntry[]): Promise { const installed = await this.extensionManagementService.getInstalled(ExtensionType.User); @@ -93,13 +156,15 @@ export class CodexSideloaderContribution extends Disposable implements IWorkbenc for (const entry of entries) { if (typeof entry === 'string') { - // Gallery entry: skip if ID is present (any version) + // Gallery entry: skip if ID is present (any version). + // `migrateLegacyAppScope` handles the legacy isApplicationScoped + // flag separately, so we don't need to reinstall just for that. const found = installed.some(e => e.identifier.id.toLowerCase() === entry.toLowerCase()); if (!found) { missingGallery.push(entry); } } else { - // VSIX entry: skip only if ID AND version match + // VSIX entry: skip only if ID AND version match. const installedExt = installed.find(e => e.identifier.id.toLowerCase() === entry.id.toLowerCase()); if (!installedExt || installedExt.manifest.version !== entry.version) { missingVsix.push(entry); @@ -145,7 +210,16 @@ export class CodexSideloaderContribution extends Disposable implements IWorkbenc } try { - await this.extensionManagementService.installFromGallery(galleryExt, { isMachineScoped: true }); + // `isApplicationScoped: false` is explicit (not omitted) because + // the upstream install task inherits this flag from any existing + // installation of the same extension — see the patched line at + // extensionManagementService.ts:1047 (`??` semantics). Passing + // `false` ensures legacy app-scoped installs migrate to + // non-app-scoped on reinstall. + await this.extensionManagementService.installFromGallery(galleryExt, { + isApplicationScoped: false, + isMachineScoped: true, + }); this.logService.info(`${TAG} Installed "${id}" v${galleryExt.version}`); } catch (err) { this.logService.error(`${TAG} Failed to install "${id}"`, err); @@ -170,9 +244,12 @@ export class CodexSideloaderContribution extends Disposable implements IWorkbenc for (const entry of entries) { try { + // See the comment in installFromGallery for why + // `isApplicationScoped: false` is explicit. await channel.call('install', [URI.parse(entry.vsix), { installGivenVersion: true, pinned: true, + isApplicationScoped: false, isMachineScoped: true, profileLocation: this.userDataProfilesService.defaultProfile.extensionsResource, }]);