From 7bf303425068279209344048b2d3f8bc406075de Mon Sep 17 00:00:00 2001 From: Jonah Braun Date: Tue, 5 May 2026 17:21:41 -0600 Subject: [PATCH 1/5] fix(conductor): allow reading extensions with validation warnings in profiles This ensures that pinned extensions with prerelease tags or other minor manifest warnings can be successfully verified during profile installation. By default, the profile scanner filters out extensions marked as invalid. In the context of the Conductor, which often installs custom VSIX builds with unstable version strings, this filtering causes the verification step to fail with a 'Cannot read the extension' error even if the installation succeeded on disk. This commit mirrors the behavior of non-profile installations by explicitly including invalid extensions during the verification scan. --- AGENTS.md | 1 + patches/fix-conductor-extension-read.patch | 13 +++++++++++++ 2 files changed, 14 insertions(+) create mode 100644 patches/fix-conductor-extension-read.patch diff --git a/AGENTS.md b/AGENTS.md index ba4a0e22cb7..4c3cd0c2178 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,6 +160,7 @@ 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) | 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); From 756224044ca96fd649d5b74c899085e762b5c322 Mon Sep 17 00:00:00 2001 From: Jonah Braun Date: Tue, 5 May 2026 17:21:55 -0600 Subject: [PATCH 2/5] refactor(sideloader): unify sideload types and parsing in codexTypes Consolidates the definition of 'core' extensions and their parsing logic into a shared location. Previously, the Sideloader and Conductor had duplicated (and in the Conductor's case, hard-coded) definitions for core extensions. By moving the SideloadEntry types and the parseSideloadEntries utility to codexTypes.ts, we establish a single source of truth for configuration and prepare for the Conductor to dynamically derive its core extension list from product.json. --- .../codexConductor/browser/codexTypes.ts | 26 ++++++++++++++++++ .../browser/codexSideloader.ts | 27 +------------------ 2 files changed, 27 insertions(+), 26 deletions(-) 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..97a31c9eec6 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,10 @@ 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; -} - export class CodexSideloaderContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.codexSideloader'; From 5fd88fa5832ab298d7f6e4afa853e44496e65aec Mon Sep 17 00:00:00 2001 From: Jonah Braun Date: Tue, 5 May 2026 17:22:53 -0600 Subject: [PATCH 3/5] feat(conductor): unify core extension management with sideloader Refactors the Conductor to dynamically derive its core extension list from product.json and correctly honor VSIX-pinned sideloads in custom profiles. This commit addresses several architectural issues: 1. P2 Hard-coded Duplication: Removed CORE_EXTENSION_IDS constant. Core extensions are now read from productService.codexSideloadExtensions. 2. P1 VSIX Divergence: The backfillCoreExtensions helper now handles both gallery and VSIX-based installations. If a beta build specifies a VSIX for a core extension, the Conductor will install that exact version into the pinned profile instead of falling back to the public gallery. 3. P1 Pin Conflict Logic: enforcePins and validateProfileExtensions now explicitly skip core-version checks if the extension is already pinned to a specific version in the project metadata. This ensures project-specific pins take precedence without triggering repair/reload loops. --- .../codexConductor/browser/codexConductor.ts | 188 ++++++++++++++++-- 1 file changed, 175 insertions(+), 13 deletions(-) diff --git a/src/stable/src/vs/workbench/contrib/codexConductor/browser/codexConductor.ts b/src/stable/src/vs/workbench/contrib/codexConductor/browser/codexConductor.ts index 091a7445a09..18ac03ec00e 100644 --- a/src/stable/src/vs/workbench/contrib/codexConductor/browser/codexConductor.ts +++ b/src/stable/src/vs/workbench/contrib/codexConductor/browser/codexConductor.ts @@ -10,6 +10,7 @@ import { IWorkspaceContextService, WorkbenchState, toWorkspaceIdentifier, isSing import { IUserDataProfileService } from '../../../services/userDataProfile/common/userDataProfile.js'; import { IUserDataProfile, IUserDataProfilesService } from '../../../../platform/userDataProfile/common/userDataProfile.js'; import { IWorkbenchExtensionManagementService } from '../../../services/extensionManagement/common/extensionManagement.js'; +import { IExtensionGalleryService } from '../../../../platform/extensionManagement/common/extensionManagement.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { URI } from '../../../../base/common/uri.js'; @@ -27,17 +28,16 @@ import { IClipboardService } from '../../../../platform/clipboard/common/clipboa import { IProductService } from '../../../../platform/product/common/productService.js'; import { OS, OperatingSystem } from '../../../../base/common/platform.js'; import { timeout } from '../../../../base/common/async.js'; -import { PinnedExtensions, RequiredExtensions, ProjectMetadata, parsePinnedExtensions } from './codexTypes.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { PinnedExtensions, RequiredExtensions, ProjectMetadata, parsePinnedExtensions, SideloadEntry, SideloadVsixEntry, parseSideloadEntries } from './codexTypes.js'; /** Maps profile name → array of project folder URIs that reference it. */ type ProfileAssociations = Record; -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,7 +323,16 @@ 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 { @@ -364,14 +386,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 +522,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; } @@ -541,6 +582,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). @@ -567,7 +609,12 @@ export class CodexConductorContribution extends Disposable implements IWorkbench return; } - await this.switchProfileAndReload(profile); + // 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 +848,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 +979,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 +1016,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 +1033,93 @@ 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 { + await this.extensionManagementService.installFromGallery(galleryExt, { + profileLocation: profile.extensionsResource, + 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 { + await channel.call('install', [URI.parse(entry.vsix), { + installGivenVersion: true, + pinned: true, + 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; + } + } + } + } } From 37913149e511ac72d9119121451630c62ebd9854 Mon Sep 17 00:00:00 2001 From: Jonah Braun Date: Thu, 7 May 2026 08:47:59 -0600 Subject: [PATCH 4/5] fix(conductor): stop pinned VSIX installs from inheriting app-scope from sideloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pinned VSIX installs were failing post-install with "Cannot read the extension from /Users/j/.codex/extensions/...". Three independent decisions interacted to cause this: 1. d9f6ee6 marked sideloaded default-profile extensions as `isApplicationScoped: true` to make them visible inside conductor profiles via VS Code's cross-profile bridge. 2. 5fd88fa added `backfillCoreExtensions`, which copies sideloaded extensions into each conductor profile explicitly. This made (1) redundant. 3. extensionManagementService.ts:1047 inherits `isApplicationScoped` from any existing extension via `||`. When the conductor installs a pinned VSIX into a custom profile, the existingExtension lookup finds the bridged Default-profile sideload (app-scoped) and the new install inherits the flag. The post-install profile scan then filters out app-scoped entries and looks for them in the Default profile — where the pinned version doesn't exist — so the find fails and "Cannot read the extension" is thrown. Even if it succeeded, dedup prefers app-scoped, so the conductor's pin would be silently shadowed by the sideload at runtime. Fix: - Sideloader: pass `isApplicationScoped: false` explicitly on every install. Add `migrateLegacyAppScope()` that runs in every window (not just default-profile) and uses `updateMetadata` to clear the flag on legacy data. Document the rationale in a top-of-file comment. - Conductor: pass `isApplicationScoped: false` explicitly in `installPinnedExtensions` and both branches of `backfillCoreExtensions`. - New patch `fix-extension-scope-inheritance.patch`: change \`||\` to \`??\` on extensionManagementService.ts:1047 so an explicit \`isApplicationScoped: false\` from options actually wins over an inherited \`true\`. - Update AGENTS.md patch-dependency table. Verified: legacy state migrates on next launch (sideloader logs "Migrated legacy app-scope on …" for each affected extension), the conductor's pinned VSIX install completes without the "Cannot read the extension" error, and the conductor profile ends up with the correct pin versions actually installed and runnable. --- AGENTS.md | 1 + patches/fix-extension-scope-inheritance.patch | 9 ++ .../codexConductor/browser/codexConductor.ts | 17 +++ .../browser/codexSideloader.ts | 126 ++++++++++++++++-- 4 files changed, 141 insertions(+), 12 deletions(-) create mode 100644 patches/fix-extension-scope-inheritance.patch diff --git a/AGENTS.md b/AGENTS.md index 4c3cd0c2178..08eb6ccc830 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,6 +161,7 @@ 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-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)['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; } @@ -55,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); @@ -68,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); @@ -120,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); @@ -145,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, }]); From 9c995cd05a2f636730477e2b6b04c04248c1e8fb Mon Sep 17 00:00:00 2001 From: Jonah Braun Date: Thu, 7 May 2026 09:47:08 -0600 Subject: [PATCH 5/5] feat(conductor): show progress toast during startup pin install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enforcePins() previously installed pinned VSIXs silently behind the splash screen. Mirror the toast styling used by checkForPinChanges() so first-open and repair flows surface the same "Installing pinned extension…" indeterminate progress notification while the conductor downloads and installs. --- .../contrib/codexConductor/browser/codexConductor.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/stable/src/vs/workbench/contrib/codexConductor/browser/codexConductor.ts b/src/stable/src/vs/workbench/contrib/codexConductor/browser/codexConductor.ts index 7e1c123a2bc..6449aa67431 100644 --- a/src/stable/src/vs/workbench/contrib/codexConductor/browser/codexConductor.ts +++ b/src/stable/src/vs/workbench/contrib/codexConductor/browser/codexConductor.ts @@ -591,10 +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) { @@ -620,6 +628,8 @@ export class CodexConductorContribution extends Disposable implements IWorkbench return; } + 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