chat: negotiate managed settings client compatibility (#329688)

* chat: negotiate managed settings client compatibility

Report the VS Code managed-settings client version, securely transport the User-Agent, parse compatibility responses, and preserve fail-closed state across refreshes.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chat: simplify managed settings client identity

Use the existing browser-safe Editor-Version convention from productService and remove the dedicated User-Agent IPC transport.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chat: report bundled Copilot runtime version

Send the runtime version from product metadata alongside the VS Code editor identity and include both values in policy diagnostics.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test: align peer chat sandbox expectation

Use the shared sandbox config builder introduced on main so the peer-chat assertion follows the current sandbox semantics.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
joshspicer
2026-08-12 19:11:24 +00:00
committed by GitHub
co-authored by Copilot
parent 246e86411d
commit 661f18fdeb
12 changed files with 347 additions and 74 deletions
@@ -298,6 +298,8 @@ export async function withAsyncTestCodeEditorAndInlineCompletionsModel<T>(
managedSettingsFetchStatus: null,
managedSettingsFetchedAt: null,
managedSettingsRawResponse: null,
managedSettingsCompatibilityError: null,
onDidChangeManagedSettingsCompatibilityError: Event.None,
getDefaultAccount: async () => null,
setDefaultAccountProvider: () => { },
getDefaultAccountAuthenticationProvider: () => { return { id: 'mockProvider', name: 'Mock Provider', enterprise: false }; },
@@ -1134,6 +1134,8 @@ class StandaloneDefaultAccountService implements IDefaultAccountService {
readonly managedSettingsFetchStatus: null = null;
readonly managedSettingsFetchedAt: null = null;
readonly managedSettingsRawResponse: unknown = null;
readonly managedSettingsCompatibilityError = null;
readonly onDidChangeManagedSettingsCompatibilityError = Event.None;
async getDefaultAccount(): Promise<IDefaultAccount | null> {
return null;
@@ -27,6 +27,14 @@ export const GitHubPaths = {
*/
export type ManagedSettingsFetchStatus = number | 'ok' | 'no-url' | 'no-response' | 'parse-error' | null;
export const MANAGED_SETTINGS_UPDATE_REQUIRED_ERROR_CODE = 'client_update_required';
export interface IManagedSettingsCompatibilityError {
readonly errorCode: typeof MANAGED_SETTINGS_UPDATE_REQUIRED_ERROR_CODE;
readonly clientVersion?: string;
readonly minimumClientVersion?: string;
}
export interface IDefaultAccountProvider {
readonly defaultAccount: IDefaultAccount | null;
readonly onDidChangeDefaultAccount: Event<IDefaultAccount | null>;
@@ -39,6 +47,8 @@ export interface IDefaultAccountProvider {
readonly managedSettingsFetchedAt: number | null;
/** The raw JSON response from the managed-settings endpoint, for diagnostics. */
readonly managedSettingsRawResponse: unknown;
readonly managedSettingsCompatibilityError: IManagedSettingsCompatibilityError | null;
readonly onDidChangeManagedSettingsCompatibilityError: Event<IManagedSettingsCompatibilityError | null>;
getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider;
/**
@@ -70,6 +80,8 @@ export interface IDefaultAccountService {
readonly managedSettingsFetchedAt: number | null;
/** The raw JSON response from the managed-settings endpoint, for diagnostics. */
readonly managedSettingsRawResponse: unknown;
readonly managedSettingsCompatibilityError: IManagedSettingsCompatibilityError | null;
readonly onDidChangeManagedSettingsCompatibilityError: Event<IManagedSettingsCompatibilityError | null>;
getDefaultAccount(): Promise<IDefaultAccount | null>;
getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider;
setDefaultAccountProvider(provider: IDefaultAccountProvider): void;
+2
View File
@@ -133,6 +133,8 @@ class MockDefaultAccountService implements IDefaultAccountService {
readonly managedSettingsFetchStatus: null = null;
readonly managedSettingsFetchedAt: null = null;
readonly managedSettingsRawResponse: unknown = null;
readonly managedSettingsCompatibilityError = null;
readonly onDidChangeManagedSettingsCompatibilityError = Event.None;
async getDefaultAccount(): Promise<IDefaultAccount | null> { return MOCK_ACCOUNT; }
getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider { return MOCK_ACCOUNT.authenticationProvider; }
@@ -53,7 +53,7 @@ import { IPolicyService, PolicyValueSource } from '../../../platform/policy/comm
import { COPILOT_ENABLED_PLUGINS_KEY, COPILOT_EXTRA_MARKETPLACES_KEY, COPILOT_STRICT_MARKETPLACES_KEY, INativeManagedSettingsService, IFileManagedSettingsService, ManagedSettingsChannel, ManagedSettingsSource, normalizeManagedSettings, projectManagedSettings, pickManagedSettings } from '../../../platform/policy/common/copilotManagedSettings.js';
import { IManagedSettingPolicyDefinition, ManagedSettingsData } from '../../../base/common/policy.js';
import { APPROVED_ACCOUNT_ORGANIZATIONS_POLICY_NAME, IAccountPolicyGateService } from '../../services/policies/common/accountPolicyService.js';
import { adaptManagedSettings, IManagedSettingsResponse } from '../../services/accounts/browser/managedSettings.js';
import { adaptManagedSettings, getManagedSettingsClientHeaders, IManagedSettingsResponse } from '../../services/accounts/browser/managedSettings.js';
import { isObject } from '../../../base/common/types.js';
import * as json from '../../../base/common/json.js';
import { getParseErrorMessage } from '../../../base/common/jsonErrorMessages.js';
@@ -1062,13 +1062,17 @@ class PolicyDiagnosticsAction extends Action2 {
const fetchStatus = defaultAccountService.managedSettingsFetchStatus;
const fetchedAt = defaultAccountService.managedSettingsFetchedAt;
const clientHeaders = getManagedSettingsClientHeaders(productService);
const compatibilityError = defaultAccountService.managedSettingsCompatibilityError;
content += '#### GitHub Server API\n\n';
content += markdownTable(
['Property', 'Value'],
[
['Endpoint', '/copilot_internal/managed_settings'],
['Last fetch', fetchStatus === null ? 'never' : String(fetchStatus)],
['Last successful fetch', fetchedAt ? new Date(fetchedAt).toLocaleString() : 'n/a'],
['Last fetch', fetchStatus === null ? 'never' : `${fetchStatus}${fetchedAt ? ` at ${new Date(fetchedAt).toLocaleString()}` : ''}`],
['Editor-Version', String(clientHeaders['Editor-Version'])],
['Copilot-Runtime-Version', String(clientHeaders['Copilot-Runtime-Version'] ?? 'not available')],
['Compatibility', compatibilityError ? `update required (${compatibilityError.clientVersion ?? '?'}${compatibilityError.minimumClientVersion ?? '?'})` : 'compatible or not evaluated'],
['Contributes winning keys', channelContributes('server') ? 'yes' : 'no']
]
);
@@ -14,18 +14,17 @@ import { equals } from '../../../../base/common/objects.js';
import { isWeb } from '../../../../base/common/platform.js';
import { IDefaultChatAgent } from '../../../../base/common/product.js';
import { isString, isUndefined, Mutable } from '../../../../base/common/types.js';
import { IRequestContext } from '../../../../base/parts/request/common/request.js';
import { IHeaders, IRequestContext } from '../../../../base/parts/request/common/request.js';
import { localize2 } from '../../../../nls.js';
import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js';
import { ICommandService } from '../../../../platform/commands/common/commands.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';
import { IDefaultAccountProvider, IDefaultAccountService, ManagedSettingsFetchStatus } from '../../../../platform/defaultAccount/common/defaultAccount.js';
import { IDefaultAccountProvider, IDefaultAccountService, IManagedSettingsCompatibilityError, MANAGED_SETTINGS_UPDATE_REQUIRED_ERROR_CODE, ManagedSettingsFetchStatus } from '../../../../platform/defaultAccount/common/defaultAccount.js';
import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
import { ILogService } from '../../../../platform/log/common/log.js';
import { IProductService } from '../../../../platform/product/common/productService.js';
import { asJson, IRequestService, isClientError, isSuccess, readHeader, retryAfterFromHeaders } from '../../../../platform/request/common/request.js';
import { INativeManagedSettingsService, shouldForceRemoteSettingsRefresh } from '../../../../platform/policy/common/copilotManagedSettings.js';
import { asJson, asText, IRequestService, isClientError, isSuccess, readHeader, retryAfterFromHeaders } from '../../../../platform/request/common/request.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js';
@@ -33,7 +32,7 @@ import { AuthenticationSession, AuthenticationSessionAccount, IAuthenticationExt
import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js';
import { IExtensionService } from '../../extensions/common/extensions.js';
import { IHostService } from '../../host/browser/host.js';
import { adaptManagedSettings, IManagedSettingsResponse } from './managedSettings.js';
import { adaptManagedSettings, getManagedSettingsClientHeaders, IManagedSettingsResponse, parseManagedSettingsCompatibilityError } from './managedSettings.js';
interface IDefaultAccountConfig {
readonly preferredExtensions: string[];
@@ -126,6 +125,7 @@ export class DefaultAccountService extends Disposable implements IDefaultAccount
get managedSettingsFetchStatus(): ManagedSettingsFetchStatus { return this.defaultAccountProvider?.managedSettingsFetchStatus ?? null; }
get managedSettingsFetchedAt(): number | null { return this.defaultAccountProvider?.managedSettingsFetchedAt ?? null; }
get managedSettingsRawResponse(): unknown { return this.defaultAccountProvider?.managedSettingsRawResponse ?? null; }
get managedSettingsCompatibilityError(): IManagedSettingsCompatibilityError | null { return this.defaultAccountProvider?.managedSettingsCompatibilityError ?? null; }
private readonly initBarrier = new Barrier();
@@ -138,6 +138,9 @@ export class DefaultAccountService extends Disposable implements IDefaultAccount
private readonly _onDidChangeCopilotTokenInfo = this._register(new Emitter<ICopilotTokenInfo | null>());
readonly onDidChangeCopilotTokenInfo = this._onDidChangeCopilotTokenInfo.event;
private readonly _onDidChangeManagedSettingsCompatibilityError = this._register(new Emitter<IManagedSettingsCompatibilityError | null>());
readonly onDidChangeManagedSettingsCompatibilityError = this._onDidChangeManagedSettingsCompatibilityError.event;
private readonly defaultAccountConfig: IDefaultAccountConfig;
private defaultAccountProvider: IDefaultAccountProvider | null = null;
@@ -169,9 +172,13 @@ export class DefaultAccountService extends Disposable implements IDefaultAccount
}
this.defaultAccountProvider = provider;
this._register(provider.onDidChangeManagedSettingsCompatibilityError(error => this._onDidChangeManagedSettingsCompatibilityError.fire(error)));
if (this.defaultAccountProvider.policyData) {
this._onDidChangePolicyData.fire(this.defaultAccountProvider.policyData);
}
if (this.defaultAccountProvider.managedSettingsCompatibilityError) {
this._onDidChangeManagedSettingsCompatibilityError.fire(this.defaultAccountProvider.managedSettingsCompatibilityError);
}
provider.refresh().then(account => {
this.defaultAccount = account;
}).finally(() => {
@@ -224,6 +231,7 @@ interface IAccountPolicyData {
readonly tokenEntitlementsFetchedAt?: number;
readonly mcpRegistryDataFetchedAt?: number;
readonly managedSettingsFetchedAt?: number;
readonly managedSettingsCompatibilityError?: IManagedSettingsCompatibilityError;
}
interface ICachedAccountData {
@@ -238,6 +246,12 @@ interface IDefaultAccountData {
copilotTokenInfo: ICopilotTokenInfo | null;
}
type ManagedSettingsRequestResult =
| { readonly kind: 'success'; readonly data: Partial<IPolicyData> }
| { readonly kind: 'noSettings' }
| { readonly kind: 'updateRequired'; readonly error: IManagedSettingsCompatibilityError }
| { readonly kind: 'unavailable' };
type DefaultAccountStatusTelemetry = {
status: string;
initial: boolean;
@@ -280,6 +294,9 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun
private _managedSettingsRawResponse: unknown = null;
get managedSettingsRawResponse(): unknown { return this._managedSettingsRawResponse; }
private _managedSettingsCompatibilityError: IManagedSettingsCompatibilityError | null = null;
get managedSettingsCompatibilityError(): IManagedSettingsCompatibilityError | null { return this._managedSettingsCompatibilityError; }
private readonly _onDidChangeDefaultAccount = this._register(new Emitter<IDefaultAccount | null>());
readonly onDidChangeDefaultAccount = this._onDidChangeDefaultAccount.event;
@@ -289,6 +306,9 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun
private readonly _onDidChangeCopilotTokenInfo = this._register(new Emitter<ICopilotTokenInfo | null>());
readonly onDidChangeCopilotTokenInfo = this._onDidChangeCopilotTokenInfo.event;
private readonly _onDidChangeManagedSettingsCompatibilityError = this._register(new Emitter<IManagedSettingsCompatibilityError | null>());
readonly onDidChangeManagedSettingsCompatibilityError = this._onDidChangeManagedSettingsCompatibilityError.event;
private readonly accountStatusContext: IContextKey<string>;
private initialized = false;
private readonly initPromise: Promise<void>;
@@ -306,17 +326,18 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun
@IRequestService private readonly requestService: IRequestService,
@ILogService private readonly logService: ILogService,
@IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService,
@IProductService private readonly productService: IProductService,
@IContextKeyService contextKeyService: IContextKeyService,
@IStorageService private readonly storageService: IStorageService,
@IHostService private readonly hostService: IHostService,
@ICommandService private readonly commandService: ICommandService,
@INativeManagedSettingsService private readonly nativeManagedSettingsService: INativeManagedSettingsService,
) {
super();
this.accountStatusContext = CONTEXT_DEFAULT_ACCOUNT_STATE.bindTo(contextKeyService);
const cachedAccountData = this.getCachedAccountData();
this._policyData = cachedAccountData?.accountPolicyData ?? null;
this._copilotTokenInfo = cachedAccountData?.copilotTokenInfo ?? null;
this._managedSettingsCompatibilityError = cachedAccountData?.accountPolicyData.managedSettingsCompatibilityError ?? null;
this.initPromise = this.init()
.finally(() => {
this.telemetryService.publicLog2<DefaultAccountStatusTelemetry, DefaultAccountStatusTelemetryClassification>('defaultaccount:status', { status: this.defaultAccount ? 'available' : 'unavailable', initial: true });
@@ -539,12 +560,14 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun
this._defaultAccount = account;
this.setCopilotTokenInfo(account.copilotTokenInfo);
this.setPolicyData(account.policyData);
this.setManagedSettingsCompatibilityError(account.policyData?.managedSettingsCompatibilityError ?? null);
this._onDidChangeDefaultAccount.fire(this._defaultAccount.defaultAccount);
this.accountStatusContext.set(DefaultAccountStatus.Available);
this.logService.debug('[DefaultAccount] Account status set to Available');
} else {
this._defaultAccount = null;
this.setPolicyData(null);
this.setManagedSettingsCompatibilityError(null);
this.setCopilotTokenInfo(null);
this._onDidChangeDefaultAccount.fire(null);
this.accountDataPollScheduler.cancel();
@@ -562,6 +585,14 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun
this._onDidChangePolicyData.fire(this._policyData?.policyData ?? null);
}
private setManagedSettingsCompatibilityError(error: IManagedSettingsCompatibilityError | null): void {
if (equals(this._managedSettingsCompatibilityError, error)) {
return;
}
this._managedSettingsCompatibilityError = error;
this._onDidChangeManagedSettingsCompatibilityError.fire(error);
}
private setCopilotTokenInfo(copilotTokenInfo: ICopilotTokenInfo | null): void {
if (equals(this._copilotTokenInfo, copilotTokenInfo)) {
return;
@@ -636,6 +667,9 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun
const tokenEntitlementsFetchedAt: number | undefined = tokenEntitlementsResult?.fetchedAt;
const managedSettingsFetchedAt: number | undefined = managedSettingsResult?.fetchedAt;
const managedSettingsCompatibilityError = managedSettingsResult
? managedSettingsResult.compatibilityError
: this._managedSettingsCompatibilityError;
let mcpRegistryDataFetchedAt: number | undefined;
let policyData: Mutable<IPolicyData> | undefined = accountPolicyData?.policyData ? { ...accountPolicyData.policyData } : undefined;
if (entitlementsData) {
@@ -671,7 +705,15 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun
};
this.logService.debug('[DefaultAccount] Successfully created default account for provider:', authenticationProvider.id);
const accountPolicyResult: IAccountPolicyData | null = policyData || entitlementsFetchedAt
? { accountId, policyData: policyData ?? {}, entitlementsFetchedAt, tokenEntitlementsFetchedAt, mcpRegistryDataFetchedAt, managedSettingsFetchedAt }
? {
accountId,
policyData: policyData ?? {},
entitlementsFetchedAt,
tokenEntitlementsFetchedAt,
mcpRegistryDataFetchedAt,
managedSettingsFetchedAt,
managedSettingsCompatibilityError: managedSettingsCompatibilityError ?? undefined,
}
: null;
return {
defaultAccount,
@@ -873,7 +915,7 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun
}
}
private async getManagedSettings(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined, options?: { forceRefresh?: boolean }): Promise<{ data: Partial<IPolicyData> | undefined; fetchedAt: number }> {
private async getManagedSettings(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined, options?: { forceRefresh?: boolean }): Promise<{ data: Partial<IPolicyData> | undefined; fetchedAt: number; compatibilityError: IManagedSettingsCompatibilityError | null }> {
const accountId = sessions[0].account.id;
const cachedManagedSettings = accountPolicyData?.managedSettingsFetchedAt !== undefined && !this.isDataStale(accountPolicyData.managedSettingsFetchedAt)
? {
@@ -883,58 +925,64 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun
fetchedAt: accountPolicyData.managedSettingsFetchedAt,
}
: undefined;
let forceRemoteSettingsRefresh = false;
if (!options?.forceRefresh && cachedManagedSettings && !this.managedSettingsFetchAttemptedAccounts.has(accountId)) {
let nativeManagedSettings = this.nativeManagedSettingsService.managedSettings;
try {
nativeManagedSettings = await this.nativeManagedSettingsService.initialize();
} catch (error) {
this.logService.warn('[DefaultAccount] Failed to initialize native managed settings before resolving forceRemoteSettingsRefresh; using available values', getErrorMessage(error));
nativeManagedSettings = this.nativeManagedSettingsService.managedSettings;
}
forceRemoteSettingsRefresh = shouldForceRemoteSettingsRefresh(nativeManagedSettings, accountPolicyData?.policyData.managedSettings);
const hasFetchedThisProcess = this.managedSettingsFetchAttemptedAccounts.has(accountId);
if (!options?.forceRefresh && cachedManagedSettings && hasFetchedThisProcess) {
this.logService.debug('[DefaultAccount] Using last fetched managed settings data');
return { ...cachedManagedSettings, compatibilityError: this._managedSettingsCompatibilityError };
}
if (!options?.forceRefresh && cachedManagedSettings && !forceRemoteSettingsRefresh) {
this.logService.debug('[DefaultAccount] Using last fetched managed settings data');
// Seed status so Policy Diagnostics reflects "applied" rather than
// "not yet fetched" after a process restart that warm-starts from
// the cached policy payload.
this._managedSettingsFetchStatus = 'ok';
return cachedManagedSettings;
}
if (forceRemoteSettingsRefresh) {
this.logService.info('[DefaultAccount] forceRemoteSettingsRefresh is set; fetching fresh managed settings instead of using the cached response');
}
this.managedSettingsFetchAttemptedAccounts.add(accountId);
const data = await this.requestManagedSettings(sessions);
return { data: data ?? cachedManagedSettings?.data, fetchedAt: Date.now() };
const result = await this.requestManagedSettings(sessions);
const fetchedAt = Date.now();
switch (result.kind) {
case 'success':
return { data: result.data, fetchedAt, compatibilityError: null };
case 'noSettings':
return { data: { managedSettings: undefined }, fetchedAt, compatibilityError: null };
case 'updateRequired':
return { data: { managedSettings: undefined }, fetchedAt, compatibilityError: result.error };
case 'unavailable':
return {
data: this._managedSettingsCompatibilityError ? { managedSettings: undefined } : cachedManagedSettings?.data,
fetchedAt,
compatibilityError: this._managedSettingsCompatibilityError,
};
}
}
private async requestManagedSettings(sessions: AuthenticationSession[]): Promise<Partial<IPolicyData> | undefined> {
private async requestManagedSettings(sessions: AuthenticationSession[]): Promise<ManagedSettingsRequestResult> {
const managedSettingsUrl = this.getManagedSettingsUrl();
if (!managedSettingsUrl) {
this.logService.debug('[DefaultAccount] No managed settings URL configured; skipping enterprise policy fetch');
this._managedSettingsFetchStatus = 'no-url';
return undefined;
return { kind: 'unavailable' };
}
this.logService.debug('[DefaultAccount] Fetching managed settings from:', managedSettingsUrl);
const rateLimitBackoffActive = Date.now() < this._rateLimitBackoffUntil;
const response = await this.request(managedSettingsUrl, 'GET', undefined, sessions, CancellationToken.None, 'defaultAccount.managedSettings', MANAGED_SETTINGS_REQUEST_TIMEOUT_MS);
const response = await this.request(managedSettingsUrl, 'GET', undefined, sessions, CancellationToken.None, 'defaultAccount.managedSettings', MANAGED_SETTINGS_REQUEST_TIMEOUT_MS, getManagedSettingsClientHeaders(this.productService));
if (!response) {
this.logService.debug('[DefaultAccount] Managed settings fetch returned no response (network error, all sessions rejected, or active rate-limit backoff); falling back to local-only policy');
this.reportManagedSettingsOutcome('no-response', rateLimitBackoffActive);
return undefined;
return { kind: 'unavailable' };
}
const status = response.res.statusCode ?? 0;
if (status === 404) {
this.reportManagedSettingsOutcome(status, rateLimitBackoffActive);
return { kind: 'noSettings' };
}
if (status === 466) {
const error = await this.readManagedSettingsCompatibilityError(response);
this.setManagedSettingsCompatibilityError(error);
this.reportManagedSettingsOutcome(status, rateLimitBackoffActive);
return { kind: 'updateRequired', error };
}
// Any non-2xx response means "fall back to local settings only and continue
// operating normally" — silent fallback, no policy.
if (!isSuccess(response)) {
const status = response.res.statusCode ?? 0;
this.logService.warn(`[DefaultAccount] Managed settings fetch returned non-success status ${status}; falling back to local-only policy`);
this.reportManagedSettingsOutcome(status, rateLimitBackoffActive);
return undefined;
return { kind: 'unavailable' };
}
try {
@@ -951,14 +999,29 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun
this.logService.trace('[DefaultAccount] Managed settings payload:', JSON.stringify(adapted));
}
this.reportManagedSettingsOutcome('ok', rateLimitBackoffActive);
return adapted;
return { kind: 'success', data: adapted };
} catch (error) {
this.logService.error('[DefaultAccount] Failed to parse managed settings response', getErrorMessage(error));
this.reportManagedSettingsOutcome('parse-error', rateLimitBackoffActive);
return undefined;
return { kind: 'unavailable' };
}
}
private async readManagedSettingsCompatibilityError(response: IRequestContext): Promise<IManagedSettingsCompatibilityError> {
try {
const text = await asText(response);
const body: unknown = text ? JSON.parse(text) : undefined;
const parsed = parseManagedSettingsCompatibilityError(body);
if (parsed) {
return parsed;
}
this.logService.error('[DefaultAccount] Managed settings compatibility response did not contain the expected error code');
} catch (error) {
this.logService.error('[DefaultAccount] Failed to parse managed settings compatibility response', getErrorMessage(error));
}
return { errorCode: MANAGED_SETTINGS_UPDATE_REQUIRED_ERROR_CODE };
}
private reportManagedSettingsOutcome(status: Exclude<ManagedSettingsFetchStatus, null | 'no-url'>, rateLimitBackoffActive: boolean): void {
this._managedSettingsFetchStatus = status;
this.telemetryService.publicLog2<ManagedSettingsFetchTelemetry, ManagedSettingsFetchTelemetryClassification>('defaultaccount:managedSettings:fetch', {
@@ -994,9 +1057,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, requestTimeoutMs?: number, headers?: IHeaders): Promise<IRequestContext | undefined>;
private async request(url: string, type: 'POST', body: object, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, requestTimeoutMs?: number, headers?: IHeaders): Promise<IRequestContext | undefined>;
private async request(url: string, type: 'GET' | 'POST', body: object | undefined, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, requestTimeoutMs?: number, headers?: IHeaders): Promise<IRequestContext | undefined> {
// Rate-limit backoff: when any prior `/copilot_internal/*` request was
// throttled (429 or 403 + `X-RateLimit-Remaining: 0`), every subsequent
// request is short-circuited until the parsed `Retry-After` elapses.
@@ -1025,6 +1088,7 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun
disableCache: true,
timeout: requestTimeoutMs,
headers: {
...headers,
'Authorization': `Bearer ${session.accessToken}`
},
callSite
@@ -4,6 +4,10 @@
*--------------------------------------------------------------------------------------------*/
import { IPolicyData } from '../../../../base/common/defaultAccount.js';
import { IProductConfiguration } from '../../../../base/common/product.js';
import { isString } from '../../../../base/common/types.js';
import { IHeaders } from '../../../../base/parts/request/common/request.js';
import { IManagedSettingsCompatibilityError, MANAGED_SETTINGS_UPDATE_REQUIRED_ERROR_CODE } from '../../../../platform/defaultAccount/common/defaultAccount.js';
import { normalizeManagedSettings } from '../../../../platform/policy/common/copilotManagedSettings.js';
/**
@@ -58,6 +62,41 @@ export interface IManagedSettingsResponse {
readonly [key: string]: unknown;
}
export function getManagedSettingsClientHeaders(product: Pick<IProductConfiguration, 'version' | 'copilotVersions'>): IHeaders {
const headers: IHeaders = {
'Editor-Version': `vscode/${product.version}`,
};
const runtimeVersion = product.copilotVersions?.runtime;
if (runtimeVersion) {
headers['Copilot-Runtime-Version'] = `copilot-runtime/${runtimeVersion}`;
}
return headers;
}
interface IManagedSettingsCompatibilityErrorResponse {
readonly error_code?: unknown;
readonly client_version?: unknown;
readonly minimum_client_version?: unknown;
}
function isManagedSettingsCompatibilityErrorResponse(response: unknown): response is IManagedSettingsCompatibilityErrorResponse {
return typeof response === 'object' && response !== null;
}
export function parseManagedSettingsCompatibilityError(response: unknown): IManagedSettingsCompatibilityError | undefined {
if (!isManagedSettingsCompatibilityErrorResponse(response) || response.error_code !== MANAGED_SETTINGS_UPDATE_REQUIRED_ERROR_CODE) {
return undefined;
}
const clientVersion = isString(response.client_version) ? response.client_version : undefined;
const minimumClientVersion = isString(response.minimum_client_version) ? response.minimum_client_version : undefined;
return {
errorCode: MANAGED_SETTINGS_UPDATE_REQUIRED_ERROR_CODE,
...(clientVersion ? { clientVersion } : {}),
...(minimumClientVersion ? { minimumClientVersion } : {}),
};
}
/**
* Adapt the `managed_settings` API response into the `managedSettings` slice of
* {@link IPolicyData} that the policy framework consumes. This is a thin wrapper
@@ -6,7 +6,7 @@
import assert from 'assert';
import { bufferToStream, VSBuffer } from '../../../../../base/common/buffer.js';
import { Event } from '../../../../../base/common/event.js';
import { IRequestContext } from '../../../../../base/parts/request/common/request.js';
import { IRequestContext, IRequestOptions } from '../../../../../base/parts/request/common/request.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
import { ICommandService } from '../../../../../platform/commands/common/commands.js';
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
@@ -15,7 +15,8 @@ import { IContextKeyService } from '../../../../../platform/contextkey/common/co
import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js';
import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js';
import { COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY, INativeManagedSettingsService } from '../../../../../platform/policy/common/copilotManagedSettings.js';
import { COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY } from '../../../../../platform/policy/common/copilotManagedSettings.js';
import { IProductService } from '../../../../../platform/product/common/productService.js';
import { IRequestService } from '../../../../../platform/request/common/request.js';
import { InMemoryStorageService, IStorageService } from '../../../../../platform/storage/common/storage.js';
import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js';
@@ -25,7 +26,7 @@ import { IWorkbenchEnvironmentService } from '../../../environment/common/enviro
import { IExtensionService } from '../../../extensions/common/extensions.js';
import { IHostService } from '../../../host/browser/host.js';
import { DefaultAccountProvider } from '../../browser/defaultAccount.js';
import { IManagedSettingsResponse } from '../../browser/managedSettings.js';
import { TestProductService } from '../../../../test/common/workbenchTestServices.js';
suite('DefaultAccountProvider managed settings', () => {
@@ -38,12 +39,12 @@ suite('DefaultAccountProvider managed settings', () => {
scopes: ['user:email'],
}];
test('cached server control forces only the first fetch', async () => {
test('cached settings perform one startup compatibility fetch', async () => {
const requestService = new TestRequestService(async () => jsonResponse({
forceRemoteSettingsRefresh: true,
permissions: { disableBypassPermissionsMode: 'disable' },
}));
const provider = await createProvider({}, requestService);
const provider = await createProvider(requestService);
const cachedPolicy = createCachedPolicy(true);
const first = await provider['getManagedSettings'](sessions, cachedPolicy);
@@ -51,38 +52,86 @@ suite('DefaultAccountProvider managed settings', () => {
assert.deepStrictEqual({
requestCount: requestService.requestCount,
editorVersion: requestService.requests[0].headers?.['Editor-Version'],
runtimeVersion: requestService.requests[0].headers?.['Copilot-Runtime-Version'],
first: first.data,
second: second.data,
}, {
requestCount: 1,
editorVersion: 'vscode/1.132.0',
runtimeVersion: 'copilot-runtime/0.0.344',
first: cachedPolicy.policyData,
second: cachedPolicy.policyData,
});
});
test('native false suppresses cached server true', async () => {
const requestService = new TestRequestService(async () => {
throw new Error('unexpected request');
});
const provider = await createProvider({ [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: false }, requestService);
test('404 clears cached server managed settings', async () => {
const requestService = new TestRequestService(async () => jsonResponse({}, 404));
const provider = await createProvider(requestService);
const cachedPolicy = createCachedPolicy(true);
const result = await provider['getManagedSettings'](sessions, cachedPolicy);
assert.deepStrictEqual({
requestCount: requestService.requestCount,
status: provider.managedSettingsFetchStatus,
data: result.data,
compatibilityError: provider.managedSettingsCompatibilityError,
}, {
requestCount: 0,
data: cachedPolicy.policyData,
requestCount: 1,
status: 404,
data: { managedSettings: undefined },
compatibilityError: null,
});
});
test('failed forced fetch retains cached managed settings', async () => {
test('466 blocks and does not fall back to cached server managed settings', async () => {
const requestService = new TestRequestService(async () => jsonResponse({
error_code: 'client_update_required',
client_id: 'vscode',
client_version: '1.132.0',
minimum_client_version: '1.133.0',
}, 466));
const provider = await createProvider(requestService);
const cachedPolicy = createCachedPolicy(false);
const result = await provider['getManagedSettings'](sessions, cachedPolicy);
assert.deepStrictEqual({
status: provider.managedSettingsFetchStatus,
data: result.data,
compatibilityError: provider.managedSettingsCompatibilityError,
}, {
status: 466,
data: { managedSettings: undefined },
compatibilityError: {
errorCode: 'client_update_required',
clientVersion: '1.132.0',
minimumClientVersion: '1.133.0',
},
});
});
test('malformed 466 still blocks without compatibility metadata', async () => {
const requestService = new TestRequestService(async () => jsonResponse({ error_code: 'unexpected' }, 466));
const provider = await createProvider(requestService);
const result = await provider['getManagedSettings'](sessions, createCachedPolicy(false));
assert.deepStrictEqual({
data: result.data,
compatibilityError: provider.managedSettingsCompatibilityError,
}, {
data: { managedSettings: undefined },
compatibilityError: { errorCode: 'client_update_required' },
});
});
test('failed startup fetch retains cached managed settings when no rejection is known', async () => {
const requestService = new TestRequestService(async () => {
throw new Error('managed settings unavailable');
});
const provider = await createProvider({ [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: true }, requestService);
const provider = await createProvider(requestService);
const cachedPolicy = createCachedPolicy(false);
const result = await provider['getManagedSettings'](sessions, cachedPolicy);
@@ -98,7 +147,63 @@ suite('DefaultAccountProvider managed settings', () => {
});
});
async function createProvider(nativeManagedSettings: Record<string, boolean>, requestService: TestRequestService): Promise<DefaultAccountProvider> {
test('transient failure does not clear an update-required state', async () => {
let requestCount = 0;
const requestService = new TestRequestService(async () => {
requestCount++;
if (requestCount === 1) {
return jsonResponse({ error_code: 'client_update_required', client_id: 'vscode' }, 466);
}
throw new Error('managed settings unavailable');
});
const provider = await createProvider(requestService);
const cachedPolicy = createCachedPolicy(false);
await provider['getManagedSettings'](sessions, cachedPolicy);
const result = await provider['getManagedSettings'](sessions, cachedPolicy, { forceRefresh: true });
assert.deepStrictEqual({
status: provider.managedSettingsFetchStatus,
data: result.data,
compatibilityError: provider.managedSettingsCompatibilityError,
}, {
status: 'no-response',
data: { managedSettings: undefined },
compatibilityError: { errorCode: 'client_update_required' },
});
});
test('successful full refresh clears a prior update-required state', async () => {
const requestService = new TestRequestService(async options => {
if (options.url?.endsWith('/copilot_internal/user')) {
return jsonResponse({ chat_enabled: true });
}
if (options.url?.endsWith('/copilot_internal/managed_settings')) {
return jsonResponse({});
}
throw new Error(`Unexpected request: ${options.url}`);
});
const provider = await createProvider(requestService);
provider['setManagedSettingsCompatibilityError']({ errorCode: 'client_update_required' });
const account = await provider['getDefaultAccountFromAuthenticatedSessions'](
{ id: 'github', name: 'GitHub', enterprise: false },
sessions,
{ forceRefresh: true }
);
assert.ok(account);
provider['setDefaultAccount'](account);
assert.deepStrictEqual({
compatibilityError: provider.managedSettingsCompatibilityError,
managedSettings: provider.policyData?.managedSettings,
}, {
compatibilityError: null,
managedSettings: {},
});
});
async function createProvider(requestService: TestRequestService): Promise<DefaultAccountProvider> {
const instantiationService = disposables.add(new TestInstantiationService());
instantiationService.stub(IConfigurationService, new TestConfigurationService());
instantiationService.stub(IAuthenticationService, {
@@ -123,6 +228,11 @@ suite('DefaultAccountProvider managed settings', () => {
remoteAuthority: undefined,
isSessionsWindow: false,
});
instantiationService.stub(IProductService, {
...TestProductService,
version: '1.132.0',
copilotVersions: { runtime: '0.0.344', sdk: '0.1.0' },
});
instantiationService.stub(IContextKeyService, new MockContextKeyService());
instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService()));
instantiationService.stub(IHostService, {
@@ -130,11 +240,6 @@ suite('DefaultAccountProvider managed settings', () => {
onDidChangeFocus: Event.None,
});
instantiationService.stub(ICommandService, {});
instantiationService.stub(INativeManagedSettingsService, {
managedSettings: nativeManagedSettings,
initialize: async () => nativeManagedSettings,
onDidChangeManagedSettings: Event.None,
});
const provider = disposables.add(instantiationService.createInstance(DefaultAccountProvider, {
preferredExtensions: [],
@@ -146,7 +251,7 @@ suite('DefaultAccountProvider managed settings', () => {
scopes: [['user:email']],
},
tokenEntitlementUrl: '',
entitlementUrl: '',
entitlementUrl: 'https://api.github.com/copilot_internal/user',
mcpRegistryDataUrl: '',
managedSettingsUrl: 'https://api.github.com/copilot_internal/managed_settings',
}));
@@ -172,12 +277,14 @@ class TestRequestService implements IRequestService {
readonly _serviceBrand: undefined;
readonly onDidCompleteRequest = Event.None;
requestCount = 0;
readonly requests: IRequestOptions[] = [];
constructor(private readonly requestHandler: () => Promise<IRequestContext>) { }
constructor(private readonly requestHandler: (options: IRequestOptions) => Promise<IRequestContext>) { }
request(): Promise<IRequestContext> {
request(options: IRequestOptions): Promise<IRequestContext> {
this.requestCount++;
return this.requestHandler();
this.requests.push(options);
return this.requestHandler(options);
}
async resolveProxy(): Promise<string | undefined> {
@@ -197,9 +304,9 @@ class TestRequestService implements IRequestService {
}
}
function jsonResponse(data: IManagedSettingsResponse): IRequestContext {
function jsonResponse(data: unknown, statusCode = 200): IRequestContext {
return {
res: { statusCode: 200, headers: {} },
res: { statusCode, headers: {} },
stream: bufferToStream(VSBuffer.fromString(JSON.stringify(data))),
};
}
@@ -5,7 +5,7 @@
import assert from 'assert';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
import { adaptManagedSettings, IManagedSettingsResponse } from '../../browser/managedSettings.js';
import { adaptManagedSettings, getManagedSettingsClientHeaders, IManagedSettingsResponse, parseManagedSettingsCompatibilityError } from '../../browser/managedSettings.js';
suite('adaptManagedSettings', () => {
@@ -17,6 +17,24 @@ suite('adaptManagedSettings', () => {
});
});
test('builds available client identity headers', () => {
assert.deepStrictEqual({
withRuntime: getManagedSettingsClientHeaders({
version: '1.132.0',
copilotVersions: { runtime: '0.0.344', sdk: '0.1.0' },
}),
withoutRuntime: getManagedSettingsClientHeaders({ version: '1.132.0' }),
}, {
withRuntime: {
'Editor-Version': 'vscode/1.132.0',
'Copilot-Runtime-Version': 'copilot-runtime/0.0.344',
},
withoutRuntime: {
'Editor-Version': 'vscode/1.132.0',
},
});
});
test('normalizes permissions into a dot-path managed setting', () => {
assert.deepStrictEqual(adaptManagedSettings({
permissions: { disableBypassPermissionsMode: 'disable' },
@@ -27,6 +45,23 @@ suite('adaptManagedSettings', () => {
});
});
test('parses the stable compatibility error and optional versions', () => {
assert.deepStrictEqual(parseManagedSettingsCompatibilityError({
error_code: 'client_update_required',
client_id: 'vscode',
client_version: '1.132.0',
minimum_client_version: '1.133.0',
}), {
errorCode: 'client_update_required',
clientVersion: '1.132.0',
minimumClientVersion: '1.133.0',
});
});
test('rejects an unrecognized compatibility error shape', () => {
assert.strictEqual(parseManagedSettingsCompatibilityError({ error_code: 'unexpected' }), undefined);
});
test('carries enabledPlugins as a canonical JSON string under a single key', () => {
const response: IManagedSettingsResponse = {
enabledPlugins: {
@@ -39,6 +39,8 @@ class DefaultAccountProvider implements IDefaultAccountProvider {
readonly managedSettingsFetchStatus: null = null;
readonly managedSettingsFetchedAt: null = null;
readonly managedSettingsRawResponse: unknown = null;
readonly managedSettingsCompatibilityError = null;
readonly onDidChangeManagedSettingsCompatibilityError = Event.None;
constructor(
readonly defaultAccount: IDefaultAccount,
@@ -45,6 +45,8 @@ class DefaultAccountProvider implements IDefaultAccountProvider {
readonly managedSettingsFetchStatus: null = null;
readonly managedSettingsFetchedAt: null = null;
readonly managedSettingsRawResponse: unknown = null;
readonly managedSettingsCompatibilityError = null;
readonly onDidChangeManagedSettingsCompatibilityError = Event.None;
constructor(
readonly defaultAccount: IDefaultAccount,
@@ -592,6 +592,8 @@ export function createEditorServices(disposables: DisposableStore, options?: Cre
managedSettingsFetchStatus: null,
managedSettingsFetchedAt: null,
managedSettingsRawResponse: null,
managedSettingsCompatibilityError: null,
onDidChangeManagedSettingsCompatibilityError: Event.None,
getDefaultAccount: async () => null,
getDefaultAccountAuthenticationProvider: () => ({ id: 'test', name: 'Test', scopes: [], enterprise: false }),
resolveGitHubUrl: (path: string) => `https://github.com/${path}`,