From 661f18fdeb7861d25e79df68b32af3a681eff7b2 Mon Sep 17 00:00:00 2001 From: joshspicer <23246594+joshspicer@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:11:24 -0700 Subject: [PATCH] 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> --- .../inlineCompletions/test/browser/utils.ts | 2 + .../standalone/browser/standaloneServices.ts | 2 + .../defaultAccount/common/defaultAccount.ts | 12 ++ src/vs/sessions/test/web.test.ts | 2 + .../browser/actions/developerActions.ts | 10 +- .../accounts/browser/defaultAccount.ts | 152 ++++++++++++----- .../accounts/browser/managedSettings.ts | 39 +++++ .../test/browser/defaultAccount.test.ts | 159 +++++++++++++++--- .../test/browser/managedSettings.test.ts | 37 +++- .../test/browser/accountPolicyService.test.ts | 2 + .../browser/multiplexPolicyService.test.ts | 2 + .../browser/componentFixtures/fixtureUtils.ts | 2 + 12 files changed, 347 insertions(+), 74 deletions(-) diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts index 504909c723b..58f545c9f4c 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts @@ -298,6 +298,8 @@ export async function withAsyncTestCodeEditorAndInlineCompletionsModel( managedSettingsFetchStatus: null, managedSettingsFetchedAt: null, managedSettingsRawResponse: null, + managedSettingsCompatibilityError: null, + onDidChangeManagedSettingsCompatibilityError: Event.None, getDefaultAccount: async () => null, setDefaultAccountProvider: () => { }, getDefaultAccountAuthenticationProvider: () => { return { id: 'mockProvider', name: 'Mock Provider', enterprise: false }; }, diff --git a/src/vs/editor/standalone/browser/standaloneServices.ts b/src/vs/editor/standalone/browser/standaloneServices.ts index d2af15f7841..ed9ff06a2e2 100644 --- a/src/vs/editor/standalone/browser/standaloneServices.ts +++ b/src/vs/editor/standalone/browser/standaloneServices.ts @@ -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 { return null; diff --git a/src/vs/platform/defaultAccount/common/defaultAccount.ts b/src/vs/platform/defaultAccount/common/defaultAccount.ts index e5f16fe242c..7b9e6369f32 100644 --- a/src/vs/platform/defaultAccount/common/defaultAccount.ts +++ b/src/vs/platform/defaultAccount/common/defaultAccount.ts @@ -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; @@ -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; 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; getDefaultAccount(): Promise; getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider; setDefaultAccountProvider(provider: IDefaultAccountProvider): void; diff --git a/src/vs/sessions/test/web.test.ts b/src/vs/sessions/test/web.test.ts index fae7579f6f8..b29f95d9ab4 100644 --- a/src/vs/sessions/test/web.test.ts +++ b/src/vs/sessions/test/web.test.ts @@ -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 { return MOCK_ACCOUNT; } getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider { return MOCK_ACCOUNT.authenticationProvider; } diff --git a/src/vs/workbench/browser/actions/developerActions.ts b/src/vs/workbench/browser/actions/developerActions.ts index dba89570f05..5d2c0cbd285 100644 --- a/src/vs/workbench/browser/actions/developerActions.ts +++ b/src/vs/workbench/browser/actions/developerActions.ts @@ -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'] ] ); diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index 1fe7cc8ace5..bed41d2382e 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -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()); readonly onDidChangeCopilotTokenInfo = this._onDidChangeCopilotTokenInfo.event; + private readonly _onDidChangeManagedSettingsCompatibilityError = this._register(new Emitter()); + 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 } + | { 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()); readonly onDidChangeDefaultAccount = this._onDidChangeDefaultAccount.event; @@ -289,6 +306,9 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun private readonly _onDidChangeCopilotTokenInfo = this._register(new Emitter()); readonly onDidChangeCopilotTokenInfo = this._onDidChangeCopilotTokenInfo.event; + private readonly _onDidChangeManagedSettingsCompatibilityError = this._register(new Emitter()); + readonly onDidChangeManagedSettingsCompatibilityError = this._onDidChangeManagedSettingsCompatibilityError.event; + private readonly accountStatusContext: IContextKey; private initialized = false; private readonly initPromise: Promise; @@ -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('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 | 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 | undefined; fetchedAt: number }> { + private async getManagedSettings(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined, options?: { forceRefresh?: boolean }): Promise<{ data: Partial | 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 | undefined> { + private async requestManagedSettings(sessions: AuthenticationSession[]): Promise { 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 { + 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, rateLimitBackoffActive: boolean): void { this._managedSettingsFetchStatus = status; this.telemetryService.publicLog2('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; - 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, requestTimeoutMs?: number, headers?: IHeaders): Promise; + private async request(url: string, type: 'POST', body: object, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, requestTimeoutMs?: number, headers?: IHeaders): Promise; + private async request(url: string, type: 'GET' | 'POST', body: object | undefined, sessions: AuthenticationSession[], token: CancellationToken, callSite: string, requestTimeoutMs?: number, headers?: IHeaders): Promise { // 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 diff --git a/src/vs/workbench/services/accounts/browser/managedSettings.ts b/src/vs/workbench/services/accounts/browser/managedSettings.ts index f36a506e687..7f7f1a0f42d 100644 --- a/src/vs/workbench/services/accounts/browser/managedSettings.ts +++ b/src/vs/workbench/services/accounts/browser/managedSettings.ts @@ -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): 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 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 e5149b82164..6bc777aed83 100644 --- a/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts +++ b/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts @@ -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, requestService: TestRequestService): Promise { + 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 { 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) { } + constructor(private readonly requestHandler: (options: IRequestOptions) => Promise) { } - request(): Promise { + request(options: IRequestOptions): Promise { this.requestCount++; - return this.requestHandler(); + this.requests.push(options); + return this.requestHandler(options); } async resolveProxy(): Promise { @@ -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))), }; } diff --git a/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts b/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts index 96254edef4f..d8a9da12790 100644 --- a/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts +++ b/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts @@ -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: { diff --git a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts index 0d767d9abfd..3339f5e5096 100644 --- a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts @@ -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, diff --git a/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts b/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts index 2ddc447a47d..b9723d49c57 100644 --- a/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts @@ -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, diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts index 6535e844ee3..f98595051ab 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts @@ -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}`,