Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/vs/platform/defaultAccount/common/defaultAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
37 changes: 20 additions & 17 deletions src/vs/workbench/services/accounts/browser/defaultAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,11 @@ interface IManagedSettingsSources {
readonly file: ManagedSettingsData;
}

interface IAuthenticatedRequestOptions {
readonly requestTimeoutMs?: number;
readonly retryNotFound?: boolean;
}

type DefaultAccountStatusTelemetry = {
status: string;
initial: boolean;
Expand Down Expand Up @@ -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();
}
}

Expand Down Expand Up @@ -897,15 +902,10 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun

private async findMatchingProviderSession(authProviderId: string, allScopes: string[][]): Promise<AuthenticationSession[] | undefined> {
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;
}

Expand Down Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<IRequestContext | undefined>;
private async request(url: string, type: 'POST', body: object, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, requestTimeoutMs?: number): Promise<IRequestContext | undefined>;
private async request(url: string, type: 'GET' | 'POST', body: object | undefined, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, requestTimeoutMs?: number): Promise<IRequestContext | undefined> {
private async request(url: string, type: 'GET', body: undefined, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, options?: IAuthenticatedRequestOptions): Promise<IRequestContext | undefined>;
private async request(url: string, type: 'POST', body: object, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, options?: IAuthenticatedRequestOptions): Promise<IRequestContext | undefined>;
private async request(url: string, type: 'GET' | 'POST', body: object | undefined, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, options?: IAuthenticatedRequestOptions): Promise<IRequestContext | undefined> {
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`);
Expand All @@ -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}`
},
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -931,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<IAuthenticationService> = {},
): Promise<DefaultAccountProvider> {
const instantiationService = disposables.add(new TestInstantiationService());
instantiationService.stub(IConfigurationService, new TestConfigurationService());
Expand All @@ -944,6 +1059,7 @@ suite('DefaultAccountProvider managed settings', () => {
onDidChangeSessions: Event.None,
onDidRegisterAuthenticationProvider: Event.None,
onDidUnregisterAuthenticationProvider: Event.None,
...authenticationServiceOverrides,
});
instantiationService.stub(IAuthenticationExtensionsService, {
getAccountPreference: () => undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1221,7 +1221,7 @@ export class ChatEntitlementRequests extends Disposable {
}

async forceResolveEntitlement(token = CancellationToken.None): Promise<IEntitlements | undefined> {
const defaultAccount = await this.defaultAccountService.refresh({ forceRefresh: true });
const defaultAccount = await this.defaultAccountService.refresh({ refreshEntitlements: true });
if (!defaultAccount) {
return undefined;
}
Expand Down
Loading