From dca65a164996072837d8954ebf4c441b5cf21e4e Mon Sep 17 00:00:00 2001 From: "vs-code-engineering[bot]" Date: Tue, 1 Sep 2026 20:47:35 +0000 Subject: [PATCH 1/2] [cherry-pick] accounts: avoid more redundant managed settings requests --- .../defaultAccount/common/defaultAccount.ts | 2 + .../accounts/browser/defaultAccount.ts | 37 +++--- .../test/browser/defaultAccount.test.ts | 114 ++++++++++++++++++ .../chat/common/chatEntitlementService.ts | 2 +- 4 files changed, 137 insertions(+), 18 deletions(-) diff --git a/src/vs/platform/defaultAccount/common/defaultAccount.ts b/src/vs/platform/defaultAccount/common/defaultAccount.ts index 784bf55e050c68..e4839aeed658d8 100644 --- a/src/vs/platform/defaultAccount/common/defaultAccount.ts +++ b/src/vs/platform/defaultAccount/common/defaultAccount.ts @@ -38,6 +38,8 @@ export interface IManagedSettingsCompatibilityError { export interface IDefaultAccountRefreshOptions { readonly forceRefresh?: boolean; + /** Refreshes entitlement data even when its cache is fresh. */ + readonly refreshEntitlements?: boolean; /** Allows an explicit user action to retry managed settings after a failed attempt. */ readonly retryManagedSettings?: boolean; } diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index 38ad0cf66e1bfd..7dabdb5f168f78 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -273,6 +273,11 @@ interface IManagedSettingsSources { readonly file: ManagedSettingsData; } +interface IAuthenticatedRequestOptions { + readonly requestTimeoutMs?: number; + readonly retryNotFound?: boolean; +} + type DefaultAccountStatusTelemetry = { status: string; initial: boolean; @@ -678,7 +683,7 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun private onManagedSettingsSourceChanged(): void { if (this.initialized) { - void this.updateDefaultAccount({ forceRefresh: true }); + void this.updateDefaultAccount(); } } @@ -897,15 +902,10 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun private async findMatchingProviderSession(authProviderId: string, allScopes: string[][]): Promise { const sessions = await this.getSessions(authProviderId); - const matchingSessions = []; - for (const session of sessions) { + const matchingSessions = sessions.filter(session => { this.logService.debug('[DefaultAccount] Checking session with scopes', session.scopes); - for (const scopes of allScopes) { - if (this.scopesMatch(session.scopes, scopes)) { - matchingSessions.push(session); - } - } - } + return allScopes.some(scopes => this.scopesMatch(session.scopes, scopes)); + }); return matchingSessions.length > 0 ? matchingSessions : undefined; } @@ -999,7 +999,7 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun private async getEntitlements(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined, options?: IDefaultAccountRefreshOptions): Promise<{ data: IEntitlementsData | undefined | null; fetchedAt: number | undefined }> { const accountId = sessions[0].account.id; const existingData = this._defaultAccount?.accountId === accountId ? this._defaultAccount?.defaultAccount.entitlementsData : undefined; - if (!options?.forceRefresh && existingData && accountPolicyData?.entitlementsFetchedAt && !this.isDataStale(accountPolicyData.entitlementsFetchedAt)) { + if (!options?.forceRefresh && !options?.refreshEntitlements && existingData && accountPolicyData?.entitlementsFetchedAt && !this.isDataStale(accountPolicyData.entitlementsFetchedAt)) { this.logService.debug('[DefaultAccount] Using last fetched entitlements data'); return { data: existingData, fetchedAt: accountPolicyData.entitlementsFetchedAt }; } @@ -1152,7 +1152,7 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun const freshnessSatisfied = requirement.effective && isManagedSettingsFreshnessSatisfiedFor(this._managedSettingsFreshness, scope); // When forceRemoteSettingsRefresh is effective, reuse also requires this scope's freshness to be // satisfied; an outstanding compatibility error always forces revalidation. - if (!options?.forceRefresh && scopedCachedManagedSettings && (!requirement.effective || freshnessSatisfied) && !this._managedSettingsCompatibilityError) { + if (!options?.forceRefresh && !options?.retryManagedSettings && scopedCachedManagedSettings && (!requirement.effective || freshnessSatisfied) && !this._managedSettingsCompatibilityError) { this.logService.debug('[DefaultAccount] Using last fetched managed settings data'); return { ...scopedCachedManagedSettings, scope, compatibilityError: this._managedSettingsCompatibilityError }; } @@ -1281,7 +1281,10 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun const requestUrl = appendManagedSettingsClientIdentity(managedSettingsUrl, this.productService); this.logService.debug('[DefaultAccount] Fetching managed settings from:', requestUrl); const rateLimitBackoffActive = Date.now() < this._rateLimitBackoffUntil; - const response = await this.request(requestUrl, 'GET', undefined, sessions, CancellationToken.None, 'defaultAccount.managedSettings', MANAGED_SETTINGS_REQUEST_TIMEOUT_MS); + const response = await this.request(requestUrl, 'GET', undefined, sessions, CancellationToken.None, 'defaultAccount.managedSettings', { + requestTimeoutMs: MANAGED_SETTINGS_REQUEST_TIMEOUT_MS, + retryNotFound: false, + }); if (!response) { this.logService.debug('[DefaultAccount] Managed settings fetch returned no response (network error, all selected sessions rejected, or active rate-limit backoff); falling back to local-only policy'); this.reportManagedSettingsOutcome('no-response', rateLimitBackoffActive); @@ -1424,9 +1427,9 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun private _rateLimitBackoffUntil = 0; - private async request(url: string, type: 'GET', body: undefined, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, requestTimeoutMs?: number): Promise; - private async request(url: string, type: 'POST', body: object, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, requestTimeoutMs?: number): Promise; - private async request(url: string, type: 'GET' | 'POST', body: object | undefined, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, requestTimeoutMs?: number): Promise { + private async request(url: string, type: 'GET', body: undefined, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, options?: IAuthenticatedRequestOptions): Promise; + private async request(url: string, type: 'POST', body: object, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, options?: IAuthenticatedRequestOptions): Promise; + private async request(url: string, type: 'GET' | 'POST', body: object | undefined, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, options?: IAuthenticatedRequestOptions): Promise { if (Date.now() < this._rateLimitBackoffUntil) { const remainingSec = Math.ceil((this._rateLimitBackoffUntil - Date.now()) / 1000); this.logService.debug(`[DefaultAccount] Skipping request to ${url} — rate-limit backoff active for ${remainingSec}s more`); @@ -1446,7 +1449,7 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun url, data: type === 'POST' ? JSON.stringify(body) : undefined, disableCache: true, - timeout: requestTimeoutMs, + timeout: options?.requestTimeoutMs, headers: { 'Authorization': `Bearer ${session.accessToken}` }, @@ -1460,7 +1463,7 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun this.logService.warn(`[DefaultAccount] Rate limited by ${url} (status ${status}); backing off for ${retryAfterSec}s`); return response; } - if (status === 401 || status === 404) { + if (status === 401 || (status === 404 && options?.retryNotFound !== false)) { this.logService.debug(`[DefaultAccount] Received ${status} for URL ${url} with session ${session.id}, likely due to expired/revoked token or insufficient permissions.`, 'Trying next session if available.'); lastResponse = response; continue; // try next session diff --git a/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts b/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts index b67c3c28d378ed..b3c5cd40a2ee2c 100644 --- a/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts +++ b/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts @@ -111,6 +111,78 @@ suite('DefaultAccountProvider managed settings', () => { }); }); + test('entitlement refresh preserves satisfied governed managed settings', async () => { + const freshEntitlements = { + access_type_sku: 'copilot_business_seat', + chat_enabled: true, + assigned_date: '2026-01-01', + can_signup_for_limited: false, + copilot_plan: 'business', + organization_login_list: [], + analytics_tracking_id: 'tracking-id', + }; + const requestService = new TestRequestService(async options => options.callSite === 'defaultAccount.entitlements' + ? jsonResponse(freshEntitlements) + : Promise.reject(new Error(`Unexpected request: ${options.url}`))); + const provider = await createProvider(requestService); + const fetchedAt = Date.now() - 1000; + const managedSettingsScope = { + accountId, + authenticationProviderId: 'github', + endpointOrigin: 'https://api.github.com', + }; + const cachedPolicy = { + ...createCachedPolicy(true), + managedSettingsScope, + }; + const policyData = { + ...cachedPolicy, + entitlementsFetchedAt: fetchedAt, + managedSettingsFetchedAt: fetchedAt, + }; + provider['setDefaultAccount']({ + defaultAccount: { + authenticationProvider: { id: 'github', name: 'GitHub', enterprise: false }, + accountName: sessions[0].account.label, + sessionId: sessions[0].id, + enterprise: false, + entitlementsData: { ...freshEntitlements, copilot_plan: 'individual' }, + }, + accountId, + policyData, + copilotTokenInfo: null, + }); + provider['setManagedSettingsFreshness']({ + state: ManagedSettingsFreshnessState.Satisfied, + source: 'server', + scope: managedSettingsScope, + lastAttemptAt: fetchedAt, + satisfiedAt: fetchedAt, + }); + + const refreshedEntitlements = await provider['getDefaultAccountFromAuthenticatedSessions']( + { id: 'github', name: 'GitHub', enterprise: false }, + sessions, + { refreshEntitlements: true } + ); + + assert.deepStrictEqual({ + callSites: requestService.requests.map(request => request.callSite), + refreshedCopilotPlan: refreshedEntitlements?.defaultAccount.entitlementsData?.copilot_plan, + freshness: describeFreshness(provider.managedSettingsFreshness), + }, { + callSites: ['defaultAccount.entitlements'], + refreshedCopilotPlan: 'business', + freshness: { + state: ManagedSettingsFreshnessState.Satisfied, + source: 'server', + scope: managedSettingsScope, + hasLastAttempt: true, + hasSatisfiedAt: true, + }, + }); + }); + test('settings without a refresh requirement refetch only after the cache becomes stale', async () => { const requestService = new TestRequestService(async () => jsonResponse({})); const provider = await createProvider(requestService); @@ -277,6 +349,26 @@ suite('DefaultAccountProvider managed settings', () => { }); }); + test('managed settings 404 does not retry with another authentication session', async () => { + const requestService = new TestRequestService(async () => jsonResponse({}, 404)); + const provider = await createProvider(requestService); + + const result = await provider['getManagedSettings']([ + sessions[0], + { ...sessions[0], id: 'second-session' }, + ], undefined); + + assert.deepStrictEqual({ + requestCount: requestService.requestCount, + status: provider.managedSettingsFetchStatus, + data: result.data, + }, { + requestCount: 1, + status: 404, + data: { managedSettings: undefined }, + }); + }); + test('fresh 404 satisfies a native refresh requirement', async () => { const requestService = new TestRequestService(async () => jsonResponse({}, 404)); const provider = await createProvider(requestService, { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: true }); @@ -556,6 +648,28 @@ suite('DefaultAccountProvider managed settings', () => { assert.strictEqual(requestService.requestCount, 2); }); + test('matching authentication sessions are not duplicated by overlapping accepted scopes', async () => { + const broadSession: AuthenticationSession = { + ...sessions[0], + scopes: ['read:user', 'user:email', 'repo', 'workflow'], + }; + const provider = await createProvider( + new TestRequestService(async () => jsonResponse({})), + {}, + {}, + '', + { getSessions: async () => [broadSession] } + ); + + const matching = await provider['findMatchingProviderSession']('github', [ + ['read:user', 'user:email', 'repo', 'workflow'], + ['user:email'], + ['read:user'], + ]); + + assert.deepStrictEqual(matching?.map(session => session.id), ['session']); + }); + test('first server response can establish and satisfy a refresh requirement', async () => { const requestService = new TestRequestService(async () => jsonResponse({ forceRemoteSettingsRefresh: true, diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index 9d6f333acc8bf1..c84b31cf634f76 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -1221,7 +1221,7 @@ export class ChatEntitlementRequests extends Disposable { } async forceResolveEntitlement(token = CancellationToken.None): Promise { - const defaultAccount = await this.defaultAccountService.refresh({ forceRefresh: true }); + const defaultAccount = await this.defaultAccountService.refresh({ refreshEntitlements: true }); if (!defaultAccount) { return undefined; } From bb2c6916e0dfcaabd71ec482e6ae262ef2357b63 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:26:52 -0700 Subject: [PATCH 2/2] accounts: adapt session test helper for release Allow the cherry-picked overlapping-scope regression test to override authentication sessions on release/1.136, matching the test helper capability it used on main. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../services/accounts/test/browser/defaultAccount.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts b/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts index b3c5cd40a2ee2c..586075249c6513 100644 --- a/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts +++ b/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts @@ -1045,7 +1045,8 @@ suite('DefaultAccountProvider managed settings', () => { requestService: TestRequestService, nativeManagedSettings: ManagedSettingsData = {}, fileManagedSettings: ManagedSettingsData = {}, - managedSettingsUrl = 'https://api.github.com/copilot_internal/managed_settings' + managedSettingsUrl = 'https://api.github.com/copilot_internal/managed_settings', + authenticationServiceOverrides: Partial = {}, ): Promise { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IConfigurationService, new TestConfigurationService()); @@ -1058,6 +1059,7 @@ suite('DefaultAccountProvider managed settings', () => { onDidChangeSessions: Event.None, onDidRegisterAuthenticationProvider: Event.None, onDidUnregisterAuthenticationProvider: Event.None, + ...authenticationServiceOverrides, }); instantiationService.stub(IAuthenticationExtensionsService, { getAccountPreference: () => undefined,