From c37c4b99020a5e5b21634ebbb427f923cbdb097f Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:07:13 -0700 Subject: [PATCH 01/13] policy: add managed-settings freshness contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for making `forceRemoteSettingsRefresh` a real fail-closed startup gate (microsoft/vscode-internalbacklog#8825). Contract only — no behavior change, and nothing gates on freshness yet. Adds `managedSettingsFreshness.ts`, declaring the state machine shared by the fetch path, the policy gate and Policy Diagnostics so those consumers cannot drift: `NotRequired` / `Pending` / `Satisfied` / `Blocked`, the failure categories every inability-to-refresh maps to, and scoping by account + provider + endpoint so satisfaction is never transferable across accounts or GHE hosts. Replaces `shouldForceRemoteSettingsRefresh` with `resolveForceRemoteSettingsRefresh`, which resolves through `pickManagedSettings` instead of re-implementing precedence. Two fixes fall out: the file channel now participates (the old helper read only native MDM and server, silently ignoring managed-file delivery), and an explicit managed `false` is now distinguishable from an absent value, which a later change needs in order to know when the requirement may be cleared. The old helper had no production caller — it was left orphaned when 661f18fdeb7 reworked the managed-settings fetch — so this is inert. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../policy/common/copilotManagedSettings.ts | 37 +++- .../policy/common/managedSettingsFreshness.ts | 175 ++++++++++++++++++ .../common/copilotManagedSettings.test.ts | 32 ++-- .../common/managedSettingsFreshness.test.ts | 84 +++++++++ 4 files changed, 309 insertions(+), 19 deletions(-) create mode 100644 src/vs/platform/policy/common/managedSettingsFreshness.ts create mode 100644 src/vs/platform/policy/test/common/managedSettingsFreshness.test.ts diff --git a/src/vs/platform/policy/common/copilotManagedSettings.ts b/src/vs/platform/policy/common/copilotManagedSettings.ts index 561f8230710df..1cdf748a71cb8 100644 --- a/src/vs/platform/policy/common/copilotManagedSettings.ts +++ b/src/vs/platform/policy/common/copilotManagedSettings.ts @@ -151,15 +151,38 @@ export function managedSettingValue(key: string): (policyData: IPolicyData) => M } /** - * Resolves the startup refresh control with native MDM taking precedence over the cached server - * response. A malformed native value is treated as absent, matching the managed-settings schema. + * How the `forceRemoteSettingsRefresh` control resolved across the delivery channels. + * + * `effective` and `source` are reported separately because an explicit managed `false` is not the + * same as an absent value: an administrator who sets `false` at a higher-precedence channel is + * actively lifting the requirement, whereas an absent value simply leaves it unset. Consumers that + * persist the requirement need that distinction to know when they may clear it. + */ +export interface IForceRemoteSettingsRefreshResolution { + /** Whether a fresh managed-settings response is required before enabling agent functionality. */ + readonly effective: boolean; + /** Channel that supplied the winning value, or `'none'` when no channel supplies a usable one. */ + readonly source: ManagedSettingsSource; +} + +/** + * Resolve the fail-closed startup refresh control across every delivery channel. + * + * Resolution reuses {@link pickManagedSettings} rather than re-implementing precedence, so the + * control cannot drift from the ordering applied to every other managed setting — and so the file + * channel participates, which a native-plus-server-only resolver silently ignored. + * + * A value that is not a boolean is treated as absent (matching the managed-settings schema) and the + * next channel in precedence order is consulted, so a malformed high-precedence value cannot mask a + * well-formed lower-precedence one. */ -export function shouldForceRemoteSettingsRefresh(nativeMdm: ManagedSettingsData | undefined, server: ManagedSettingsData | undefined): boolean { - const nativeValue = nativeMdm?.[COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]; - if (typeof nativeValue === 'boolean') { - return nativeValue; +export function resolveForceRemoteSettingsRefresh(nativeMdm: ManagedSettingsData | undefined, server: ManagedSettingsData | undefined, file: ManagedSettingsData | undefined): IForceRemoteSettingsRefreshResolution { + const resolution = pickManagedSettings(nativeMdm, server, file).resolutions.get(COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY); + const contribution = resolution?.contributions.find(candidate => typeof candidate.value === 'boolean'); + if (!contribution) { + return { effective: false, source: 'none' }; } - return server?.[COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY] === true; + return { effective: contribution.value === true, source: contribution.channel }; } export const IManagedSettingsService = createDecorator('managedSettingsService'); diff --git a/src/vs/platform/policy/common/managedSettingsFreshness.ts b/src/vs/platform/policy/common/managedSettingsFreshness.ts new file mode 100644 index 0000000000000..532bfc2f03824 --- /dev/null +++ b/src/vs/platform/policy/common/managedSettingsFreshness.ts @@ -0,0 +1,175 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Vocabulary for the `forceRemoteSettingsRefresh` fail-closed startup gate. + * + * When an administrator makes that control effective, a *freshly fetched* managed-settings response + * is required before Copilot agent functionality may be enabled. Cached policy — including a cached + * "confirmed no policy" — deliberately cannot satisfy the requirement, so this vocabulary is kept + * separate from the ordinary managed-settings cache in `copilotManagedSettings.ts`. + * + * This module is intentionally pure: it declares the state machine shared by the fetch path, the + * policy gate and the diagnostics report so those consumers cannot drift apart. It performs no I/O + * and holds no mutable state; the authoritative fetch state lives with the default-account provider. + */ + +/** Lifecycle of the fail-closed freshness requirement. */ +export const enum ManagedSettingsFreshnessState { + /** + * The control is not effective, so ordinary fail-open delivery applies. This is the default and + * must remain behaviourally identical to having no freshness gate at all. + */ + NotRequired = 'notRequired', + + /** + * The control is effective and applicability is known, but no fresh response has been received + * yet. Gates like {@link Blocked}; the two differ only in remediation UX. Every `Pending` must + * reach {@link Satisfied} or {@link Blocked} within a bounded time so it cannot gate forever. + */ + Pending = 'pending', + + /** + * A fresh, successful server response was received for the current scope in this process. + * + * A freshly served "no policy configured" (HTTP 404) is a success: it confirms current server + * state. Only a *cached* confirmed-no-policy is disallowed, so treating a fresh 404 as a failure + * would permanently gate every organization that has no managed-settings file. + */ + Satisfied = 'satisfied', + + /** + * The control is effective but freshness could not be established. Every inability to refresh + * resolves here, so no failure mode silently degrades to fail-open. + */ + Blocked = 'blocked', +} + +/** + * Why freshness could not be established. Categories are distinguished only where a consumer must + * behave differently — remediation UX and diagnostics — not for reporting granularity alone. + */ +export const enum ManagedSettingsFreshnessFailure { + /** No managed-settings endpoint is configured, so no fetch can be attempted. */ + NoUrl = 'noUrl', + + /** + * No authenticated session is available, so no fetch can be attempted. Reached without issuing a + * request; the remediation is signing in, which is why authentication flows stay exempt from the + * gate. Without that exemption this state would be an unrecoverable lockout. + */ + NoToken = 'noToken', + + /** The request failed to produce a response: offline, DNS/TLS failure, or timeout. */ + Network = 'network', + + /** + * A shared rate-limit backoff is active, so the request was short-circuited before reaching the + * network. Distinct from {@link Network} because retrying is futile until the backoff elapses, + * and the UX must surface the remaining time rather than offer a retry that silently no-ops. + */ + RateLimited = 'rateLimited', + + /** The server returned a non-success status that is neither a 404 nor the update-required code. */ + HttpError = 'httpError', + + /** + * The response body could not be parsed as JSON. + * + * Scope note: this covers parse failure only. Deeper schema validation is deliberately *not* + * performed here — the Copilot runtime owns the managed-settings schema, and re-implementing that + * validation in VS Code would duplicate a runtime security decision. + */ + Malformed = 'malformed', + + /** + * The client is too old to enforce the effective managed settings (HTTP 466). Already fail-closed + * via the compatibility-error path; represented here so diagnostics report one freshness story. + */ + UpdateRequired = 'updateRequired', +} + +/** + * The scope a {@link ManagedSettingsFreshnessState.Satisfied} result belongs to. + * + * Managed settings are fetched per account against a specific endpoint, while native MDM is + * machine-scoped. Satisfaction must therefore never be a process-wide flag: a response fetched for + * one account or GitHub Enterprise host says nothing about another, and an unkeyed result would let + * an account switch or sign-out silently inherit someone else's satisfied gate. + */ +export interface IManagedSettingsFreshnessScope { + /** Account the managed-settings response was fetched for. */ + readonly accountId: string; + /** Authentication provider that supplied the session. */ + readonly authenticationProviderId: string; + /** Origin of the managed-settings endpoint, distinguishing github.com from a GHE host. */ + readonly endpointOrigin: string; +} + +/** Observable freshness state, including what diagnostics must report. */ +export interface IManagedSettingsFreshness { + readonly state: ManagedSettingsFreshnessState; + + /** Set exactly when {@link state} is {@link ManagedSettingsFreshnessState.Blocked}. */ + readonly failure?: ManagedSettingsFreshnessFailure; + + /** + * Channel that made the control effective, or `undefined` when it is not effective. Reported so + * an administrator can tell which delivery channel is responsible for a closed gate. + */ + readonly source?: string; + + /** Scope of a {@link ManagedSettingsFreshnessState.Satisfied} result. */ + readonly scope?: IManagedSettingsFreshnessScope; + + /** When a refresh was last attempted, for diagnostics. */ + readonly lastAttemptAt?: number; + + /** When freshness was last satisfied, for diagnostics. */ + readonly satisfiedAt?: number; + + /** Status code behind a {@link ManagedSettingsFreshnessFailure.HttpError}. */ + readonly httpStatus?: number; + + /** When an active {@link ManagedSettingsFreshnessFailure.RateLimited} backoff elapses. */ + readonly retryAfter?: number; +} + +/** The initial state: no freshness requirement observed. */ +export const MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED: IManagedSettingsFreshness = { state: ManagedSettingsFreshnessState.NotRequired }; + +/** + * Whether Copilot agent functionality must be withheld. + * + * Both {@link ManagedSettingsFreshnessState.Pending} and {@link ManagedSettingsFreshnessState.Blocked} + * gate: the contract is to withhold agent functionality *until* a fresh response arrives, so an + * unresolved refresh must never be treated as permission to proceed. + */ +export function isManagedSettingsFreshnessBlocking(freshness: IManagedSettingsFreshness): boolean { + return freshness.state === ManagedSettingsFreshnessState.Pending + || freshness.state === ManagedSettingsFreshnessState.Blocked; +} + +/** Whether two scopes refer to the same account, provider and endpoint. */ +export function isSameManagedSettingsFreshnessScope(a: IManagedSettingsFreshnessScope | undefined, b: IManagedSettingsFreshnessScope | undefined): boolean { + if (!a || !b) { + return false; + } + return a.accountId === b.accountId + && a.authenticationProviderId === b.authenticationProviderId + && a.endpointOrigin === b.endpointOrigin; +} + +/** + * Whether an existing freshness result still satisfies the gate for `scope`. + * + * Satisfaction is not transferable: a result for a different account, provider or endpoint must be + * re-established rather than inherited, which is what makes sign-out and account switching close the + * gate again instead of silently reusing the previous account's success. + */ +export function isManagedSettingsFreshnessSatisfiedFor(freshness: IManagedSettingsFreshness, scope: IManagedSettingsFreshnessScope): boolean { + return freshness.state === ManagedSettingsFreshnessState.Satisfied + && isSameManagedSettingsFreshnessScope(freshness.scope, scope); +} diff --git a/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts b/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts index 864fa4986268c..389ecfd17c0bd 100644 --- a/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts +++ b/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { IStringDictionary } from '../../../../base/common/collections.js'; import { IPolicyData } from '../../../../base/common/defaultAccount.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { collectManagedSettingsDefinitions, COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY, COPILOT_MODEL_KEY, COPILOT_TOP_LEVEL_MODEL_KEY, hasManagedSettingsDefinitions, managedModelValue, managedSettingValue, projectManagedSettings, pickManagedSettings, shouldForceRemoteSettingsRefresh } from '../../common/copilotManagedSettings.js'; +import { collectManagedSettingsDefinitions, COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY, COPILOT_MODEL_KEY, COPILOT_TOP_LEVEL_MODEL_KEY, hasManagedSettingsDefinitions, managedModelValue, managedSettingValue, projectManagedSettings, pickManagedSettings, resolveForceRemoteSettingsRefresh } from '../../common/copilotManagedSettings.js'; import { PolicyDefinition } from '../../common/policy.js'; suite('Copilot managed settings projection', () => { @@ -106,19 +106,27 @@ suite('Copilot managed settings projection', () => { assert.strictEqual(managedModelValue(), managedModelValue()); }); - test('forceRemoteSettingsRefresh uses native MDM over the cached server value', () => { + test('forceRemoteSettingsRefresh resolves across all channels and reports the winning source', () => { + const key = COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY; assert.deepStrictEqual({ - serverTrue: shouldForceRemoteSettingsRefresh(undefined, { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: true }), - nativeTrue: shouldForceRemoteSettingsRefresh({ [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: true }, { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: false }), - nativeFalse: shouldForceRemoteSettingsRefresh({ [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: false }, { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: true }), - malformedNative: shouldForceRemoteSettingsRefresh({ [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: 'true' }, { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: true }), - unset: shouldForceRemoteSettingsRefresh(undefined, undefined), + serverTrue: resolveForceRemoteSettingsRefresh(undefined, { [key]: true }, undefined), + nativeTrue: resolveForceRemoteSettingsRefresh({ [key]: true }, { [key]: false }, undefined), + nativeFalse: resolveForceRemoteSettingsRefresh({ [key]: false }, { [key]: true }, undefined), + malformedNative: resolveForceRemoteSettingsRefresh({ [key]: 'true' }, { [key]: true }, undefined), + fileTrue: resolveForceRemoteSettingsRefresh(undefined, undefined, { [key]: true }), + serverBeatsFile: resolveForceRemoteSettingsRefresh(undefined, { [key]: false }, { [key]: true }), + unset: resolveForceRemoteSettingsRefresh(undefined, undefined, undefined), }, { - serverTrue: true, - nativeTrue: true, - nativeFalse: false, - malformedNative: true, - unset: false, + serverTrue: { effective: true, source: 'server' }, + nativeTrue: { effective: true, source: 'nativeMdm' }, + // An explicit managed `false` lifts the requirement, and is distinguishable from unset. + nativeFalse: { effective: false, source: 'nativeMdm' }, + // A malformed value is treated as absent so it cannot mask a lower-precedence channel. + malformedNative: { effective: true, source: 'server' }, + // The file channel participates; a native/server-only resolver ignored it. + fileTrue: { effective: true, source: 'file' }, + serverBeatsFile: { effective: false, source: 'server' }, + unset: { effective: false, source: 'none' }, }); }); diff --git a/src/vs/platform/policy/test/common/managedSettingsFreshness.test.ts b/src/vs/platform/policy/test/common/managedSettingsFreshness.test.ts new file mode 100644 index 0000000000000..a94c8b1e3465e --- /dev/null +++ b/src/vs/platform/policy/test/common/managedSettingsFreshness.test.ts @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { + IManagedSettingsFreshness, + IManagedSettingsFreshnessScope, + isManagedSettingsFreshnessBlocking, + isManagedSettingsFreshnessSatisfiedFor, + isSameManagedSettingsFreshnessScope, + MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED, + ManagedSettingsFreshnessFailure, + ManagedSettingsFreshnessState, +} from '../../common/managedSettingsFreshness.js'; + +suite('Managed settings freshness', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const scope: IManagedSettingsFreshnessScope = { + accountId: 'account-1', + authenticationProviderId: 'github', + endpointOrigin: 'https://api.github.com', + }; + + const satisfied: IManagedSettingsFreshness = { state: ManagedSettingsFreshnessState.Satisfied, scope }; + + test('only an unresolved or failed refresh withholds agent functionality', () => { + assert.deepStrictEqual({ + notRequired: isManagedSettingsFreshnessBlocking(MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED), + pending: isManagedSettingsFreshnessBlocking({ state: ManagedSettingsFreshnessState.Pending }), + satisfied: isManagedSettingsFreshnessBlocking(satisfied), + blocked: isManagedSettingsFreshnessBlocking({ state: ManagedSettingsFreshnessState.Blocked, failure: ManagedSettingsFreshnessFailure.Network }), + }, { + notRequired: false, + // Pending gates: an unresolved refresh must never read as permission to proceed. + pending: true, + satisfied: false, + blocked: true, + }); + }); + + test('satisfaction is scoped to one account, provider and endpoint', () => { + assert.deepStrictEqual({ + sameScope: isManagedSettingsFreshnessSatisfiedFor(satisfied, { ...scope }), + otherAccount: isManagedSettingsFreshnessSatisfiedFor(satisfied, { ...scope, accountId: 'account-2' }), + otherProvider: isManagedSettingsFreshnessSatisfiedFor(satisfied, { ...scope, authenticationProviderId: 'github-enterprise' }), + otherEndpoint: isManagedSettingsFreshnessSatisfiedFor(satisfied, { ...scope, endpointOrigin: 'https://ghe.example.com' }), + unscopedSatisfied: isManagedSettingsFreshnessSatisfiedFor({ state: ManagedSettingsFreshnessState.Satisfied }, scope), + pendingNeverSatisfies: isManagedSettingsFreshnessSatisfiedFor({ state: ManagedSettingsFreshnessState.Pending, scope }, scope), + }, { + sameScope: true, + // A different account, provider or GHE host must re-establish freshness rather than + // inherit another scope's success. + otherAccount: false, + otherProvider: false, + otherEndpoint: false, + // A satisfied result without a scope is never transferable. + unscopedSatisfied: false, + pendingNeverSatisfies: false, + }); + }); + + test('scope comparison treats a missing scope as never matching', () => { + assert.deepStrictEqual({ + bothPresent: isSameManagedSettingsFreshnessScope(scope, { ...scope }), + leftMissing: isSameManagedSettingsFreshnessScope(undefined, scope), + rightMissing: isSameManagedSettingsFreshnessScope(scope, undefined), + bothMissing: isSameManagedSettingsFreshnessScope(undefined, undefined), + }, { + bothPresent: true, + leftMissing: false, + rightMissing: false, + bothMissing: false, + }); + }); + + test('the default state is not-required so the gate is inert until a control is observed', () => { + assert.deepStrictEqual(MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED, { state: ManagedSettingsFreshnessState.NotRequired }); + }); +}); From d7006da3972c47aee2a18561574544f558a857d3 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Fri, 21 Aug 2026 06:14:15 -0700 Subject: [PATCH 02/13] policy: enforce freshness invariants in the type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR feedback: `IManagedSettingsFreshness` was a bag of optional fields, so a consumer could construct `Blocked` with no failure, `Satisfied` with no scope, or attach `httpStatus`/`retryAfter` to states where they mean nothing — leaving the fetch, gate and diagnostics consumers free to drift despite the type. Models it as a discriminated union instead, so each active state requires the fields its contract defines. `Blocked` is itself a union keyed on the failure category, so a status code is required for an HTTP error, a backoff deadline for rate limiting, and neither is accepted elsewhere. `source` is now the shared `ManagedSettingsChannel` rather than `string`, and is required on the effective states, which also encodes that it is never `'none'` once a channel has supplied the control. Adds `@ts-expect-error` coverage for the three rejected shapes: the directives fail the build if any shape becomes constructible again. `isSameManagedSettingsFreshnessScope` is now a private helper with required arguments — the union guarantees a scope is present, so its undefined-tolerance was unreachable, and nothing outside this module used it. Also trims two over-long comments flagged in review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../policy/common/copilotManagedSettings.ts | 12 +--- .../policy/common/managedSettingsFreshness.ts | 66 +++++++++++-------- .../common/managedSettingsFreshness.test.ts | 44 ++++++------- 3 files changed, 63 insertions(+), 59 deletions(-) diff --git a/src/vs/platform/policy/common/copilotManagedSettings.ts b/src/vs/platform/policy/common/copilotManagedSettings.ts index 1cdf748a71cb8..beefa2893e85f 100644 --- a/src/vs/platform/policy/common/copilotManagedSettings.ts +++ b/src/vs/platform/policy/common/copilotManagedSettings.ts @@ -166,15 +166,9 @@ export interface IForceRemoteSettingsRefreshResolution { } /** - * Resolve the fail-closed startup refresh control across every delivery channel. - * - * Resolution reuses {@link pickManagedSettings} rather than re-implementing precedence, so the - * control cannot drift from the ordering applied to every other managed setting — and so the file - * channel participates, which a native-plus-server-only resolver silently ignored. - * - * A value that is not a boolean is treated as absent (matching the managed-settings schema) and the - * next channel in precedence order is consulted, so a malformed high-precedence value cannot mask a - * well-formed lower-precedence one. + * Resolve the fail-closed startup refresh control across every delivery channel, reusing + * {@link pickManagedSettings} precedence rather than re-implementing it. A non-boolean value is + * treated as absent, so a malformed high-precedence value cannot mask a well-formed lower one. */ export function resolveForceRemoteSettingsRefresh(nativeMdm: ManagedSettingsData | undefined, server: ManagedSettingsData | undefined, file: ManagedSettingsData | undefined): IForceRemoteSettingsRefreshResolution { const resolution = pickManagedSettings(nativeMdm, server, file).resolutions.get(COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY); diff --git a/src/vs/platform/policy/common/managedSettingsFreshness.ts b/src/vs/platform/policy/common/managedSettingsFreshness.ts index 532bfc2f03824..e11039421ee4e 100644 --- a/src/vs/platform/policy/common/managedSettingsFreshness.ts +++ b/src/vs/platform/policy/common/managedSettingsFreshness.ts @@ -16,6 +16,8 @@ * and holds no mutable state; the authoritative fetch state lives with the default-account provider. */ +import type { ManagedSettingsChannel } from './copilotManagedSettings.js'; + /** Lifecycle of the fail-closed freshness requirement. */ export const enum ManagedSettingsFreshnessState { /** @@ -108,34 +110,46 @@ export interface IManagedSettingsFreshnessScope { readonly endpointOrigin: string; } -/** Observable freshness state, including what diagnostics must report. */ -export interface IManagedSettingsFreshness { - readonly state: ManagedSettingsFreshnessState; - - /** Set exactly when {@link state} is {@link ManagedSettingsFreshnessState.Blocked}. */ - readonly failure?: ManagedSettingsFreshnessFailure; - +/** Common shape of the states reached only once the control is known to be effective. */ +interface IManagedSettingsFreshnessEffective { /** - * Channel that made the control effective, or `undefined` when it is not effective. Reported so - * an administrator can tell which delivery channel is responsible for a closed gate. + * Channel that made the control effective, so diagnostics can name the delivery channel + * responsible for a closed gate. Never `'none'`: these states are reached only when a channel + * supplies the control. */ - readonly source?: string; - - /** Scope of a {@link ManagedSettingsFreshnessState.Satisfied} result. */ - readonly scope?: IManagedSettingsFreshnessScope; - + readonly source: ManagedSettingsChannel; /** When a refresh was last attempted, for diagnostics. */ readonly lastAttemptAt?: number; +} - /** When freshness was last satisfied, for diagnostics. */ - readonly satisfiedAt?: number; - - /** Status code behind a {@link ManagedSettingsFreshnessFailure.HttpError}. */ - readonly httpStatus?: number; +/** + * A failed refresh, carrying exactly the detail its category defines: a status code for an HTTP + * error and a backoff deadline for rate limiting, neither of which is meaningful for the others. + */ +type ManagedSettingsFreshnessBlocked = IManagedSettingsFreshnessEffective + & { readonly state: ManagedSettingsFreshnessState.Blocked } + & ( + | { readonly failure: ManagedSettingsFreshnessFailure.HttpError; readonly httpStatus: number } + | { readonly failure: ManagedSettingsFreshnessFailure.RateLimited; readonly retryAfter: number } + | { readonly failure: Exclude } + ); - /** When an active {@link ManagedSettingsFreshnessFailure.RateLimited} backoff elapses. */ - readonly retryAfter?: number; -} +/** + * Observable freshness state, including what diagnostics must report. + * + * Modelled as a discriminated union so the state machine's invariants are enforced by the compiler + * rather than by convention: the fetch path cannot report a block without a cause, or a satisfied + * result without the scope that makes it non-transferable. + */ +export type IManagedSettingsFreshness = + | { readonly state: ManagedSettingsFreshnessState.NotRequired } + | (IManagedSettingsFreshnessEffective & { readonly state: ManagedSettingsFreshnessState.Pending }) + | (IManagedSettingsFreshnessEffective & { + readonly state: ManagedSettingsFreshnessState.Satisfied; + readonly scope: IManagedSettingsFreshnessScope; + readonly satisfiedAt: number; + }) + | ManagedSettingsFreshnessBlocked; /** The initial state: no freshness requirement observed. */ export const MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED: IManagedSettingsFreshness = { state: ManagedSettingsFreshnessState.NotRequired }; @@ -152,11 +166,7 @@ export function isManagedSettingsFreshnessBlocking(freshness: IManagedSettingsFr || freshness.state === ManagedSettingsFreshnessState.Blocked; } -/** Whether two scopes refer to the same account, provider and endpoint. */ -export function isSameManagedSettingsFreshnessScope(a: IManagedSettingsFreshnessScope | undefined, b: IManagedSettingsFreshnessScope | undefined): boolean { - if (!a || !b) { - return false; - } +function isSameScope(a: IManagedSettingsFreshnessScope, b: IManagedSettingsFreshnessScope): boolean { return a.accountId === b.accountId && a.authenticationProviderId === b.authenticationProviderId && a.endpointOrigin === b.endpointOrigin; @@ -171,5 +181,5 @@ export function isSameManagedSettingsFreshnessScope(a: IManagedSettingsFreshness */ export function isManagedSettingsFreshnessSatisfiedFor(freshness: IManagedSettingsFreshness, scope: IManagedSettingsFreshnessScope): boolean { return freshness.state === ManagedSettingsFreshnessState.Satisfied - && isSameManagedSettingsFreshnessScope(freshness.scope, scope); + && isSameScope(freshness.scope, scope); } diff --git a/src/vs/platform/policy/test/common/managedSettingsFreshness.test.ts b/src/vs/platform/policy/test/common/managedSettingsFreshness.test.ts index a94c8b1e3465e..45443705d8ecc 100644 --- a/src/vs/platform/policy/test/common/managedSettingsFreshness.test.ts +++ b/src/vs/platform/policy/test/common/managedSettingsFreshness.test.ts @@ -10,7 +10,6 @@ import { IManagedSettingsFreshnessScope, isManagedSettingsFreshnessBlocking, isManagedSettingsFreshnessSatisfiedFor, - isSameManagedSettingsFreshnessScope, MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED, ManagedSettingsFreshnessFailure, ManagedSettingsFreshnessState, @@ -26,14 +25,19 @@ suite('Managed settings freshness', () => { endpointOrigin: 'https://api.github.com', }; - const satisfied: IManagedSettingsFreshness = { state: ManagedSettingsFreshnessState.Satisfied, scope }; + const satisfied: IManagedSettingsFreshness = { + state: ManagedSettingsFreshnessState.Satisfied, + source: 'nativeMdm', + scope, + satisfiedAt: 1, + }; test('only an unresolved or failed refresh withholds agent functionality', () => { assert.deepStrictEqual({ notRequired: isManagedSettingsFreshnessBlocking(MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED), - pending: isManagedSettingsFreshnessBlocking({ state: ManagedSettingsFreshnessState.Pending }), + pending: isManagedSettingsFreshnessBlocking({ state: ManagedSettingsFreshnessState.Pending, source: 'server' }), satisfied: isManagedSettingsFreshnessBlocking(satisfied), - blocked: isManagedSettingsFreshnessBlocking({ state: ManagedSettingsFreshnessState.Blocked, failure: ManagedSettingsFreshnessFailure.Network }), + blocked: isManagedSettingsFreshnessBlocking({ state: ManagedSettingsFreshnessState.Blocked, source: 'server', failure: ManagedSettingsFreshnessFailure.Network }), }, { notRequired: false, // Pending gates: an unresolved refresh must never read as permission to proceed. @@ -49,33 +53,29 @@ suite('Managed settings freshness', () => { otherAccount: isManagedSettingsFreshnessSatisfiedFor(satisfied, { ...scope, accountId: 'account-2' }), otherProvider: isManagedSettingsFreshnessSatisfiedFor(satisfied, { ...scope, authenticationProviderId: 'github-enterprise' }), otherEndpoint: isManagedSettingsFreshnessSatisfiedFor(satisfied, { ...scope, endpointOrigin: 'https://ghe.example.com' }), - unscopedSatisfied: isManagedSettingsFreshnessSatisfiedFor({ state: ManagedSettingsFreshnessState.Satisfied }, scope), - pendingNeverSatisfies: isManagedSettingsFreshnessSatisfiedFor({ state: ManagedSettingsFreshnessState.Pending, scope }, scope), + pendingNeverSatisfies: isManagedSettingsFreshnessSatisfiedFor({ state: ManagedSettingsFreshnessState.Pending, source: 'nativeMdm' }, scope), }, { sameScope: true, - // A different account, provider or GHE host must re-establish freshness rather than - // inherit another scope's success. otherAccount: false, otherProvider: false, otherEndpoint: false, - // A satisfied result without a scope is never transferable. - unscopedSatisfied: false, pendingNeverSatisfies: false, }); }); - test('scope comparison treats a missing scope as never matching', () => { - assert.deepStrictEqual({ - bothPresent: isSameManagedSettingsFreshnessScope(scope, { ...scope }), - leftMissing: isSameManagedSettingsFreshnessScope(undefined, scope), - rightMissing: isSameManagedSettingsFreshnessScope(scope, undefined), - bothMissing: isSameManagedSettingsFreshnessScope(undefined, undefined), - }, { - bothPresent: true, - leftMissing: false, - rightMissing: false, - bothMissing: false, - }); + test('the state machine rejects states that would let consumers drift', () => { + // @ts-expect-error a block must carry the cause that drives remediation and diagnostics + const blockedWithoutFailure: IManagedSettingsFreshness = { state: ManagedSettingsFreshnessState.Blocked, source: 'server' }; + // @ts-expect-error satisfaction without a scope would be transferable across accounts + const satisfiedWithoutScope: IManagedSettingsFreshness = { state: ManagedSettingsFreshnessState.Satisfied, source: 'server', satisfiedAt: 1 }; + // @ts-expect-error a retry deadline is meaningful only while rate limited + const retryAfterOnNetworkFailure: IManagedSettingsFreshness = { state: ManagedSettingsFreshnessState.Blocked, source: 'server', failure: ManagedSettingsFreshnessFailure.Network, retryAfter: 1 }; + + assert.deepStrictEqual([blockedWithoutFailure, satisfiedWithoutScope, retryAfterOnNetworkFailure].map(freshness => freshness.state), [ + ManagedSettingsFreshnessState.Blocked, + ManagedSettingsFreshnessState.Satisfied, + ManagedSettingsFreshnessState.Blocked, + ]); }); test('the default state is not-required so the gate is inert until a control is observed', () => { From 75cfaffccfbc695eae674e225a972a103f8c628e Mon Sep 17 00:00:00 2001 From: Josh Spicer Date: Mon, 24 Aug 2026 18:03:53 +0000 Subject: [PATCH 03/13] chat: fail closed on forced managed settings refresh Require a fresh managed-settings response before enabling AI features when forceRemoteSettingsRefresh is effective. Preserve recovery through sign-in and retry, expose diagnostics, and cover native, server, file, failure, scope, and sign-out behavior. Related to microsoft/vscode-internalbacklog#8825. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../github-managed-settings.md | 11 +- .../inlineCompletions/test/browser/utils.ts | 4 +- .../standalone/browser/standaloneServices.ts | 4 +- .../defaultAccount/common/defaultAccount.ts | 7 + .../policy/common/copilotManagedSettings.ts | 12 +- .../policy/common/fileManagedSettingsIpc.ts | 16 +- .../common/fileManagedSettingsService.ts | 27 +- .../common/fileManagedSettingsService.test.ts | 15 +- .../browser/policyBlocked.contribution.ts | 6 + .../browser/sessionsPolicyBlocked.ts | 36 ++ .../browser/sessionsPolicyBlocked.fixture.ts | 13 + src/vs/sessions/test/web.test.ts | 4 +- .../browser/actions/developerActions.ts | 13 + src/vs/workbench/browser/web.main.ts | 7 +- .../accounts/browser/defaultAccount.ts | 377 ++++++++++++++++-- .../test/browser/defaultAccount.test.ts | 351 +++++++++++++++- .../browser/accountPolicyGateContribution.ts | 64 ++- .../policies/common/accountPolicyService.ts | 23 +- .../accountPolicyGateContribution.test.ts | 51 ++- .../test/browser/accountPolicyService.test.ts | 31 +- .../browser/multiplexPolicyService.test.ts | 4 +- .../browser/componentFixtures/fixtureUtils.ts | 4 +- 22 files changed, 985 insertions(+), 95 deletions(-) diff --git a/.github/skills/policy-and-managed-settings/github-managed-settings.md b/.github/skills/policy-and-managed-settings/github-managed-settings.md index 55020bc82892e..16dfb499d8c5b 100644 --- a/.github/skills/policy-and-managed-settings/github-managed-settings.md +++ b/.github/skills/policy-and-managed-settings/github-managed-settings.md @@ -372,10 +372,13 @@ constant, configuration policy, or policy-data export. `forceRemoteSettingsRefresh` is not a user configuration setting. It controls whether the server-managed-settings cache may satisfy startup, so VS Code preserves it in the cached raw server -bag and always includes it in the native MDM watch schema. `DefaultAccountProvider` resolves an -explicit native MDM boolean ahead of the cached server value; when the result is `true`, it bypasses -an otherwise-fresh server cache for the first fetch for that account in the current process. The -cache remains available as the normal fetch-failure fallback. +bag and always includes it in the native MDM watch schema. `DefaultAccountProvider` resolves the +control across native MDM, cached server, and managed-file delivery before using the server cache. +When the result is `true`, only a fresh successful server response for the current account, +authentication provider, and endpoint satisfies the requirement. A failed refresh may retain cached +restrictions and the flag itself, but the Account Policy gate keeps AI features disabled until a +retry succeeds. Authentication remains available so users can recover from missing or expired +credentials. Reference tests: - `src/vs/platform/policy/test/common/copilotManagedSettings.test.ts` diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts index 58f545c9f4c0d..bdd250adb7ac3 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts @@ -14,7 +14,7 @@ import { buildHistoryFromTasks, renderSwimlanes } from '../../../../../base/test import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; import { createTraceLogger, ITraceLogEntry, ITraceLogger } from '../../../../../base/test/common/virtualScheduling/index.js'; import { IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; -import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IDefaultAccountService, MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; @@ -300,6 +300,8 @@ export async function withAsyncTestCodeEditorAndInlineCompletionsModel( managedSettingsRawResponse: null, managedSettingsCompatibilityError: null, onDidChangeManagedSettingsCompatibilityError: Event.None, + managedSettingsFreshness: MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED, + onDidChangeManagedSettingsFreshness: 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 fcfbab8c0241d..8148cb9313511 100644 --- a/src/vs/editor/standalone/browser/standaloneServices.ts +++ b/src/vs/editor/standalone/browser/standaloneServices.ts @@ -45,7 +45,7 @@ import { ContextMenuService } from '../../../platform/contextview/browser/contex import { IContextMenuService, IContextViewDelegate, IContextViewService, IOpenContextView } from '../../../platform/contextview/browser/contextView.js'; import { ContextViewService } from '../../../platform/contextview/browser/contextViewService.js'; import { IDataChannelService, NullDataChannelService } from '../../../platform/dataChannel/common/dataChannel.js'; -import { IDefaultAccountService } from '../../../platform/defaultAccount/common/defaultAccount.js'; +import { IDefaultAccountService, MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED } from '../../../platform/defaultAccount/common/defaultAccount.js'; import { IConfirmation, IConfirmationResult, IDialogService, IInputResult, IPrompt, IPromptBaseButton, IPromptResult, IPromptResultWithCancel, IPromptWithCustomCancel, IPromptWithDefaultCancel } from '../../../platform/dialogs/common/dialogs.js'; import { ExtensionKind, IEnvironmentService, IExtensionHostDebugParams } from '../../../platform/environment/common/environment.js'; import { SyncDescriptor } from '../../../platform/instantiation/common/descriptors.js'; @@ -1136,6 +1136,8 @@ class StandaloneDefaultAccountService implements IDefaultAccountService { readonly managedSettingsRawResponse: unknown = null; readonly managedSettingsCompatibilityError = null; readonly onDidChangeManagedSettingsCompatibilityError = Event.None; + readonly managedSettingsFreshness = MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED; + readonly onDidChangeManagedSettingsFreshness = 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 7b9e6369f32e5..5a5062a3ed65c 100644 --- a/src/vs/platform/defaultAccount/common/defaultAccount.ts +++ b/src/vs/platform/defaultAccount/common/defaultAccount.ts @@ -6,6 +6,7 @@ import { ICopilotTokenInfo, IDefaultAccount, IDefaultAccountAuthenticationProvider, IPolicyData } from '../../../base/common/defaultAccount.js'; import { Event } from '../../../base/common/event.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { IManagedSettingsFreshness, MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED } from '../../policy/common/managedSettingsFreshness.js'; /** * Well-known GitHub URL paths used with {@link IDefaultAccountService.resolveGitHubUrl}. @@ -49,6 +50,8 @@ export interface IDefaultAccountProvider { readonly managedSettingsRawResponse: unknown; readonly managedSettingsCompatibilityError: IManagedSettingsCompatibilityError | null; readonly onDidChangeManagedSettingsCompatibilityError: Event; + readonly managedSettingsFreshness: IManagedSettingsFreshness; + readonly onDidChangeManagedSettingsFreshness: Event; getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider; /** @@ -82,6 +85,8 @@ export interface IDefaultAccountService { readonly managedSettingsRawResponse: unknown; readonly managedSettingsCompatibilityError: IManagedSettingsCompatibilityError | null; readonly onDidChangeManagedSettingsCompatibilityError: Event; + readonly managedSettingsFreshness: IManagedSettingsFreshness; + readonly onDidChangeManagedSettingsFreshness: Event; getDefaultAccount(): Promise; getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider; setDefaultAccountProvider(provider: IDefaultAccountProvider): void; @@ -98,3 +103,5 @@ export interface IDefaultAccountService { */ resolveGitHubUrl(path: string): string; } + +export { MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED }; diff --git a/src/vs/platform/policy/common/copilotManagedSettings.ts b/src/vs/platform/policy/common/copilotManagedSettings.ts index beefa2893e85f..9efea32a9f0b5 100644 --- a/src/vs/platform/policy/common/copilotManagedSettings.ts +++ b/src/vs/platform/policy/common/copilotManagedSettings.ts @@ -158,12 +158,9 @@ export function managedSettingValue(key: string): (policyData: IPolicyData) => M * actively lifting the requirement, whereas an absent value simply leaves it unset. Consumers that * persist the requirement need that distinction to know when they may clear it. */ -export interface IForceRemoteSettingsRefreshResolution { - /** Whether a fresh managed-settings response is required before enabling agent functionality. */ - readonly effective: boolean; - /** Channel that supplied the winning value, or `'none'` when no channel supplies a usable one. */ - readonly source: ManagedSettingsSource; -} +export type IForceRemoteSettingsRefreshResolution = + | { readonly effective: true; readonly source: ManagedSettingsChannel } + | { readonly effective: false; readonly source: ManagedSettingsSource }; /** * Resolve the fail-closed startup refresh control across every delivery channel, reusing @@ -698,6 +695,7 @@ export interface IFileManagedSettingsService { readonly managedSettings: ManagedSettingsData; readonly onDidChangeRawManagedSettings: Event; readonly onDidChangeManagedSettings: Event; + initialize(): Promise; } export class NullFileManagedSettingsService implements IFileManagedSettingsService { @@ -706,4 +704,6 @@ export class NullFileManagedSettingsService implements IFileManagedSettingsServi readonly managedSettings: ManagedSettingsData = {}; readonly onDidChangeRawManagedSettings = Event.None; readonly onDidChangeManagedSettings = Event.None; + + async initialize(): Promise { return this.managedSettings; } } diff --git a/src/vs/platform/policy/common/fileManagedSettingsIpc.ts b/src/vs/platform/policy/common/fileManagedSettingsIpc.ts index f982a21f6de20..9bfa9adca1604 100644 --- a/src/vs/platform/policy/common/fileManagedSettingsIpc.ts +++ b/src/vs/platform/policy/common/fileManagedSettingsIpc.ts @@ -27,8 +27,8 @@ export class FileManagedSettingsChannel implements IServerChannel { call(_: unknown, command: string): Promise { switch (command) { - case 'getRawManagedSettings': return Promise.resolve(this.service.rawManagedSettings as T); - case 'getManagedSettings': return Promise.resolve(this.service.managedSettings as T); + case 'getRawManagedSettings': return this.service.initialize().then(() => this.service.rawManagedSettings as T); + case 'getManagedSettings': return this.service.initialize().then(() => this.service.managedSettings as T); } throw new Error(`Call not found: ${command}`); @@ -57,20 +57,28 @@ export class FileManagedSettingsChannelClient extends Disposable implements IFil private readonly _onDidChangeManagedSettings = this._register(new Emitter()); readonly onDidChangeManagedSettings = this._onDidChangeManagedSettings.event; + private readonly initialSnapshot: Promise; + constructor(channel: IChannel) { super(); this._register(channel.listen('onDidChangeRawManagedSettings')(managedSettings => this.updateRawManagedSettings(managedSettings, true))); this._register(channel.listen('onDidChangeManagedSettings')(managedSettings => this.updateManagedSettings(managedSettings, true))); - channel.call('getRawManagedSettings').then(managedSettings => { + const rawSnapshot = channel.call('getRawManagedSettings').then(managedSettings => { if (!this.hasReceivedRawManagedSettings) { this.updateRawManagedSettings(managedSettings, true); } }); - channel.call('getManagedSettings').then(managedSettings => { + const managedSnapshot = channel.call('getManagedSettings').then(managedSettings => { if (!this.hasReceivedManagedSettings) { this.updateManagedSettings(managedSettings, true); } }); + this.initialSnapshot = Promise.all([rawSnapshot, managedSnapshot]).then(() => undefined); + } + + async initialize(): Promise { + await this.initialSnapshot; + return this._managedSettings; } private updateRawManagedSettings(managedSettings: RawManagedSettingsData, fireEvent: boolean): void { diff --git a/src/vs/platform/policy/common/fileManagedSettingsService.ts b/src/vs/platform/policy/common/fileManagedSettingsService.ts index 848b2d2e51faa..9eea98b21f3c4 100644 --- a/src/vs/platform/policy/common/fileManagedSettingsService.ts +++ b/src/vs/platform/policy/common/fileManagedSettingsService.ts @@ -3,7 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ThrottledDelayer } from '../../../base/common/async.js'; +import { Barrier, ThrottledDelayer } from '../../../base/common/async.js'; +import { isCancellationError } from '../../../base/common/errors.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { equals } from '../../../base/common/objects.js'; @@ -39,6 +40,7 @@ export class FileManagedSettingsService extends Disposable implements IFileManag readonly onDidChangeManagedSettings = this._onDidChangeManagedSettings.event; private readonly throttledDelayer = this._register(new ThrottledDelayer(500)); + private readonly initialized = new Barrier(); constructor( private readonly file: URI, @@ -49,12 +51,25 @@ export class FileManagedSettingsService extends Disposable implements IFileManag const onDidChangeFile = Event.filter(fileService.onDidFilesChange, e => e.affects(file)); this._register(fileService.watch(file)); - this._register(onDidChangeFile(() => this.throttledDelayer.trigger(() => this.refresh()))); + this._register(onDidChangeFile(() => this.scheduleRefresh())); - // Initial read — routed through the same delayer (with no delay) so it is serialized - // against change-triggered refreshes and can't be clobbered by a racing read. Non-blocking; - // IPC clients handle eventual data arrival. - this.throttledDelayer.trigger(() => this.refresh(), 0); + this.scheduleRefresh(0); + } + + async initialize(): Promise { + await this.initialized.wait(); + return this._managedSettings; + } + + private scheduleRefresh(delay?: number): void { + void this.throttledDelayer.trigger(() => this.refresh(), delay).then(() => { + this.initialized.open(); + }, error => { + if (!isCancellationError(error)) { + this.logService.error('[FileManagedSettingsService] Failed to schedule managed-settings refresh', error); + this.initialized.open(); + } + }); } private async refresh(): Promise { diff --git a/src/vs/platform/policy/test/common/fileManagedSettingsService.test.ts b/src/vs/platform/policy/test/common/fileManagedSettingsService.test.ts index b21aba0b8330c..66e80eeb02913 100644 --- a/src/vs/platform/policy/test/common/fileManagedSettingsService.test.ts +++ b/src/vs/platform/policy/test/common/fileManagedSettingsService.test.ts @@ -181,18 +181,7 @@ suite('FileManagedSettingsService', () => { }))); const service = disposables.add(new FileManagedSettingsService(managedSettingsFile, fileService, logService)); - - // Wait for the async refresh to complete - await new Promise(resolve => { - if (Object.keys(service.managedSettings).length > 0) { - resolve(); - } else { - const listener = disposables.add(service.onDidChangeManagedSettings(() => { - listener.dispose(); - resolve(); - })); - } - }); + await service.initialize(); assert.deepStrictEqual(service.managedSettings, { 'permissions.disableBypassPermissionsMode': 'disable', @@ -362,7 +351,7 @@ suite('FileManagedSettingsChannelClient', () => { channel.fire({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'disable' }); channel.resolveInitialRawSnapshot({ permissions: { deny: ['Shell(echo *)'] } }); channel.resolveInitialSnapshot({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'enable' }); - await Promise.all([channel.initialRawSnapshot, channel.initialSnapshot]); + await client.initialize(); assert.deepStrictEqual({ raw: client.rawManagedSettings, normalized: client.managedSettings }, { raw: { permissions: { allow: ['Shell(echo *)'] } }, diff --git a/src/vs/sessions/contrib/policyBlocked/browser/policyBlocked.contribution.ts b/src/vs/sessions/contrib/policyBlocked/browser/policyBlocked.contribution.ts index b08c349fb411b..48c99beca620a 100644 --- a/src/vs/sessions/contrib/policyBlocked/browser/policyBlocked.contribution.ts +++ b/src/vs/sessions/contrib/policyBlocked/browser/policyBlocked.contribution.ts @@ -12,6 +12,7 @@ import { IDefaultAccountService } from '../../../../platform/defaultAccount/comm import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js'; import { ISessionsBlockedOverlayOptions, SessionsBlockedReason, SessionsPolicyBlockedOverlay } from './sessionsPolicyBlocked.js'; import { AccountPolicyGateState, AccountPolicyGateUnsatisfiedReason, IAccountPolicyGateService } from '../../../../workbench/services/policies/common/accountPolicyService.js'; +import { ManagedSettingsFreshnessState } from '../../../../platform/policy/common/managedSettingsFreshness.js'; export class SessionsPolicyBlockedContribution extends Disposable implements IWorkbenchContribution { @@ -66,6 +67,11 @@ export class SessionsPolicyBlockedContribution extends Disposable implements IWo if (gateInfo.reason === AccountPolicyGateUnsatisfiedReason.PolicyNotResolved) { this.showOverlay({ reason: SessionsBlockedReason.Loading }); + } else if (gateInfo.reason === AccountPolicyGateUnsatisfiedReason.ManagedSettingsRefresh) { + const freshness = gateInfo.managedSettingsFreshness; + this.showOverlay(freshness?.state === ManagedSettingsFreshnessState.Blocked + ? { reason: SessionsBlockedReason.ManagedSettingsRefresh, freshness } + : { reason: SessionsBlockedReason.Loading }); } else { const accountName = this.defaultAccountService.currentDefaultAccount?.accountName; this.showOverlay({ diff --git a/src/vs/sessions/contrib/policyBlocked/browser/sessionsPolicyBlocked.ts b/src/vs/sessions/contrib/policyBlocked/browser/sessionsPolicyBlocked.ts index 4d3dbaafc2f9e..5737ef906d40e 100644 --- a/src/vs/sessions/contrib/policyBlocked/browser/sessionsPolicyBlocked.ts +++ b/src/vs/sessions/contrib/policyBlocked/browser/sessionsPolicyBlocked.ts @@ -14,6 +14,8 @@ import { IProductService } from '../../../../platform/product/common/productServ import { URI } from '../../../../base/common/uri.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IManagedSettingsFreshness, ManagedSettingsFreshnessFailure, ManagedSettingsFreshnessState } from '../../../../platform/policy/common/managedSettingsFreshness.js'; export const enum SessionsBlockedReason { AgentDisabled = 'agentDisabled', @@ -21,12 +23,14 @@ export const enum SessionsBlockedReason { Loading = 'loading', /** Signed in but not in an approved org — must switch accounts. */ AccountPolicyGate = 'accountPolicyGate', + ManagedSettingsRefresh = 'managedSettingsRefresh', } export interface ISessionsBlockedOverlayOptions { readonly reason: SessionsBlockedReason; readonly approvedOrganizations?: readonly string[]; readonly accountName?: string; + readonly freshness?: Extract; } /** @@ -42,6 +46,7 @@ export class SessionsPolicyBlockedOverlay extends Disposable { @ICommandService private readonly commandService: ICommandService, @IOpenerService private readonly openerService: IOpenerService, @IProductService private readonly productService: IProductService, + @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, ) { super(); @@ -86,6 +91,9 @@ export class SessionsPolicyBlockedOverlay extends Disposable { case SessionsBlockedReason.AccountPolicyGate: this._renderAccountPolicyGate(card, options); break; + case SessionsBlockedReason.ManagedSettingsRefresh: + this._renderManagedSettingsRefresh(card, options.freshness); + break; } } @@ -164,6 +172,34 @@ export class SessionsPolicyBlockedOverlay extends Disposable { })); } + private _renderManagedSettingsRefresh(card: HTMLElement, freshness: ISessionsBlockedOverlayOptions['freshness']): void { + this.overlay.setAttribute('aria-label', localize('managedSettingsRefresh.aria', "Managed settings refresh required")); + append(card, $('h2', undefined, localize('managedSettingsRefresh.title', "Managed Settings Unavailable"))); + + const message = freshness?.failure === ManagedSettingsFreshnessFailure.NoToken + ? localize('managedSettingsRefresh.noToken', "Sign in so {0} can refresh your organization's managed settings before starting an agent.", this.productService.nameShort) + : freshness?.failure === ManagedSettingsFreshnessFailure.RateLimited + ? localize('managedSettingsRefresh.rateLimited', "{0} is waiting to retry your organization's managed settings service. Agents will remain unavailable until the refresh succeeds.", this.productService.nameShort) + : localize('managedSettingsRefresh.failed', "{0} could not refresh your organization's managed settings. Agents will remain unavailable until the refresh succeeds.", this.productService.nameShort); + append(card, $('p', undefined, message)); + + if (freshness?.failure === ManagedSettingsFreshnessFailure.NoToken) { + const signInButton = this._register(new Button(card, { ...defaultButtonStyles })); + signInButton.label = localize('managedSettingsRefresh.signIn', "Sign In"); + this._register(signInButton.onDidClick(() => this.commandService.executeCommand('workbench.action.agenticSignIn'))); + } else if (freshness?.failure !== ManagedSettingsFreshnessFailure.RateLimited + && freshness?.failure !== ManagedSettingsFreshnessFailure.NoUrl + && freshness?.failure !== ManagedSettingsFreshnessFailure.UpdateRequired) { + const retryButton = this._register(new Button(card, { ...defaultButtonStyles })); + retryButton.label = localize('managedSettingsRefresh.retry', "Retry"); + this._register(retryButton.onDidClick(() => this.defaultAccountService.refresh({ forceRefresh: true }))); + } + + const openVSCodeButton = this._register(new Button(card, { ...defaultButtonStyles, secondary: true })); + openVSCodeButton.label = localize('managedSettingsRefresh.openVSCode', "Open VS Code"); + this._register(openVSCodeButton.onDidClick(() => this._openVSCode())); + } + private _openVSCode(): void { const scheme = this.productService.parentPolicyConfig?.urlProtocol ?? this.productService.urlProtocol; this.openerService.open(URI.from({ scheme, query: 'windowId=_blank' }), { openExternal: true }); diff --git a/src/vs/sessions/contrib/policyBlocked/test/browser/sessionsPolicyBlocked.fixture.ts b/src/vs/sessions/contrib/policyBlocked/test/browser/sessionsPolicyBlocked.fixture.ts index 944a54cebcc06..1beabee60aa54 100644 --- a/src/vs/sessions/contrib/policyBlocked/test/browser/sessionsPolicyBlocked.fixture.ts +++ b/src/vs/sessions/contrib/policyBlocked/test/browser/sessionsPolicyBlocked.fixture.ts @@ -5,6 +5,7 @@ import { mock } from '../../../../../base/test/common/mock.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { ManagedSettingsFreshnessFailure, ManagedSettingsFreshnessState } from '../../../../../platform/policy/common/managedSettingsFreshness.js'; import { IWorkbenchLayoutService } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; import { ISessionsBlockedOverlayOptions, SessionsBlockedReason, SessionsPolicyBlockedOverlay } from '../../browser/sessionsPolicyBlocked.js'; @@ -51,4 +52,16 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { reason: SessionsBlockedReason.AccountPolicyGate, }), }), + ManagedSettingsUnavailable: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => createOverlay(ctx, { + reason: SessionsBlockedReason.ManagedSettingsRefresh, + freshness: { + state: ManagedSettingsFreshnessState.Blocked, + source: 'server', + failure: ManagedSettingsFreshnessFailure.Network, + lastAttemptAt: Date.now(), + }, + }), + }), }); diff --git a/src/vs/sessions/test/web.test.ts b/src/vs/sessions/test/web.test.ts index b29f95d9ab428..62c6b48b379b6 100644 --- a/src/vs/sessions/test/web.test.ts +++ b/src/vs/sessions/test/web.test.ts @@ -12,7 +12,7 @@ import { Emitter, Event } from '../../base/common/event.js'; import { CancellationToken } from '../../base/common/cancellation.js'; import { IObservable, observableValue } from '../../base/common/observable.js'; import { ChatEntitlement, IChatEntitlementService, IChatSentiment } from '../../workbench/services/chat/common/chatEntitlementService.js'; -import { IDefaultAccountService } from '../../platform/defaultAccount/common/defaultAccount.js'; +import { IDefaultAccountService, MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED } from '../../platform/defaultAccount/common/defaultAccount.js'; import { IDefaultAccount, IDefaultAccountAuthenticationProvider, ICopilotTokenInfo, IPolicyData } from '../../base/common/defaultAccount.js'; import { IChatAgentService, IChatAgentData, IChatAgentImplementation } from '../../workbench/contrib/chat/common/participants/chatAgents.js'; import { ChatAgentLocation, ChatModeKind } from '../../workbench/contrib/chat/common/constants.js'; @@ -135,6 +135,8 @@ class MockDefaultAccountService implements IDefaultAccountService { readonly managedSettingsRawResponse: unknown = null; readonly managedSettingsCompatibilityError = null; readonly onDidChangeManagedSettingsCompatibilityError = Event.None; + readonly managedSettingsFreshness = MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED; + readonly onDidChangeManagedSettingsFreshness = 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 f394728f063ce..61089dd31d4d0 100644 --- a/src/vs/workbench/browser/actions/developerActions.ts +++ b/src/vs/workbench/browser/actions/developerActions.ts @@ -1072,12 +1072,25 @@ class PolicyDiagnosticsAction extends Action2 { const fetchedAt = defaultAccountService.managedSettingsFetchedAt; const clientIdentity = appendManagedSettingsClientIdentity('https://api.github.com/copilot_internal/managed_settings', productService); const compatibilityError = defaultAccountService.managedSettingsCompatibilityError; + const freshness = defaultAccountService.managedSettingsFreshness; + const freshnessFailure = freshness.state === 'blocked' + ? freshness.failure === 'httpError' + ? `${freshness.failure} (${freshness.httpStatus})` + : freshness.failure === 'rateLimited' + ? `${freshness.failure} (retry after ${new Date(freshness.retryAfter).toLocaleString()})` + : freshness.failure + : 'none'; content += '#### GitHub Server API\n\n'; content += markdownTable( ['Property', 'Value'], [ ['Endpoint', '/copilot_internal/managed_settings'], ['Last fetch', fetchStatus === null ? 'never' : `${fetchStatus}${fetchedAt ? ` at ${new Date(fetchedAt).toLocaleString()}` : ''}`], + ['Freshness', freshness.state], + ['Freshness source', freshness.state === 'notRequired' ? 'none' : freshness.source], + ['Last freshness attempt', freshness.state !== 'notRequired' && freshness.lastAttemptAt ? new Date(freshness.lastAttemptAt).toLocaleString() : 'never'], + ['Freshness failure', freshnessFailure], + ['Satisfied scope', freshness.state === 'satisfied' ? `${freshness.scope.authenticationProviderId} at ${freshness.scope.endpointOrigin}` : 'none'], ['Client identity', new URL(clientIdentity).search.replace(/^\?/, '')], ['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/browser/web.main.ts b/src/vs/workbench/browser/web.main.ts index c7f154e0459b4..a2f0958154ede 100644 --- a/src/vs/workbench/browser/web.main.ts +++ b/src/vs/workbench/browser/web.main.ts @@ -69,7 +69,7 @@ import { DelayedLogChannel } from '../services/output/common/delayedLogChannel.j import { dirname, joinPath } from '../../base/common/resources.js'; import { IUserDataProfile, IUserDataProfilesService } from '../../platform/userDataProfile/common/userDataProfile.js'; import { IPolicyService } from '../../platform/policy/common/policy.js'; -import { IManagedSettingsService, INativeManagedSettingsService, NullNativeManagedSettingsService } from '../../platform/policy/common/copilotManagedSettings.js'; +import { IFileManagedSettingsService, IManagedSettingsService, INativeManagedSettingsService, NullFileManagedSettingsService, NullNativeManagedSettingsService } from '../../platform/policy/common/copilotManagedSettings.js'; import { IRemoteExplorerService } from '../services/remote/common/remoteExplorerService.js'; import { DisposableTunnel, TunnelProtocol } from '../../platform/tunnel/common/tunnel.js'; import { ILabelService } from '../../platform/label/common/label.js'; @@ -368,7 +368,10 @@ export class BrowserMain extends Disposable { serviceCollection.set(IDefaultAccountService, defaultAccountService); // Policies - serviceCollection.set(INativeManagedSettingsService, new NullNativeManagedSettingsService()); + const nativeManagedSettings = new NullNativeManagedSettingsService(); + const fileManagedSettings = new NullFileManagedSettingsService(); + serviceCollection.set(INativeManagedSettingsService, nativeManagedSettings); + serviceCollection.set(IFileManagedSettingsService, fileManagedSettings); const policyService = new AccountPolicyService(logService, defaultAccountService); serviceCollection.set(IPolicyService, policyService); serviceCollection.set(IAccountPolicyGateService, policyService); diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index 73c4245b8d6f8..f700321aa7bbb 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -23,6 +23,8 @@ import { IContextKey, IContextKeyService, RawContextKey } from '../../../../plat 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 { IFileManagedSettingsService, INativeManagedSettingsService, ManagedSettingsData, resolveForceRemoteSettingsRefresh } from '../../../../platform/policy/common/copilotManagedSettings.js'; +import { IManagedSettingsFreshness, IManagedSettingsFreshnessScope, isManagedSettingsFreshnessBlocking, isManagedSettingsFreshnessSatisfiedFor, MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED, ManagedSettingsFreshnessFailure, ManagedSettingsFreshnessState } from '../../../../platform/policy/common/managedSettingsFreshness.js'; import { IProductService } from '../../../../platform/product/common/productService.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'; @@ -126,6 +128,7 @@ export class DefaultAccountService extends Disposable implements IDefaultAccount 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; } + get managedSettingsFreshness(): IManagedSettingsFreshness { return this.defaultAccountProvider?.managedSettingsFreshness ?? MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED; } private readonly initBarrier = new Barrier(); @@ -141,6 +144,9 @@ export class DefaultAccountService extends Disposable implements IDefaultAccount private readonly _onDidChangeManagedSettingsCompatibilityError = this._register(new Emitter()); readonly onDidChangeManagedSettingsCompatibilityError = this._onDidChangeManagedSettingsCompatibilityError.event; + private readonly _onDidChangeManagedSettingsFreshness = this._register(new Emitter()); + readonly onDidChangeManagedSettingsFreshness = this._onDidChangeManagedSettingsFreshness.event; + private readonly defaultAccountConfig: IDefaultAccountConfig; private defaultAccountProvider: IDefaultAccountProvider | null = null; @@ -173,12 +179,16 @@ export class DefaultAccountService extends Disposable implements IDefaultAccount this.defaultAccountProvider = provider; this._register(provider.onDidChangeManagedSettingsCompatibilityError(error => this._onDidChangeManagedSettingsCompatibilityError.fire(error))); + this._register(provider.onDidChangeManagedSettingsFreshness(freshness => this._onDidChangeManagedSettingsFreshness.fire(freshness))); if (this.defaultAccountProvider.policyData) { this._onDidChangePolicyData.fire(this.defaultAccountProvider.policyData); } if (this.defaultAccountProvider.managedSettingsCompatibilityError) { this._onDidChangeManagedSettingsCompatibilityError.fire(this.defaultAccountProvider.managedSettingsCompatibilityError); } + if (this.defaultAccountProvider.managedSettingsFreshness.state !== ManagedSettingsFreshnessState.NotRequired) { + this._onDidChangeManagedSettingsFreshness.fire(this.defaultAccountProvider.managedSettingsFreshness); + } provider.refresh().then(account => { this.defaultAccount = account; }).finally(() => { @@ -231,6 +241,7 @@ interface IAccountPolicyData { readonly tokenEntitlementsFetchedAt?: number; readonly mcpRegistryDataFetchedAt?: number; readonly managedSettingsFetchedAt?: number; + readonly managedSettingsScope?: IManagedSettingsFreshnessScope; readonly managedSettingsCompatibilityError?: IManagedSettingsCompatibilityError; } @@ -250,7 +261,15 @@ type ManagedSettingsRequestResult = | { readonly kind: 'success'; readonly data: Partial } | { readonly kind: 'noSettings' } | { readonly kind: 'updateRequired'; readonly error: IManagedSettingsCompatibilityError } - | { readonly kind: 'unavailable' }; + | { readonly kind: 'network' } + | { readonly kind: 'rateLimited'; readonly retryAfter: number } + | { readonly kind: 'httpError'; readonly status: number } + | { readonly kind: 'malformed' }; + +interface IManagedSettingsSources { + readonly nativeMdm: ManagedSettingsData; + readonly file: ManagedSettingsData; +} type DefaultAccountStatusTelemetry = { status: string; @@ -297,6 +316,9 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun private _managedSettingsCompatibilityError: IManagedSettingsCompatibilityError | null = null; get managedSettingsCompatibilityError(): IManagedSettingsCompatibilityError | null { return this._managedSettingsCompatibilityError; } + private _managedSettingsFreshness: IManagedSettingsFreshness = MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED; + get managedSettingsFreshness(): IManagedSettingsFreshness { return this._managedSettingsFreshness; } + private readonly _onDidChangeDefaultAccount = this._register(new Emitter()); readonly onDidChangeDefaultAccount = this._onDidChangeDefaultAccount.event; @@ -309,6 +331,9 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun private readonly _onDidChangeManagedSettingsCompatibilityError = this._register(new Emitter()); readonly onDidChangeManagedSettingsCompatibilityError = this._onDidChangeManagedSettingsCompatibilityError.event; + private readonly _onDidChangeManagedSettingsFreshness = this._register(new Emitter()); + readonly onDidChangeManagedSettingsFreshness = this._onDidChangeManagedSettingsFreshness.event; + private readonly accountStatusContext: IContextKey; private initialized = false; private readonly initPromise: Promise; @@ -331,6 +356,8 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun @IStorageService private readonly storageService: IStorageService, @IHostService private readonly hostService: IHostService, @ICommandService private readonly commandService: ICommandService, + @INativeManagedSettingsService private readonly nativeManagedSettingsService: INativeManagedSettingsService, + @IFileManagedSettingsService private readonly fileManagedSettingsService: IFileManagedSettingsService, ) { super(); this.accountStatusContext = CONTEXT_DEFAULT_ACCOUNT_STATE.bindTo(contextKeyService); @@ -338,6 +365,13 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun this._policyData = cachedAccountData?.accountPolicyData ?? null; this._copilotTokenInfo = cachedAccountData?.copilotTokenInfo ?? null; this._managedSettingsCompatibilityError = cachedAccountData?.accountPolicyData.managedSettingsCompatibilityError ?? null; + this.updateManagedSettingsFreshnessRequirement( + this.nativeManagedSettingsService.managedSettings, + this.getCachedServerManagedSettings(this.getDefaultAccountAuthenticationProvider()), + this.fileManagedSettingsService.managedSettings + ); + this._register(this.nativeManagedSettingsService.onDidChangeManagedSettings(() => this.onManagedSettingsSourceChanged())); + this._register(this.fileManagedSettingsService.onDidChangeManagedSettings(() => this.onManagedSettingsSourceChanged())); this.initPromise = this.init() .finally(() => { this.telemetryService.publicLog2('defaultaccount:status', { status: this.defaultAccount ? 'available' : 'unavailable', initial: true }); @@ -530,19 +564,44 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun this.scheduleAccountDataPoll(); } catch (error) { this.logService.error('[DefaultAccount] Error while updating default account', getErrorMessage(error)); + if (this._managedSettingsFreshness.state === ManagedSettingsFreshnessState.Pending) { + this.setManagedSettingsFreshness({ + state: ManagedSettingsFreshnessState.Blocked, + source: this._managedSettingsFreshness.source, + lastAttemptAt: this._managedSettingsFreshness.lastAttemptAt, + failure: ManagedSettingsFreshnessFailure.Network, + }); + } } } private async fetchDefaultAccount(options?: { forceRefresh?: boolean }): Promise { const defaultAccountProvider = this.getDefaultAccountAuthenticationProvider(); this.logService.debug('[DefaultAccount] Default account provider ID:', defaultAccountProvider.id); + const managedSettingsSources = await this.initializeManagedSettingsSources(); + const refreshRequirement = resolveForceRemoteSettingsRefresh( + managedSettingsSources.nativeMdm, + this.getCachedServerManagedSettings(defaultAccountProvider), + managedSettingsSources.file + ); + if (refreshRequirement.effective) { + if (this._managedSettingsFreshness.state !== ManagedSettingsFreshnessState.Satisfied) { + this.setManagedSettingsFreshness({ + state: ManagedSettingsFreshnessState.Pending, + source: refreshRequirement.source, + }); + } + } else { + this.setManagedSettingsFreshness(MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED); + } if (!this.isAccountProviderAvailable(defaultAccountProvider)) { this.logService.info(`[DefaultAccount] Authentication provider is not available.`, defaultAccountProvider); + this.blockManagedSettingsFreshnessWithoutToken(refreshRequirement); return null; } - return await this.getDefaultAccountForAuthenticationProvider(defaultAccountProvider, options); + return await this.getDefaultAccountForAuthenticationProvider(defaultAccountProvider, managedSettingsSources, refreshRequirement, options); } private isAccountProviderAvailable(accountProvider: IDefaultAccountAuthenticationProvider): boolean { @@ -566,7 +625,18 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun this.logService.debug('[DefaultAccount] Account status set to Available'); } else { this._defaultAccount = null; - this.setPolicyData(null); + const refreshRequirement = resolveForceRemoteSettingsRefresh( + this.nativeManagedSettingsService.managedSettings, + this.getCachedServerManagedSettings(this.getDefaultAccountAuthenticationProvider()), + this.fileManagedSettingsService.managedSettings + ); + this.blockManagedSettingsFreshnessWithoutToken(refreshRequirement); + const retainedPolicyData = this._managedSettingsFreshness.state !== ManagedSettingsFreshnessState.NotRequired + && isManagedSettingsFreshnessBlocking(this._managedSettingsFreshness) + && this._managedSettingsFreshness.source === 'server' + ? this._policyData + : null; + this.setPolicyData(retainedPolicyData); this.setManagedSettingsCompatibilityError(null); this.setCopilotTokenInfo(null); this._onDidChangeDefaultAccount.fire(null); @@ -593,6 +663,70 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun this._onDidChangeManagedSettingsCompatibilityError.fire(error); } + private setManagedSettingsFreshness(freshness: IManagedSettingsFreshness): void { + if (equals(this._managedSettingsFreshness, freshness)) { + return; + } + this._managedSettingsFreshness = freshness; + this._onDidChangeManagedSettingsFreshness.fire(freshness); + } + + private updateManagedSettingsFreshnessRequirement(nativeMdm: ManagedSettingsData, server: ManagedSettingsData | undefined, file: ManagedSettingsData): void { + const requirement = resolveForceRemoteSettingsRefresh(nativeMdm, server, file); + this.setManagedSettingsFreshness(requirement.effective + ? { state: ManagedSettingsFreshnessState.Pending, source: requirement.source } + : MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED); + } + + private onManagedSettingsSourceChanged(): void { + this.updateManagedSettingsFreshnessRequirement( + this.nativeManagedSettingsService.managedSettings, + this.getCachedServerManagedSettings(this.getDefaultAccountAuthenticationProvider()), + this.fileManagedSettingsService.managedSettings + ); + if (this.initialized) { + void this.updateDefaultAccount({ forceRefresh: true }); + } + } + + private async initializeManagedSettingsSources(): Promise { + let nativeMdm = this.nativeManagedSettingsService.managedSettings; + try { + nativeMdm = await this.nativeManagedSettingsService.initialize(); + } catch (error) { + this.logService.warn('[DefaultAccount] Failed to initialize native managed settings before resolving forceRemoteSettingsRefresh; using available values', getErrorMessage(error)); + } + + let file = this.fileManagedSettingsService.managedSettings; + try { + file = await this.fileManagedSettingsService.initialize(); + } catch (error) { + this.logService.warn('[DefaultAccount] Failed to initialize file managed settings before resolving forceRemoteSettingsRefresh; using available values', getErrorMessage(error)); + } + return { nativeMdm, file }; + } + + private blockManagedSettingsFreshnessWithoutToken(requirement: ReturnType): void { + if (requirement.effective) { + this.setManagedSettingsFreshness({ + state: ManagedSettingsFreshnessState.Blocked, + source: requirement.source, + failure: ManagedSettingsFreshnessFailure.NoToken, + }); + } + } + + private getCachedServerManagedSettings(authenticationProvider: IDefaultAccountAuthenticationProvider): ManagedSettingsData | undefined { + const scope = this._policyData?.managedSettingsScope; + const managedSettingsUrl = this.getManagedSettingsUrl(); + if (scope && (!managedSettingsUrl + || scope.authenticationProviderId !== authenticationProvider.id + || scope.endpointOrigin !== this.createManagedSettingsFreshnessScope(scope.accountId, authenticationProvider.id, managedSettingsUrl).endpointOrigin)) { + return undefined; + } + return this._policyData?.policyData.managedSettings; + } + private setCopilotTokenInfo(copilotTokenInfo: ICopilotTokenInfo | null): void { if (equals(this._copilotTokenInfo, copilotTokenInfo)) { return; @@ -619,7 +753,11 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun if (!this._defaultAccount) { return; } - this.accountDataPollScheduler.schedule(ACCOUNT_DATA_POLL_INTERVAL_MS); + const delay = this._managedSettingsFreshness.state === ManagedSettingsFreshnessState.Blocked + && this._managedSettingsFreshness.failure === ManagedSettingsFreshnessFailure.RateLimited + ? Math.max(0, this._managedSettingsFreshness.retryAfter - Date.now()) + : ACCOUNT_DATA_POLL_INTERVAL_MS; + this.accountDataPollScheduler.schedule(delay); } private extractFromToken(token: string): Map { @@ -634,39 +772,70 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun return result; } - private async getDefaultAccountForAuthenticationProvider(authenticationProvider: IDefaultAccountAuthenticationProvider, options?: { forceRefresh?: boolean }): Promise { + private async getDefaultAccountForAuthenticationProvider( + authenticationProvider: IDefaultAccountAuthenticationProvider, + managedSettingsSources: IManagedSettingsSources, + refreshRequirement: ReturnType, + options?: { forceRefresh?: boolean } + ): Promise { try { this.logService.debug('[DefaultAccount] Getting Default Account from authenticated sessions for provider:', authenticationProvider.id); const sessions = await this.findMatchingProviderSession(authenticationProvider.id, this.defaultAccountConfig.authenticationProvider.scopes); if (!sessions?.length) { this.logService.debug('[DefaultAccount] No matching session found for provider:', authenticationProvider.id); + this.blockManagedSettingsFreshnessWithoutToken(refreshRequirement); return null; } - return this.getDefaultAccountFromAuthenticatedSessions(authenticationProvider, sessions, options); + return this.getDefaultAccountFromAuthenticatedSessions(authenticationProvider, sessions, options, managedSettingsSources); } catch (error) { this.logService.error('[DefaultAccount] Failed to get default account for provider:', authenticationProvider.id, getErrorMessage(error)); + this.blockManagedSettingsFreshnessWithoutToken(refreshRequirement); return null; } } - private async getDefaultAccountFromAuthenticatedSessions(authenticationProvider: IDefaultAccountAuthenticationProvider, sessions: AuthenticationSession[], options?: { forceRefresh?: boolean }): Promise { + private async getDefaultAccountFromAuthenticatedSessions( + authenticationProvider: IDefaultAccountAuthenticationProvider, + sessions: AuthenticationSession[], + options?: { forceRefresh?: boolean }, + managedSettingsSources?: IManagedSettingsSources + ): Promise { try { const accountId = sessions[0].account.id; const accountPolicyData = this._policyData?.accountId === accountId ? this._policyData : undefined; + const sources = managedSettingsSources ?? await this.initializeManagedSettingsSources(); + const requirement = resolveForceRemoteSettingsRefresh( + sources.nativeMdm, + accountPolicyData?.policyData.managedSettings, + sources.file + ); + const managedSettingsUrl = this.getManagedSettingsUrl(); + const scope = managedSettingsUrl + ? this.createManagedSettingsFreshnessScope(accountId, authenticationProvider.id, managedSettingsUrl) + : undefined; + if (!requirement.effective) { + this.setManagedSettingsFreshness(MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED); + } else if (!scope || !isManagedSettingsFreshnessSatisfiedFor(this._managedSettingsFreshness, scope) || options?.forceRefresh) { + this.setManagedSettingsFreshness({ + state: ManagedSettingsFreshnessState.Pending, + source: requirement.source, + }); + } const entitlementsResult = await this.getEntitlements(sessions, accountPolicyData, options); const entitlementsData = entitlementsResult?.data; const entitlementsFetchedAt = entitlementsResult?.fetchedAt; - const [tokenEntitlementsResult, managedSettingsResult] = entitlementsData?.chat_enabled - ? await Promise.all([ - this.getTokenEntitlements(sessions, accountPolicyData, options), - this.getManagedSettings(sessions, accountPolicyData, options), - ]) - : [undefined, undefined]; + const [tokenEntitlementsResult, managedSettingsResult] = await Promise.all([ + entitlementsData?.chat_enabled ? this.getTokenEntitlements(sessions, accountPolicyData, options) : undefined, + entitlementsData?.chat_enabled || requirement.effective + ? this.getManagedSettings(sessions, accountPolicyData, options, authenticationProvider, sources, requirement) + : undefined, + ]); const tokenEntitlementsFetchedAt: number | undefined = tokenEntitlementsResult?.fetchedAt; const managedSettingsFetchedAt: number | undefined = managedSettingsResult?.fetchedAt; + const managedSettingsScope = managedSettingsResult?.scope ?? accountPolicyData?.managedSettingsScope; const managedSettingsCompatibilityError = managedSettingsResult ? managedSettingsResult.compatibilityError : this._managedSettingsCompatibilityError; @@ -712,6 +881,7 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun tokenEntitlementsFetchedAt, mcpRegistryDataFetchedAt, managedSettingsFetchedAt, + managedSettingsScope, managedSettingsCompatibilityError: managedSettingsCompatibilityError ?? undefined, } : null; @@ -723,6 +893,14 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun }; } catch (error) { this.logService.error('[DefaultAccount] Failed to create default account for provider:', authenticationProvider.id, getErrorMessage(error)); + if (this._managedSettingsFreshness.state === ManagedSettingsFreshnessState.Pending) { + this.setManagedSettingsFreshness({ + state: ManagedSettingsFreshnessState.Blocked, + source: this._managedSettingsFreshness.source, + lastAttemptAt: this._managedSettingsFreshness.lastAttemptAt, + failure: ManagedSettingsFreshnessFailure.Network, + }); + } return null; } } @@ -915,8 +1093,21 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun } } - private async getManagedSettings(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined, options?: { forceRefresh?: boolean }): Promise<{ data: Partial | undefined; fetchedAt: number | undefined; compatibilityError: IManagedSettingsCompatibilityError | null }> { + private async getManagedSettings( + sessions: AuthenticationSession[], + accountPolicyData: IAccountPolicyData | undefined, + options?: { forceRefresh?: boolean }, + authenticationProvider = this.getDefaultAccountAuthenticationProvider(), + managedSettingsSources?: IManagedSettingsSources, + refreshRequirement?: ReturnType + ): Promise<{ data: Partial | undefined; fetchedAt: number | undefined; scope: IManagedSettingsFreshnessScope | undefined; compatibilityError: IManagedSettingsCompatibilityError | null }> { const accountId = sessions[0].account.id; + const sources = managedSettingsSources ?? await this.initializeManagedSettingsSources(); + const requirement = refreshRequirement ?? resolveForceRemoteSettingsRefresh( + sources.nativeMdm, + accountPolicyData?.policyData.managedSettings, + sources.file + ); const cachedManagedSettings = accountPolicyData?.managedSettingsFetchedAt !== undefined && !this.isDataStale(accountPolicyData.managedSettingsFetchedAt) ? { data: { @@ -925,42 +1116,152 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun fetchedAt: accountPolicyData.managedSettingsFetchedAt, } : undefined; - const hasFetchedThisProcess = this.managedSettingsFetchAttemptedAccounts.has(accountId); - if (!options?.forceRefresh && cachedManagedSettings && hasFetchedThisProcess) { + const managedSettingsUrl = this.getManagedSettingsUrl(); + if (!managedSettingsUrl) { + this.logService.debug('[DefaultAccount] No managed settings URL configured; skipping enterprise policy fetch'); + this._managedSettingsFetchStatus = 'no-url'; + if (requirement.effective) { + this.setManagedSettingsFreshness({ + state: ManagedSettingsFreshnessState.Blocked, + source: requirement.source, + failure: ManagedSettingsFreshnessFailure.NoUrl, + }); + } + const retained = requirement.effective + ? { data: { managedSettings: accountPolicyData?.policyData.managedSettings }, fetchedAt: accountPolicyData?.managedSettingsFetchedAt } + : cachedManagedSettings; + return { + data: retained?.data, + fetchedAt: retained?.fetchedAt, + scope: accountPolicyData?.managedSettingsScope, + compatibilityError: this._managedSettingsCompatibilityError, + }; + } + + const scope = this.createManagedSettingsFreshnessScope(accountId, authenticationProvider.id, managedSettingsUrl); + const fetchScopeKey = `${scope.authenticationProviderId}\n${scope.accountId}\n${scope.endpointOrigin}`; + const hasFetchedThisProcess = this.managedSettingsFetchAttemptedAccounts.has(fetchScopeKey); + const freshnessSatisfied = requirement.effective && isManagedSettingsFreshnessSatisfiedFor(this._managedSettingsFreshness, scope); + if (!options?.forceRefresh && cachedManagedSettings && ((hasFetchedThisProcess && !requirement.effective) || freshnessSatisfied)) { this.logService.debug('[DefaultAccount] Using last fetched managed settings data'); - return { ...cachedManagedSettings, compatibilityError: this._managedSettingsCompatibilityError }; + return { ...cachedManagedSettings, scope, compatibilityError: this._managedSettingsCompatibilityError }; } - this.managedSettingsFetchAttemptedAccounts.add(accountId); - const result = await this.requestManagedSettings(sessions); - const fetchedAt = Date.now(); + const lastAttemptAt = Date.now(); + if (requirement.effective) { + this.setManagedSettingsFreshness({ + state: ManagedSettingsFreshnessState.Pending, + source: requirement.source, + lastAttemptAt, + }); + } + this.managedSettingsFetchAttemptedAccounts.add(fetchScopeKey); + const result = await this.requestManagedSettings(sessions, managedSettingsUrl); switch (result.kind) { - case 'success': - return { data: result.data, fetchedAt, compatibilityError: null }; - case 'noSettings': - return { data: { managedSettings: undefined }, fetchedAt, compatibilityError: null }; + case 'success': { + const fetchedAt = Date.now(); + this.resolveManagedSettingsFreshnessAfterSuccess(result.data.managedSettings, scope, lastAttemptAt, fetchedAt); + return { data: result.data, fetchedAt, scope, compatibilityError: null }; + } + case 'noSettings': { + const fetchedAt = Date.now(); + this.resolveManagedSettingsFreshnessAfterSuccess(undefined, scope, lastAttemptAt, fetchedAt); + return { data: { managedSettings: undefined }, fetchedAt, scope, compatibilityError: null }; + } case 'updateRequired': - return { data: { managedSettings: undefined }, fetchedAt, compatibilityError: result.error }; - case 'unavailable': { + if (requirement.effective) { + this.setManagedSettingsFreshness({ + state: ManagedSettingsFreshnessState.Blocked, + source: requirement.source, + lastAttemptAt, + failure: ManagedSettingsFreshnessFailure.UpdateRequired, + }); + } + return { + data: requirement.effective ? { managedSettings: accountPolicyData?.policyData.managedSettings } : { managedSettings: undefined }, + fetchedAt: requirement.effective ? accountPolicyData?.managedSettingsFetchedAt : Date.now(), + scope, + compatibilityError: result.error, + }; + case 'network': + case 'rateLimited': + case 'httpError': + case 'malformed': { + if (requirement.effective) { + this.setManagedSettingsFreshness(this.toBlockedManagedSettingsFreshness(requirement.source, result, lastAttemptAt)); + return { + data: { managedSettings: accountPolicyData?.policyData.managedSettings }, + fetchedAt: accountPolicyData?.managedSettingsFetchedAt, + scope, + compatibilityError: this._managedSettingsCompatibilityError, + }; + } // A failed fetch must not extend the life of the cached response: carry the cache's timestamp for expiry const retained = this._managedSettingsCompatibilityError ? undefined : cachedManagedSettings; return { data: { managedSettings: retained?.data.managedSettings }, fetchedAt: retained?.fetchedAt, + scope, compatibilityError: this._managedSettingsCompatibilityError, }; } } } - 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 { kind: 'unavailable' }; + private createManagedSettingsFreshnessScope(accountId: string, authenticationProviderId: string, managedSettingsUrl: string): IManagedSettingsFreshnessScope { + let endpointOrigin = managedSettingsUrl; + try { + endpointOrigin = new URL(managedSettingsUrl).origin; + } catch { + // Preserve a stable scope for a malformed product endpoint; the request will report the failure. } + return { + accountId, + authenticationProviderId, + endpointOrigin, + }; + } + private resolveManagedSettingsFreshnessAfterSuccess( + server: ManagedSettingsData | undefined, + scope: IManagedSettingsFreshnessScope, + lastAttemptAt: number, + satisfiedAt: number + ): void { + const freshRequirement = resolveForceRemoteSettingsRefresh( + this.nativeManagedSettingsService.managedSettings, + server, + this.fileManagedSettingsService.managedSettings + ); + this.setManagedSettingsFreshness(freshRequirement.effective + ? { + state: ManagedSettingsFreshnessState.Satisfied, + source: freshRequirement.source, + scope, + lastAttemptAt, + satisfiedAt, + } + : MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED); + } + + private toBlockedManagedSettingsFreshness( + source: Exclude['source'], 'none'>, + result: Extract, + lastAttemptAt: number + ): IManagedSettingsFreshness { + switch (result.kind) { + case 'network': + return { state: ManagedSettingsFreshnessState.Blocked, source, lastAttemptAt, failure: ManagedSettingsFreshnessFailure.Network }; + case 'rateLimited': + return { state: ManagedSettingsFreshnessState.Blocked, source, lastAttemptAt, failure: ManagedSettingsFreshnessFailure.RateLimited, retryAfter: result.retryAfter }; + case 'httpError': + return { state: ManagedSettingsFreshnessState.Blocked, source, lastAttemptAt, failure: ManagedSettingsFreshnessFailure.HttpError, httpStatus: result.status }; + case 'malformed': + return { state: ManagedSettingsFreshnessState.Blocked, source, lastAttemptAt, failure: ManagedSettingsFreshnessFailure.Malformed }; + } + } + + private async requestManagedSettings(sessions: AuthenticationSession[], managedSettingsUrl: string): Promise { const requestUrl = appendManagedSettingsClientIdentity(managedSettingsUrl, this.productService); this.logService.debug('[DefaultAccount] Fetching managed settings from:', requestUrl); const rateLimitBackoffActive = Date.now() < this._rateLimitBackoffUntil; @@ -968,7 +1269,9 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun 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 { kind: 'unavailable' }; + return rateLimitBackoffActive + ? { kind: 'rateLimited', retryAfter: this._rateLimitBackoffUntil } + : { kind: 'network' }; } const status = response.res.statusCode ?? 0; @@ -982,11 +1285,15 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun this.reportManagedSettingsOutcome(status, rateLimitBackoffActive); return { kind: 'updateRequired', error }; } + if (this.isRateLimited(response)) { + this.reportManagedSettingsOutcome(status, rateLimitBackoffActive); + return { kind: 'rateLimited', retryAfter: this._rateLimitBackoffUntil }; + } if (!isSuccess(response)) { this.logService.warn(`[DefaultAccount] Managed settings fetch returned non-success status ${status}; falling back to local-only policy`); this.reportManagedSettingsOutcome(status, rateLimitBackoffActive); - return { kind: 'unavailable' }; + return { kind: 'httpError', status }; } try { @@ -1007,7 +1314,7 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun } catch (error) { this.logService.error('[DefaultAccount] Failed to parse managed settings response', getErrorMessage(error)); this.reportManagedSettingsOutcome('parse-error', rateLimitBackoffActive); - return { kind: 'unavailable' }; + return { kind: 'malformed' }; } } 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 47ec9d0f4d55e..dc6c6594797d7 100644 --- a/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts +++ b/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { timeout } from '../../../../../base/common/async.js'; import { bufferToStream, VSBuffer } from '../../../../../base/common/buffer.js'; import { Event } from '../../../../../base/common/event.js'; import { IRequestContext, IRequestOptions } from '../../../../../base/parts/request/common/request.js'; @@ -15,7 +16,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 } from '../../../../../platform/policy/common/copilotManagedSettings.js'; +import { COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY, IFileManagedSettingsService, INativeManagedSettingsService, ManagedSettingsData } from '../../../../../platform/policy/common/copilotManagedSettings.js'; +import { IManagedSettingsFreshness, ManagedSettingsFreshnessFailure, ManagedSettingsFreshnessState } from '../../../../../platform/policy/common/managedSettingsFreshness.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'; @@ -53,17 +55,19 @@ suite('DefaultAccountProvider managed settings', () => { assert.deepStrictEqual({ requestCount: requestService.requestCount, requestQuery: new URL(requestService.requests[0].url!).search, + disableCache: requestService.requests[0].disableCache, first: first.data, second: second.data, }, { requestCount: 1, requestQuery: '?client_id=vscode&client_version=1.132.0&copilot_runtime_version=0.0.344', + disableCache: true, first: cachedPolicy.policyData, second: cachedPolicy.policyData, }); }); - test('404 clears cached server managed settings', async () => { + test('fresh 404 clears a cached server requirement', async () => { const requestService = new TestRequestService(async () => jsonResponse({}, 404)); const provider = await createProvider(requestService); const cachedPolicy = createCachedPolicy(true); @@ -75,11 +79,32 @@ suite('DefaultAccountProvider managed settings', () => { status: provider.managedSettingsFetchStatus, data: result.data, compatibilityError: provider.managedSettingsCompatibilityError, + freshness: provider.managedSettingsFreshness, }, { requestCount: 1, status: 404, data: { managedSettings: undefined }, compatibilityError: null, + freshness: { state: ManagedSettingsFreshnessState.NotRequired }, + }); + }); + + test('fresh 404 satisfies a native refresh requirement', async () => { + const requestService = new TestRequestService(async () => jsonResponse({}, 404)); + const provider = await createProvider(requestService, { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: true }); + + await provider['getManagedSettings'](sessions, undefined); + + assert.deepStrictEqual(describeFreshness(provider.managedSettingsFreshness), { + state: ManagedSettingsFreshnessState.Satisfied, + source: 'nativeMdm', + scope: { + accountId, + authenticationProviderId: 'github', + endpointOrigin: 'https://api.github.com', + }, + hasLastAttempt: true, + hasSatisfiedAt: true, }); }); @@ -125,6 +150,30 @@ suite('DefaultAccountProvider managed settings', () => { }); }); + test('466 is a blocked forced refresh and keeps cached restrictions', async () => { + const requestService = new TestRequestService(async () => jsonResponse({ + error_code: 'client_update_required', + client_id: 'vscode', + }, 466)); + const provider = await createProvider(requestService); + const cachedPolicy = createCachedPolicy(true); + + const result = await provider['getManagedSettings'](sessions, cachedPolicy); + + assert.deepStrictEqual({ + freshness: describeFreshness(provider.managedSettingsFreshness), + data: result.data, + }, { + freshness: { + state: ManagedSettingsFreshnessState.Blocked, + source: 'server', + failure: ManagedSettingsFreshnessFailure.UpdateRequired, + hasLastAttempt: true, + }, + data: cachedPolicy.policyData, + }); + }); + 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'); @@ -145,6 +194,256 @@ suite('DefaultAccountProvider managed settings', () => { }); }); + test('failed forced refresh blocks without treating cached settings as fresh', async () => { + const requestService = new TestRequestService(async () => { + throw new Error('managed settings unavailable'); + }); + const provider = await createProvider(requestService); + const cachedPolicy = createCachedPolicy(true); + + const result = await provider['getManagedSettings'](sessions, cachedPolicy); + + assert.deepStrictEqual({ + requestCount: requestService.requestCount, + status: provider.managedSettingsFetchStatus, + freshness: describeFreshness(provider.managedSettingsFreshness), + data: result.data, + fetchedAt: result.fetchedAt, + }, { + requestCount: 1, + status: 'no-response', + freshness: { + state: ManagedSettingsFreshnessState.Blocked, + source: 'server', + failure: ManagedSettingsFreshnessFailure.Network, + hasLastAttempt: true, + }, + data: cachedPolicy.policyData, + fetchedAt: cachedPolicy.managedSettingsFetchedAt, + }); + }); + + test('retry after a failed forced refresh stays forced and blocked', async () => { + const requestService = new TestRequestService(async () => { + throw new Error('managed settings unavailable'); + }); + const provider = await createProvider(requestService); + const cachedPolicy = createCachedPolicy(true); + + const first = await provider['getManagedSettings'](sessions, cachedPolicy); + const retryPolicy = { + ...cachedPolicy, + policyData: first.data ?? {}, + managedSettingsFetchedAt: first.fetchedAt, + }; + await provider['getManagedSettings'](sessions, retryPolicy, { forceRefresh: true }); + + assert.deepStrictEqual({ + requestCount: requestService.requestCount, + freshness: describeFreshness(provider.managedSettingsFreshness), + }, { + requestCount: 2, + freshness: { + state: ManagedSettingsFreshnessState.Blocked, + source: 'server', + failure: ManagedSettingsFreshnessFailure.Network, + hasLastAttempt: true, + }, + }); + }); + + test('first server response can establish and satisfy a refresh requirement', async () => { + const requestService = new TestRequestService(async () => jsonResponse({ + forceRemoteSettingsRefresh: true, + })); + const provider = await createProvider(requestService); + + await provider['getManagedSettings'](sessions, undefined); + + assert.deepStrictEqual(describeFreshness(provider.managedSettingsFreshness), { + state: ManagedSettingsFreshnessState.Satisfied, + source: 'server', + scope: { + accountId, + authenticationProviderId: 'github', + endpointOrigin: 'https://api.github.com', + }, + hasLastAttempt: true, + hasSatisfiedAt: true, + }); + }); + + test('forced refresh remains pending until the live request completes', async () => { + let resolveRequest!: (response: IRequestContext) => void; + const response = new Promise(resolve => resolveRequest = resolve); + const provider = await createProvider(new TestRequestService(() => response)); + + const refresh = provider['getManagedSettings'](sessions, createCachedPolicy(true)); + await timeout(0); + assert.deepStrictEqual(describeFreshness(provider.managedSettingsFreshness), { + state: ManagedSettingsFreshnessState.Pending, + source: 'server', + hasLastAttempt: true, + }); + + resolveRequest(jsonResponse({ forceRemoteSettingsRefresh: true })); + await refresh; + assert.strictEqual(provider.managedSettingsFreshness.state, ManagedSettingsFreshnessState.Satisfied); + }); + + test('successful retry clears a blocked requirement when the server removes it', async () => { + let requestCount = 0; + const requestService = new TestRequestService(async () => { + requestCount++; + if (requestCount === 1) { + throw new Error('managed settings unavailable'); + } + return jsonResponse({}); + }); + const provider = await createProvider(requestService); + const cachedPolicy = createCachedPolicy(true); + + const failed = await provider['getManagedSettings'](sessions, cachedPolicy); + await provider['getManagedSettings'](sessions, { + ...cachedPolicy, + policyData: failed.data ?? {}, + managedSettingsFetchedAt: failed.fetchedAt, + }, { forceRefresh: true }); + + assert.deepStrictEqual({ + requestCount, + freshness: provider.managedSettingsFreshness, + }, { + requestCount: 2, + freshness: { state: ManagedSettingsFreshnessState.NotRequired }, + }); + }); + + test('sign-out closes a satisfied native refresh gate', async () => { + const requestService = new TestRequestService(async options => { + if (options.url?.endsWith('/copilot_internal/user')) { + return jsonResponse({ chat_enabled: true }); + } + if (options.url?.includes('/copilot_internal/managed_settings')) { + return jsonResponse({}); + } + throw new Error(`Unexpected request: ${options.url}`); + }); + const provider = await createProvider(requestService, { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: true }); + const account = await provider['getDefaultAccountFromAuthenticatedSessions']( + { id: 'github', name: 'GitHub', enterprise: false }, + sessions, + { forceRefresh: true } + ); + assert.ok(account); + provider['setDefaultAccount'](account); + assert.strictEqual(provider.managedSettingsFreshness.state, ManagedSettingsFreshnessState.Satisfied); + + provider['setDefaultAccount'](null); + + assert.deepStrictEqual(provider.managedSettingsFreshness, { + state: ManagedSettingsFreshnessState.Blocked, + source: 'nativeMdm', + failure: ManagedSettingsFreshnessFailure.NoToken, + }); + }); + + test('native false disables a cached server refresh requirement', async () => { + const requestService = new TestRequestService(async () => { + throw new Error('managed settings unavailable'); + }); + const provider = await createProvider(requestService, { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: false }); + const cachedPolicy = createCachedPolicy(true); + + const result = await provider['getManagedSettings'](sessions, cachedPolicy); + + assert.deepStrictEqual({ + freshness: provider.managedSettingsFreshness, + data: result.data, + }, { + freshness: { state: ManagedSettingsFreshnessState.NotRequired }, + data: cachedPolicy.policyData, + }); + }); + + test('file-delivered refresh requirement fails closed', async () => { + const requestService = new TestRequestService(async () => jsonResponse({}, 503)); + const provider = await createProvider(requestService, {}, { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: true }); + + await provider['getManagedSettings'](sessions, undefined); + + assert.deepStrictEqual(describeFreshness(provider.managedSettingsFreshness), { + state: ManagedSettingsFreshnessState.Blocked, + source: 'file', + failure: ManagedSettingsFreshnessFailure.HttpError, + httpStatus: 503, + hasLastAttempt: true, + }); + }); + + test('rate-limited forced refresh records retry timing', async () => { + const requestService = new TestRequestService(async () => jsonResponse({}, 429, { 'retry-after': '60' })); + const provider = await createProvider(requestService, { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: true }); + + await provider['getManagedSettings'](sessions, undefined); + + const freshness = provider.managedSettingsFreshness; + assert.deepStrictEqual({ + ...describeFreshness(freshness), + hasFutureRetry: freshness.state === ManagedSettingsFreshnessState.Blocked + && freshness.failure === ManagedSettingsFreshnessFailure.RateLimited + && freshness.retryAfter > Date.now(), + }, { + state: ManagedSettingsFreshnessState.Blocked, + source: 'nativeMdm', + failure: ManagedSettingsFreshnessFailure.RateLimited, + hasLastAttempt: true, + hasRetryAfter: true, + hasFutureRetry: true, + }); + }); + + test('malformed forced refresh response fails closed', async () => { + const requestService = new TestRequestService(async () => ({ + res: { statusCode: 200, headers: {} }, + stream: bufferToStream(VSBuffer.fromString('{')), + })); + const provider = await createProvider(requestService, { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: true }); + + await provider['getManagedSettings'](sessions, undefined); + + assert.deepStrictEqual(describeFreshness(provider.managedSettingsFreshness), { + state: ManagedSettingsFreshnessState.Blocked, + source: 'nativeMdm', + failure: ManagedSettingsFreshnessFailure.Malformed, + hasLastAttempt: true, + }); + }); + + test('forced refresh without an endpoint fails closed', async () => { + const provider = await createProvider(new TestRequestService(async () => jsonResponse({})), { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: true }, {}, ''); + + await provider['getManagedSettings'](sessions, undefined); + + assert.deepStrictEqual(describeFreshness(provider.managedSettingsFreshness), { + state: ManagedSettingsFreshnessState.Blocked, + source: 'nativeMdm', + failure: ManagedSettingsFreshnessFailure.NoUrl, + hasLastAttempt: false, + }); + }); + + test('forced refresh without authentication fails closed but leaves sign-in available', async () => { + const provider = await createProvider(new TestRequestService(async () => jsonResponse({})), { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: true }); + + assert.deepStrictEqual(describeFreshness(provider.managedSettingsFreshness), { + state: ManagedSettingsFreshnessState.Blocked, + source: 'nativeMdm', + failure: ManagedSettingsFreshnessFailure.NoToken, + hasLastAttempt: false, + }); + }); + test('repeated no-response fetches let cached managed settings age out instead of renewing them', async () => { const requestService = new TestRequestService(async () => { throw new Error('managed settings unavailable'); @@ -229,7 +528,12 @@ suite('DefaultAccountProvider managed settings', () => { }); }); - async function createProvider(requestService: TestRequestService): Promise { + async function createProvider( + requestService: TestRequestService, + nativeManagedSettings: ManagedSettingsData = {}, + fileManagedSettings: ManagedSettingsData = {}, + managedSettingsUrl = 'https://api.github.com/copilot_internal/managed_settings' + ): Promise { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IConfigurationService, new TestConfigurationService()); instantiationService.stub(IAuthenticationService, { @@ -266,6 +570,21 @@ suite('DefaultAccountProvider managed settings', () => { onDidChangeFocus: Event.None, }); instantiationService.stub(ICommandService, {}); + instantiationService.stub(INativeManagedSettingsService, { + _serviceBrand: undefined, + managedSettings: nativeManagedSettings, + onDidChangeManagedSettings: Event.None, + initialize: async () => nativeManagedSettings, + updatePolicyDefinitions: async () => nativeManagedSettings, + }); + instantiationService.stub(IFileManagedSettingsService, { + _serviceBrand: undefined, + rawManagedSettings: fileManagedSettings, + managedSettings: fileManagedSettings, + onDidChangeRawManagedSettings: Event.None, + onDidChangeManagedSettings: Event.None, + initialize: async () => fileManagedSettings, + }); const provider = disposables.add(instantiationService.createInstance(DefaultAccountProvider, { preferredExtensions: [], @@ -279,7 +598,7 @@ suite('DefaultAccountProvider managed settings', () => { tokenEntitlementUrl: '', entitlementUrl: 'https://api.github.com/copilot_internal/user', mcpRegistryDataUrl: '', - managedSettingsUrl: 'https://api.github.com/copilot_internal/managed_settings', + managedSettingsUrl, })); await provider.refresh(); return provider; @@ -297,6 +616,26 @@ suite('DefaultAccountProvider managed settings', () => { managedSettingsFetchedAt: Date.now(), }; } + + function describeFreshness(freshness: IManagedSettingsFreshness): object { + if (freshness.state === ManagedSettingsFreshnessState.Satisfied) { + const { lastAttemptAt, satisfiedAt, ...rest } = freshness; + return { ...rest, hasLastAttempt: lastAttemptAt !== undefined, hasSatisfiedAt: satisfiedAt !== undefined }; + } + if (freshness.state === ManagedSettingsFreshnessState.Pending) { + const { lastAttemptAt, ...rest } = freshness; + return { ...rest, hasLastAttempt: lastAttemptAt !== undefined }; + } + if (freshness.state === ManagedSettingsFreshnessState.Blocked) { + const { lastAttemptAt, ...rest } = freshness; + if (rest.failure === ManagedSettingsFreshnessFailure.RateLimited) { + const { retryAfter, ...withoutRetryAfter } = rest; + return { ...withoutRetryAfter, hasLastAttempt: lastAttemptAt !== undefined, hasRetryAfter: retryAfter !== undefined }; + } + return { ...rest, hasLastAttempt: lastAttemptAt !== undefined }; + } + return freshness; + } }); class TestRequestService implements IRequestService { @@ -330,9 +669,9 @@ class TestRequestService implements IRequestService { } } -function jsonResponse(data: unknown, statusCode = 200): IRequestContext { +function jsonResponse(data: unknown, statusCode = 200, headers: Record = {}): IRequestContext { return { - res: { statusCode, headers: {} }, + res: { statusCode, headers }, stream: bufferToStream(VSBuffer.fromString(JSON.stringify(data))), }; } diff --git a/src/vs/workbench/services/policies/browser/accountPolicyGateContribution.ts b/src/vs/workbench/services/policies/browser/accountPolicyGateContribution.ts index 10daada6f655d..c8f56899a52d8 100644 --- a/src/vs/workbench/services/policies/browser/accountPolicyGateContribution.ts +++ b/src/vs/workbench/services/policies/browser/accountPolicyGateContribution.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { disposableTimeout } from '../../../../base/common/async.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; @@ -14,6 +14,7 @@ import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IManagedSettingsFreshness, ManagedSettingsFreshnessFailure, ManagedSettingsFreshnessState } from '../../../../platform/policy/common/managedSettingsFreshness.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; @@ -33,9 +34,9 @@ type AccountPolicyGateStateEvent = { type AccountPolicyGateStateClassification = { owner: 'joshspicer'; comment: 'Tracks the Account Policy gate state for diagnosing account-driven restriction issues.'; - gateActive: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'True if an admin has activated the Approved Account gate (non-empty approved-organization list).' }; - gateSatisfied: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'True if the gate is satisfied (signed-in approved account with resolved policy).' }; - reasonNotSatisfied: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bucketed reason the gate is unsatisfied: noAccount, wrongProvider, orgNotApproved, policyNotResolved.' }; + gateActive: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'True if an enterprise account or managed-settings gate is active.' }; + gateSatisfied: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'True if the active enterprise gate is satisfied.' }; + reasonNotSatisfied: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bucketed reason the gate is unsatisfied: noAccount, wrongProvider, orgNotApproved, policyNotResolved, or managedSettingsRefresh.' }; }; /** @@ -51,6 +52,7 @@ export class AccountPolicyGateContribution extends Disposable implements IWorkbe private lastInfo: IAccountPolicyGateInfo; private readonly notificationHandle = this._register(new MutableDisposable()); + private readonly managedSettingsRefreshNotificationHandle = this._register(new MutableDisposable()); private compatibilityDialogVisible = false; private dismissedKey: string | undefined; @@ -112,6 +114,14 @@ export class AccountPolicyGateContribution extends Disposable implements IWorkbe }); } + if (info.reason === AccountPolicyGateUnsatisfiedReason.ManagedSettingsRefresh) { + this.notificationHandle.clear(); + this.dismissedKey = undefined; + this.updateManagedSettingsRefreshNotification(info.managedSettingsFreshness); + return; + } + this.managedSettingsRefreshNotificationHandle.clear(); + if (info.state !== AccountPolicyGateState.Restricted) { this.notificationHandle.clear(); this.dismissedKey = undefined; @@ -228,6 +238,52 @@ export class AccountPolicyGateContribution extends Disposable implements IWorkbe void this.showManagedSettingsCompatibilityDialog(error).finally(() => this.compatibilityDialogVisible = false); } + private updateManagedSettingsRefreshNotification(freshness: IManagedSettingsFreshness | undefined): void { + if (!freshness || freshness.state !== ManagedSettingsFreshnessState.Blocked) { + this.managedSettingsRefreshNotificationHandle.clear(); + return; + } + + this.managedSettingsRefreshNotificationHandle.clear(); + const actions = freshness.failure === ManagedSettingsFreshnessFailure.NoToken + ? [{ + label: localize('managedSettingsRefresh.notification.signIn', "Sign In"), + run: () => this.commandService.executeCommand(DEFAULT_ACCOUNT_SIGN_IN_COMMAND), + }] + : freshness.failure === ManagedSettingsFreshnessFailure.RateLimited || freshness.failure === ManagedSettingsFreshnessFailure.NoUrl + ? [] + : [{ + label: localize('managedSettingsRefresh.notification.retry', "Retry"), + run: () => this.defaultAccountService.refresh({ forceRefresh: true }), + }]; + const handle = this.notificationService.prompt( + Severity.Warning, + this.getManagedSettingsRefreshMessage(freshness), + actions, + { sticky: true } + ); + this.managedSettingsRefreshNotificationHandle.value = toDisposable(() => handle.close()); + } + + private getManagedSettingsRefreshMessage(freshness: Extract): string { + switch (freshness.failure) { + case ManagedSettingsFreshnessFailure.NoToken: + return localize('managedSettingsRefresh.notification.noToken', "AI features are unavailable because {0} must refresh your organization's managed settings. Sign in to continue.", this.productService.nameShort); + case ManagedSettingsFreshnessFailure.NoUrl: + return localize('managedSettingsRefresh.notification.noUrl', "AI features are unavailable because {0} cannot locate your organization's managed settings service. Contact your administrator.", this.productService.nameShort); + case ManagedSettingsFreshnessFailure.RateLimited: + return localize('managedSettingsRefresh.notification.rateLimited', "AI features are temporarily unavailable because your organization's managed settings service is rate limiting requests. {0} will retry automatically.", this.productService.nameShort); + case ManagedSettingsFreshnessFailure.HttpError: + return localize('managedSettingsRefresh.notification.httpError', "AI features are unavailable because {0} could not refresh your organization's managed settings (HTTP {1}). Retry after checking your connection.", this.productService.nameShort, freshness.httpStatus); + case ManagedSettingsFreshnessFailure.Malformed: + return localize('managedSettingsRefresh.notification.malformed', "AI features are unavailable because {0} received an invalid managed settings response. Retry or contact your administrator.", this.productService.nameShort); + case ManagedSettingsFreshnessFailure.Network: + return localize('managedSettingsRefresh.notification.network', "AI features are unavailable because {0} could not refresh your organization's managed settings. Check your connection, then retry.", this.productService.nameShort); + case ManagedSettingsFreshnessFailure.UpdateRequired: + return localize('managedSettingsRefresh.notification.updateRequired', "AI features are unavailable until {0} is updated to support your organization's managed settings.", this.productService.nameShort); + } + } + private async showManagedSettingsCompatibilityDialog(error: IManagedSettingsCompatibilityError): Promise { const message = error.minimumClientVersion ? localize( diff --git a/src/vs/workbench/services/policies/common/accountPolicyService.ts b/src/vs/workbench/services/policies/common/accountPolicyService.ts index 5755dddbd7ab9..e377d53d0af81 100644 --- a/src/vs/workbench/services/policies/common/accountPolicyService.ts +++ b/src/vs/workbench/services/policies/common/accountPolicyService.ts @@ -13,6 +13,7 @@ import { RawContextKey } from '../../../../platform/contextkey/common/contextkey import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { INativeManagedSettingsService, IFileManagedSettingsService, IManagedSettingsPick, IManagedSettingsService, ManagedSettingsChannel, collectManagedSettingsDefinitions, hasManagedSettingsDefinitions, projectManagedSettings, pickManagedSettings } from '../../../../platform/policy/common/copilotManagedSettings.js'; +import { IManagedSettingsFreshness, isManagedSettingsFreshnessBlocking } from '../../../../platform/policy/common/managedSettingsFreshness.js'; import { AbstractPolicyService, getRestrictedPolicyValue, IPolicyService, PolicyDefinition, PolicyValue, PolicyValueSource } from '../../../../platform/policy/common/policy.js'; import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; @@ -34,18 +35,20 @@ export const enum AccountPolicyGateUnsatisfiedReason { WrongProvider = 'wrongProvider', OrgNotApproved = 'orgNotApproved', PolicyNotResolved = 'policyNotResolved', + ManagedSettingsRefresh = 'managedSettingsRefresh', } export interface IAccountPolicyGateInfo { readonly state: AccountPolicyGateState; readonly reason?: AccountPolicyGateUnsatisfiedReason; readonly approvedOrganizations?: readonly string[]; + readonly managedSettingsFreshness?: IManagedSettingsFreshness; } export const ChatAccountPolicyGateActiveContext = new RawContextKey( 'chatAccountPolicyGateActive', false, - { type: 'boolean', description: localize('chatAccountPolicyGateActive', "True when account or managed-settings compatibility policy prevents this client from using AI features.") } + { type: 'boolean', description: localize('chatAccountPolicyGateActive', "True when account policy or managed-settings enforcement prevents this client from using AI features.") } ); /** @@ -109,6 +112,9 @@ export class AccountPolicyService extends AbstractPolicyService implements IPoli this._register(this.defaultAccountService.onDidChangeDefaultAccount(() => { this._updatePolicyDefinitions(this.policyDefinitions); })); + this._register(this.defaultAccountService.onDidChangeManagedSettingsFreshness(() => { + this._updatePolicyDefinitions(this.policyDefinitions); + })); if (this.managedPolicyReader) { this._register(this.managedPolicyReader.onDidChange(names => { if (names.includes(APPROVED_ACCOUNT_ORGANIZATIONS_POLICY_NAME)) { @@ -144,11 +150,7 @@ export class AccountPolicyService extends AbstractPolicyService implements IPoli const previousInfo = this._gateInfo; this._gateInfo = this.computeGateInfo(); - const previousApprovedOrgs = previousInfo.approvedOrganizations?.join('\n') ?? ''; - const currentApprovedOrgs = this._gateInfo.approvedOrganizations?.join('\n') ?? ''; - const gateInfoChanged = previousInfo.state !== this._gateInfo.state - || previousInfo.reason !== this._gateInfo.reason - || previousApprovedOrgs !== currentApprovedOrgs; + const gateInfoChanged = !equals(previousInfo, this._gateInfo); // `policyNotResolved` is a transient state where the user IS in an approved // org but account-side policy data hasn't loaded yet. We don't force restricted @@ -287,6 +289,15 @@ export class AccountPolicyService extends AbstractPolicyService implements IPoli } private computeGateInfo(): IAccountPolicyGateInfo { + const freshness = this.defaultAccountService.managedSettingsFreshness; + if (isManagedSettingsFreshnessBlocking(freshness)) { + return { + state: AccountPolicyGateState.Restricted, + reason: AccountPolicyGateUnsatisfiedReason.ManagedSettingsRefresh, + managedSettingsFreshness: freshness, + }; + } + if (!this.managedPolicyReader) { return { state: AccountPolicyGateState.Inactive }; } diff --git a/src/vs/workbench/services/policies/test/browser/accountPolicyGateContribution.test.ts b/src/vs/workbench/services/policies/test/browser/accountPolicyGateContribution.test.ts index a32dfa7c06654..f7d555953152e 100644 --- a/src/vs/workbench/services/policies/test/browser/accountPolicyGateContribution.test.ts +++ b/src/vs/workbench/services/policies/test/browser/accountPolicyGateContribution.test.ts @@ -15,6 +15,7 @@ import { MockContextKeyService } from '../../../../../platform/keybinding/test/c import { NullLogService } from '../../../../../platform/log/common/log.js'; import { TestNotificationService } from '../../../../../platform/notification/test/common/testNotificationService.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { ManagedSettingsFreshnessFailure, ManagedSettingsFreshnessState } from '../../../../../platform/policy/common/managedSettingsFreshness.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { InMemoryStorageService } from '../../../../../platform/storage/common/storage.js'; import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js'; @@ -82,6 +83,8 @@ suite('AccountPolicyGateContribution', () => { const storageService = disposables.add(new InMemoryStorageService()); const dialogService = new TestDialogService(); const promptStub = sinon.stub(dialogService, 'prompt').resolves({}); + const notificationService = new TestNotificationService(); + const notificationPromptSpy = sinon.spy(notificationService, 'prompt'); const productService = new class extends mock() { override readonly nameShort = 'Code'; }(); @@ -92,7 +95,7 @@ suite('AccountPolicyGateContribution', () => { chatEntitlementService, defaultAccountService, new NullLogService(), - new TestNotificationService(), + notificationService, dialogService, new class extends mock() { }(), new class extends mock() { }(), @@ -130,6 +133,31 @@ suite('AccountPolicyGateContribution', () => { captureState(); defaultAccountService.setManagedSettingsCompatibilityError(null); captureState(); + gateService.setGateInfo({ + state: AccountPolicyGateState.Restricted, + reason: AccountPolicyGateUnsatisfiedReason.ManagedSettingsRefresh, + managedSettingsFreshness: { + state: ManagedSettingsFreshnessState.Blocked, + source: 'server', + failure: ManagedSettingsFreshnessFailure.Network, + lastAttemptAt: 42, + }, + }); + captureState(); + const refreshNotification = notificationPromptSpy.lastCall; + gateService.setGateInfo({ + state: AccountPolicyGateState.Restricted, + reason: AccountPolicyGateUnsatisfiedReason.ManagedSettingsRefresh, + managedSettingsFreshness: { + state: ManagedSettingsFreshnessState.Blocked, + source: 'nativeMdm', + failure: ManagedSettingsFreshnessFailure.NoToken, + }, + }); + captureState(); + const signInNotification = notificationPromptSpy.lastCall; + gateService.setGateInfo({ state: AccountPolicyGateState.Inactive }); + captureState(); const compatibilityDialog = promptStub.firstCall.args[0]; const fallbackCompatibilityDialog = promptStub.secondCall.args[0]; @@ -144,6 +172,14 @@ suite('AccountPolicyGateContribution', () => { cancelButton: compatibilityDialog.cancelButton, }, fallbackCompatibilityMessage: fallbackCompatibilityDialog.message, + refreshNotification: { + message: refreshNotification.args[1], + actions: refreshNotification.args[2].map(action => action.label), + }, + signInNotification: { + message: signInNotification.args[1], + actions: signInNotification.args[2].map(action => action.label), + }, }, { states: [ { context: false, hidden: false }, @@ -153,8 +189,11 @@ suite('AccountPolicyGateContribution', () => { { context: true, hidden: true }, { context: true, hidden: true }, { context: false, hidden: false }, + { context: true, hidden: true }, + { context: true, hidden: true }, + { context: false, hidden: false }, ], - forceHiddenValues: [false, true, false, true, false], + forceHiddenValues: [false, true, false, true, false, true, false], compatibilityDialog: { title: 'Update Required', message: 'Your version of Code cannot enforce your organization\'s managed settings. Update Code to version 1.135.0 or later to continue using AI features.', @@ -163,6 +202,14 @@ suite('AccountPolicyGateContribution', () => { cancelButton: 'Close', }, fallbackCompatibilityMessage: 'Your version of Code cannot enforce your organization\'s managed settings. Update Code to continue using AI features.', + refreshNotification: { + message: 'AI features are unavailable because Code could not refresh your organization\'s managed settings. Check your connection, then retry.', + actions: ['Retry'], + }, + signInNotification: { + message: 'AI features are unavailable because Code must refresh your organization\'s managed settings. Sign in to continue.', + actions: ['Sign In'], + }, }); }); }); 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 cf95f5d5f8cb0..b11c95a9f4e6e 100644 --- a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts @@ -10,9 +10,10 @@ import { ManagedSettingsData, PolicyCategory } from '../../../../../base/common/ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { Extensions, IConfigurationNode, IConfigurationRegistry } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { DefaultConfiguration, PolicyConfiguration } from '../../../../../platform/configuration/common/configurations.js'; -import { IDefaultAccountProvider, IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IDefaultAccountProvider, IDefaultAccountService, MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; import { COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_ENABLED_PLUGINS_KEY, COPILOT_SANDBOX_ENABLED_KEY, INativeManagedSettingsService, IFileManagedSettingsService, thirdPartyAgentEnabledValue } from '../../../../../platform/policy/common/copilotManagedSettings.js'; +import { IManagedSettingsFreshness, ManagedSettingsFreshnessFailure, ManagedSettingsFreshnessState } from '../../../../../platform/policy/common/managedSettingsFreshness.js'; import { AbstractPolicyService, IPolicyService, PolicyDefinition, PolicyValue, PolicyValueSource } from '../../../../../platform/policy/common/policy.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { TestProductService } from '../../../../test/common/workbenchTestServices.js'; @@ -41,10 +42,12 @@ class DefaultAccountProvider implements IDefaultAccountProvider { readonly managedSettingsRawResponse: unknown = null; readonly managedSettingsCompatibilityError = null; readonly onDidChangeManagedSettingsCompatibilityError = Event.None; + readonly onDidChangeManagedSettingsFreshness = Event.None; constructor( readonly defaultAccount: IDefaultAccount, readonly policyData: IPolicyData | null = {}, + readonly managedSettingsFreshness: IManagedSettingsFreshness = MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED, ) { } getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider { @@ -674,12 +677,15 @@ suite('AccountPolicyService', () => { readonly onDidChangeManagedSettings = this._onDidChangeManagedSettings.event; constructor(public managedSettings: ManagedSettingsData = {}) { } + + async initialize(): Promise { return this.managedSettings; } } async function setupGate(opts: { approvedOrgs?: string[] | string; account?: IDefaultAccount | null; policyData?: IPolicyData | null; + managedSettingsFreshness?: IManagedSettingsFreshness; }): Promise<{ policyService: AccountPolicyService; managed: FakeManagedPolicyService }> { const managed = disposables.add(new FakeManagedPolicyService()); if (opts.approvedOrgs !== undefined) { @@ -692,7 +698,7 @@ suite('AccountPolicyService', () => { const accountService = disposables.add(new DefaultAccountService(TestProductService)); if (opts.account !== null && opts.account !== undefined) { const policyData = opts.policyData === undefined ? {} : opts.policyData; - accountService.setDefaultAccountProvider(new DefaultAccountProvider(opts.account, policyData)); + accountService.setDefaultAccountProvider(new DefaultAccountProvider(opts.account, policyData, opts.managedSettingsFreshness)); await accountService.refresh(); } @@ -710,6 +716,27 @@ suite('AccountPolicyService', () => { assert.strictEqual(policyService.getPolicyValue('PolicySettingD'), false); // account policy still flows }); + test('forced managed settings refresh blocks independently of approved account policy', async () => { + const freshness: IManagedSettingsFreshness = { + state: ManagedSettingsFreshnessState.Blocked, + source: 'server', + failure: ManagedSettingsFreshnessFailure.Network, + lastAttemptAt: 42, + }; + const { policyService } = await setupGate({ + account: APPROVED_ORG_ACCOUNT, + policyData: {}, + managedSettingsFreshness: freshness, + }); + + assert.deepStrictEqual(policyService.gateInfo, { + state: AccountPolicyGateState.Restricted, + reason: AccountPolicyGateUnsatisfiedReason.ManagedSettingsRefresh, + managedSettingsFreshness: freshness, + }); + assert.strictEqual(policyService.getPolicyValueSource('PolicySettingD'), PolicyValueSource.AccountGate); + }); + test('gate active, no account signed in: restricted', async () => { const { policyService } = await setupGate({ approvedOrgs: ['ApprovedOrg'], account: null }); assert.strictEqual(policyService.gateInfo.state, AccountPolicyGateState.Restricted); 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 b9723d49c578e..f4bc59acaafa8 100644 --- a/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts @@ -12,7 +12,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { Extensions, IConfigurationNode, IConfigurationRegistry } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { DefaultConfiguration, PolicyConfiguration } from '../../../../../platform/configuration/common/configurations.js'; -import { IDefaultAccountProvider, IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IDefaultAccountProvider, IDefaultAccountService, MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { FileService } from '../../../../../platform/files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../../../platform/files/common/inMemoryFilesystemProvider.js'; @@ -47,6 +47,8 @@ class DefaultAccountProvider implements IDefaultAccountProvider { readonly managedSettingsRawResponse: unknown = null; readonly managedSettingsCompatibilityError = null; readonly onDidChangeManagedSettingsCompatibilityError = Event.None; + readonly managedSettingsFreshness = MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED; + readonly onDidChangeManagedSettingsFreshness = 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 f7157b2473429..3f03eff16d84b 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts @@ -73,7 +73,7 @@ import { IContextKeyService } from '../../../../platform/contextkey/common/conte import { IContextMenuService, IContextViewService } from '../../../../platform/contextview/browser/contextView.js'; import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { IDataChannelService, NullDataChannelService } from '../../../../platform/dataChannel/common/dataChannel.js'; -import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IDefaultAccountService, MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED } from '../../../../platform/defaultAccount/common/defaultAccount.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { TestDialogService } from '../../../../platform/dialogs/test/common/testDialogService.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; @@ -596,6 +596,8 @@ export function createEditorServices(disposables: DisposableStore, options?: Cre managedSettingsRawResponse: null, managedSettingsCompatibilityError: null, onDidChangeManagedSettingsCompatibilityError: Event.None, + managedSettingsFreshness: MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED, + onDidChangeManagedSettingsFreshness: Event.None, getDefaultAccount: async () => null, getDefaultAccountAuthenticationProvider: () => ({ id: 'test', name: 'Test', scopes: [], enterprise: false }), resolveGitHubUrl: (path: string) => `https://github.com/${path}`, From ea07216c5db7169007472301396e59ddecedf5c2 Mon Sep 17 00:00:00 2001 From: Josh Spicer Date: Mon, 24 Aug 2026 18:22:26 +0000 Subject: [PATCH 04/13] chat: tighten managed settings recovery UX Re-render the Agents window when freshness failure details change and preserve startup notification deferral. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/policyBlocked.contribution.ts | 4 +- .../browser/accountPolicyGateContribution.ts | 4 +- .../accountPolicyGateContribution.test.ts | 41 +++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/contrib/policyBlocked/browser/policyBlocked.contribution.ts b/src/vs/sessions/contrib/policyBlocked/browser/policyBlocked.contribution.ts index 48c99beca620a..820c85ab6e6b9 100644 --- a/src/vs/sessions/contrib/policyBlocked/browser/policyBlocked.contribution.ts +++ b/src/vs/sessions/contrib/policyBlocked/browser/policyBlocked.contribution.ts @@ -89,7 +89,9 @@ export class SessionsPolicyBlockedContribution extends Disposable implements IWo private showOverlay(options: ISessionsBlockedOverlayOptions): void { // AccountPolicyGate may need re-render when the account name changes. - if (this.currentReason === options.reason && options.reason !== SessionsBlockedReason.AccountPolicyGate) { + if (this.currentReason === options.reason + && options.reason !== SessionsBlockedReason.AccountPolicyGate + && options.reason !== SessionsBlockedReason.ManagedSettingsRefresh) { return; } this.overlayRef.clear(); diff --git a/src/vs/workbench/services/policies/browser/accountPolicyGateContribution.ts b/src/vs/workbench/services/policies/browser/accountPolicyGateContribution.ts index c8f56899a52d8..6ded86f629d9f 100644 --- a/src/vs/workbench/services/policies/browser/accountPolicyGateContribution.ts +++ b/src/vs/workbench/services/policies/browser/accountPolicyGateContribution.ts @@ -117,7 +117,9 @@ export class AccountPolicyGateContribution extends Disposable implements IWorkbe if (info.reason === AccountPolicyGateUnsatisfiedReason.ManagedSettingsRefresh) { this.notificationHandle.clear(); this.dismissedKey = undefined; - this.updateManagedSettingsRefreshNotification(info.managedSettingsFreshness); + if (showNotification) { + this.updateManagedSettingsRefreshNotification(info.managedSettingsFreshness); + } return; } this.managedSettingsRefreshNotificationHandle.clear(); diff --git a/src/vs/workbench/services/policies/test/browser/accountPolicyGateContribution.test.ts b/src/vs/workbench/services/policies/test/browser/accountPolicyGateContribution.test.ts index f7d555953152e..72db6a19aeb1b 100644 --- a/src/vs/workbench/services/policies/test/browser/accountPolicyGateContribution.test.ts +++ b/src/vs/workbench/services/policies/test/browser/accountPolicyGateContribution.test.ts @@ -30,6 +30,13 @@ class TestAccountPolicyGateService extends mock() { private readonly _onDidChangeGateInfo = new Emitter(); override readonly onDidChangeGateInfo = this._onDidChangeGateInfo.event; + constructor(initial?: IAccountPolicyGateInfo) { + super(); + if (initial) { + this._gateInfo = initial; + } + } + setGateInfo(info: IAccountPolicyGateInfo): void { this._gateInfo = info; this._onDidChangeGateInfo.fire(info); @@ -212,4 +219,38 @@ suite('AccountPolicyGateContribution', () => { }, }); }); + + test('defers an initial managed settings notification until startup settles', async () => { + const clock = sinon.useFakeTimers(); + const gateService = disposables.add(new TestAccountPolicyGateService({ + state: AccountPolicyGateState.Restricted, + reason: AccountPolicyGateUnsatisfiedReason.ManagedSettingsRefresh, + managedSettingsFreshness: { + state: ManagedSettingsFreshnessState.Blocked, + source: 'server', + failure: ManagedSettingsFreshnessFailure.Network, + }, + })); + const notificationService = new TestNotificationService(); + const notificationPromptSpy = sinon.spy(notificationService, 'prompt'); + + disposables.add(new AccountPolicyGateContribution( + gateService, + new MockContextKeyService(), + new TestChatEntitlementService(), + disposables.add(new TestDefaultAccountService()), + new NullLogService(), + notificationService, + new TestDialogService(), + new class extends mock() { }(), + new class extends mock() { }(), + new class extends mock() { override readonly nameShort = 'Code'; }(), + disposables.add(new InMemoryStorageService()), + NullTelemetryService, + )); + + assert.strictEqual(notificationPromptSpy.callCount, 0); + await clock.tickAsync(5000); + assert.strictEqual(notificationPromptSpy.callCount, 1); + }); }); From 677a5f93f5aace4510fc7326c70cdb0f3e53a88e Mon Sep 17 00:00:00 2001 From: Josh Spicer Date: Mon, 24 Aug 2026 18:45:15 +0000 Subject: [PATCH 05/13] chat: address managed settings review feedback Scope cached server controls before precedence, avoid expired rate-limit poll loops, and align update-required recovery guidance across workbench and Agents window UI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/sessionsPolicyBlocked.ts | 6 +++- .../accounts/browser/defaultAccount.ts | 28 ++++++++++----- .../test/browser/defaultAccount.test.ts | 35 +++++++++++++++++++ .../browser/accountPolicyGateContribution.ts | 4 ++- .../accountPolicyGateContribution.test.ts | 18 ++++++++++ 5 files changed, 81 insertions(+), 10 deletions(-) diff --git a/src/vs/sessions/contrib/policyBlocked/browser/sessionsPolicyBlocked.ts b/src/vs/sessions/contrib/policyBlocked/browser/sessionsPolicyBlocked.ts index 5737ef906d40e..97e30622a04f6 100644 --- a/src/vs/sessions/contrib/policyBlocked/browser/sessionsPolicyBlocked.ts +++ b/src/vs/sessions/contrib/policyBlocked/browser/sessionsPolicyBlocked.ts @@ -180,7 +180,11 @@ export class SessionsPolicyBlockedOverlay extends Disposable { ? localize('managedSettingsRefresh.noToken', "Sign in so {0} can refresh your organization's managed settings before starting an agent.", this.productService.nameShort) : freshness?.failure === ManagedSettingsFreshnessFailure.RateLimited ? localize('managedSettingsRefresh.rateLimited', "{0} is waiting to retry your organization's managed settings service. Agents will remain unavailable until the refresh succeeds.", this.productService.nameShort) - : localize('managedSettingsRefresh.failed', "{0} could not refresh your organization's managed settings. Agents will remain unavailable until the refresh succeeds.", this.productService.nameShort); + : freshness?.failure === ManagedSettingsFreshnessFailure.NoUrl + ? localize('managedSettingsRefresh.noUrl', "{0} cannot locate your organization's managed settings service. Contact your administrator.", this.productService.nameShort) + : freshness?.failure === ManagedSettingsFreshnessFailure.UpdateRequired + ? localize('managedSettingsRefresh.updateRequired', "Update {0} to a version that supports your organization's managed settings before starting an agent.", this.productService.nameShort) + : localize('managedSettingsRefresh.failed', "{0} could not refresh your organization's managed settings. Agents will remain unavailable until the refresh succeeds.", this.productService.nameShort); append(card, $('p', undefined, message)); if (freshness?.failure === ManagedSettingsFreshnessFailure.NoToken) { diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index f700321aa7bbb..cd47ceed9e8b4 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -717,14 +717,22 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun } private getCachedServerManagedSettings(authenticationProvider: IDefaultAccountAuthenticationProvider): ManagedSettingsData | undefined { - const scope = this._policyData?.managedSettingsScope; + return this.getScopedServerManagedSettings(this._policyData ?? undefined, authenticationProvider, this._policyData?.accountId); + } + + private getScopedServerManagedSettings(accountPolicyData: IAccountPolicyData | undefined, authenticationProvider: IDefaultAccountAuthenticationProvider, accountId: string | undefined): ManagedSettingsData | undefined { + if (!accountPolicyData || accountPolicyData.accountId !== accountId) { + return undefined; + } + const scope = accountPolicyData.managedSettingsScope; const managedSettingsUrl = this.getManagedSettingsUrl(); - if (scope && (!managedSettingsUrl + if (scope && (scope.accountId !== accountId + || !managedSettingsUrl || scope.authenticationProviderId !== authenticationProvider.id || scope.endpointOrigin !== this.createManagedSettingsFreshnessScope(scope.accountId, authenticationProvider.id, managedSettingsUrl).endpointOrigin)) { return undefined; } - return this._policyData?.policyData.managedSettings; + return accountPolicyData.policyData.managedSettings; } private setCopilotTokenInfo(copilotTokenInfo: ICopilotTokenInfo | null): void { @@ -753,11 +761,15 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun if (!this._defaultAccount) { return; } - const delay = this._managedSettingsFreshness.state === ManagedSettingsFreshnessState.Blocked + this.accountDataPollScheduler.schedule(this.getAccountDataPollDelay()); + } + + private getAccountDataPollDelay(): number { + const retryDelay = this._managedSettingsFreshness.state === ManagedSettingsFreshnessState.Blocked && this._managedSettingsFreshness.failure === ManagedSettingsFreshnessFailure.RateLimited ? Math.max(0, this._managedSettingsFreshness.retryAfter - Date.now()) - : ACCOUNT_DATA_POLL_INTERVAL_MS; - this.accountDataPollScheduler.schedule(delay); + : undefined; + return retryDelay !== undefined && retryDelay > 0 ? retryDelay : ACCOUNT_DATA_POLL_INTERVAL_MS; } private extractFromToken(token: string): Map { @@ -807,7 +819,7 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun const sources = managedSettingsSources ?? await this.initializeManagedSettingsSources(); const requirement = resolveForceRemoteSettingsRefresh( sources.nativeMdm, - accountPolicyData?.policyData.managedSettings, + this.getScopedServerManagedSettings(accountPolicyData, authenticationProvider, accountId), sources.file ); const managedSettingsUrl = this.getManagedSettingsUrl(); @@ -1105,7 +1117,7 @@ export class DefaultAccountProvider extends Disposable implements IDefaultAccoun const sources = managedSettingsSources ?? await this.initializeManagedSettingsSources(); const requirement = refreshRequirement ?? resolveForceRemoteSettingsRefresh( sources.nativeMdm, - accountPolicyData?.policyData.managedSettings, + this.getScopedServerManagedSettings(accountPolicyData, authenticationProvider, accountId), sources.file ); const cachedManagedSettings = accountPolicyData?.managedSettingsFetchedAt !== undefined && !this.isDataStale(accountPolicyData.managedSettingsFetchedAt) 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 dc6c6594797d7..51f7b6a5994d1 100644 --- a/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts +++ b/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts @@ -381,6 +381,29 @@ suite('DefaultAccountProvider managed settings', () => { }); }); + test('stale server scope cannot override a file-delivered refresh requirement', async () => { + const requestService = new TestRequestService(async () => jsonResponse({}, 503)); + const provider = await createProvider(requestService, {}, { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: true }); + const cachedPolicy = { + ...createCachedPolicy(false), + managedSettingsScope: { + accountId, + authenticationProviderId: 'github-enterprise', + endpointOrigin: 'https://api.enterprise.example.com', + }, + }; + + await provider['getManagedSettings'](sessions, cachedPolicy); + + assert.deepStrictEqual(describeFreshness(provider.managedSettingsFreshness), { + state: ManagedSettingsFreshnessState.Blocked, + source: 'file', + failure: ManagedSettingsFreshnessFailure.HttpError, + httpStatus: 503, + hasLastAttempt: true, + }); + }); + test('rate-limited forced refresh records retry timing', async () => { const requestService = new TestRequestService(async () => jsonResponse({}, 429, { 'retry-after': '60' })); const provider = await createProvider(requestService, { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: true }); @@ -403,6 +426,18 @@ suite('DefaultAccountProvider managed settings', () => { }); }); + test('expired rate-limit deadline falls back to the normal poll interval', async () => { + const provider = await createProvider(new TestRequestService(async () => jsonResponse({}))); + provider['_managedSettingsFreshness'] = { + state: ManagedSettingsFreshnessState.Blocked, + source: 'server', + failure: ManagedSettingsFreshnessFailure.RateLimited, + retryAfter: Date.now() - 1, + }; + + assert.strictEqual(provider['getAccountDataPollDelay'](), 60 * 60 * 1000); + }); + test('malformed forced refresh response fails closed', async () => { const requestService = new TestRequestService(async () => ({ res: { statusCode: 200, headers: {} }, diff --git a/src/vs/workbench/services/policies/browser/accountPolicyGateContribution.ts b/src/vs/workbench/services/policies/browser/accountPolicyGateContribution.ts index 6ded86f629d9f..ee8b0032da3ef 100644 --- a/src/vs/workbench/services/policies/browser/accountPolicyGateContribution.ts +++ b/src/vs/workbench/services/policies/browser/accountPolicyGateContribution.ts @@ -252,7 +252,9 @@ export class AccountPolicyGateContribution extends Disposable implements IWorkbe label: localize('managedSettingsRefresh.notification.signIn', "Sign In"), run: () => this.commandService.executeCommand(DEFAULT_ACCOUNT_SIGN_IN_COMMAND), }] - : freshness.failure === ManagedSettingsFreshnessFailure.RateLimited || freshness.failure === ManagedSettingsFreshnessFailure.NoUrl + : freshness.failure === ManagedSettingsFreshnessFailure.RateLimited + || freshness.failure === ManagedSettingsFreshnessFailure.NoUrl + || freshness.failure === ManagedSettingsFreshnessFailure.UpdateRequired ? [] : [{ label: localize('managedSettingsRefresh.notification.retry', "Retry"), diff --git a/src/vs/workbench/services/policies/test/browser/accountPolicyGateContribution.test.ts b/src/vs/workbench/services/policies/test/browser/accountPolicyGateContribution.test.ts index 72db6a19aeb1b..b0473df5f9a15 100644 --- a/src/vs/workbench/services/policies/test/browser/accountPolicyGateContribution.test.ts +++ b/src/vs/workbench/services/policies/test/browser/accountPolicyGateContribution.test.ts @@ -163,6 +163,16 @@ suite('AccountPolicyGateContribution', () => { }); captureState(); const signInNotification = notificationPromptSpy.lastCall; + gateService.setGateInfo({ + state: AccountPolicyGateState.Restricted, + reason: AccountPolicyGateUnsatisfiedReason.ManagedSettingsRefresh, + managedSettingsFreshness: { + state: ManagedSettingsFreshnessState.Blocked, + source: 'server', + failure: ManagedSettingsFreshnessFailure.UpdateRequired, + }, + }); + const updateRequiredNotification = notificationPromptSpy.lastCall; gateService.setGateInfo({ state: AccountPolicyGateState.Inactive }); captureState(); @@ -187,6 +197,10 @@ suite('AccountPolicyGateContribution', () => { message: signInNotification.args[1], actions: signInNotification.args[2].map(action => action.label), }, + updateRequiredNotification: { + message: updateRequiredNotification.args[1], + actions: updateRequiredNotification.args[2].map(action => action.label), + }, }, { states: [ { context: false, hidden: false }, @@ -217,6 +231,10 @@ suite('AccountPolicyGateContribution', () => { message: 'AI features are unavailable because Code must refresh your organization\'s managed settings. Sign in to continue.', actions: ['Sign In'], }, + updateRequiredNotification: { + message: 'AI features are unavailable until Code is updated to support your organization\'s managed settings.', + actions: [], + }, }); }); From 9b54c9b4f31f942d6c7c42217f1a84182d8220a4 Mon Sep 17 00:00:00 2001 From: Josh Spicer Date: Mon, 24 Aug 2026 18:48:12 +0000 Subject: [PATCH 06/13] test: await explicit managed settings recovery refresh Classic web initialization intentionally skips the default-account fetch. Exercise the explicit refresh path before asserting the no-token fail-closed state so the browser suite observes the same lifecycle it is validating. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../services/accounts/test/browser/defaultAccount.test.ts | 1 + 1 file changed, 1 insertion(+) 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 51f7b6a5994d1..0659a9b1c93ae 100644 --- a/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts +++ b/src/vs/workbench/services/accounts/test/browser/defaultAccount.test.ts @@ -470,6 +470,7 @@ suite('DefaultAccountProvider managed settings', () => { test('forced refresh without authentication fails closed but leaves sign-in available', async () => { const provider = await createProvider(new TestRequestService(async () => jsonResponse({})), { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: true }); + await provider.refresh(); assert.deepStrictEqual(describeFreshness(provider.managedSettingsFreshness), { state: ManagedSettingsFreshnessState.Blocked, From d94cb7b16d8eb3221ed698dc0fb4fe3b6e1487ce Mon Sep 17 00:00:00 2001 From: Josh Spicer Date: Mon, 24 Aug 2026 20:16:34 +0000 Subject: [PATCH 07/13] test: provide product name in policy overlay fixture Ensure managed-settings messages render Code - OSS instead of an undefined product label in component screenshots. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../policyBlocked/test/browser/sessionsPolicyBlocked.fixture.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/sessions/contrib/policyBlocked/test/browser/sessionsPolicyBlocked.fixture.ts b/src/vs/sessions/contrib/policyBlocked/test/browser/sessionsPolicyBlocked.fixture.ts index 1beabee60aa54..4e219141e1a23 100644 --- a/src/vs/sessions/contrib/policyBlocked/test/browser/sessionsPolicyBlocked.fixture.ts +++ b/src/vs/sessions/contrib/policyBlocked/test/browser/sessionsPolicyBlocked.fixture.ts @@ -19,6 +19,7 @@ function createOverlay(ctx: ComponentFixtureContext, options: ISessionsBlockedOv colorTheme: ctx.theme, additionalServices: (reg) => { reg.defineInstance(IProductService, new class extends mock() { + override readonly nameShort = 'Code - OSS'; override readonly quality = 'insider'; override readonly urlProtocol = 'vscode-insiders'; }()); From c69e8c2fc5b6b549f7c84bf97cdc59d8455472cb Mon Sep 17 00:00:00 2001 From: Josh Spicer Date: Mon, 24 Aug 2026 22:45:00 +0000 Subject: [PATCH 08/13] test: add managed settings failure modes Let the mock policy server return HTTP errors, malformed JSON, immediate disconnects, or no response until client timeout through presets, the GUI, and the control API. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../local-testing.md | 7 +++ scripts/mock-policy-server/README.md | 36 ++++++++++-- scripts/mock-policy-server/endpoints.ts | 32 ++++++++++ scripts/mock-policy-server/public/app.ts | 29 +++++++++- scripts/mock-policy-server/public/index.html | 10 ++++ scripts/mock-policy-server/server.ts | 58 ++++++++++++++++--- 6 files changed, 159 insertions(+), 13 deletions(-) diff --git a/.github/skills/policy-and-managed-settings/local-testing.md b/.github/skills/policy-and-managed-settings/local-testing.md index 4ea7ca8d27190..8eeb187ff26e5 100644 --- a/.github/skills/policy-and-managed-settings/local-testing.md +++ b/.github/skills/policy-and-managed-settings/local-testing.md @@ -31,6 +31,13 @@ Use **Clear Policy Cache** when the runtime's fresh managed-settings cache prevents a network request. The live request log confirms whether the client reached the server. +To test `forceRemoteSettingsRefresh` fail-closed behavior, apply the +`customization-lockdown` managed-settings preset and sync once successfully. +Then select `server-error`, `malformed-response`, `disconnect`, or `timeout` and +sync again. The successful first response seeds the cached refresh requirement; +the second response exercises HTTP, parse, immediate-network, or client-timeout +failure without manually editing payloads. + Other Copilot clients share the default cache. For deterministic testing, start both Code OSS and the mock server with the same isolated `COPILOT_CACHE_HOME`. diff --git a/scripts/mock-policy-server/README.md b/scripts/mock-policy-server/README.md index 6af646ec93302..88f1ac3689c06 100644 --- a/scripts/mock-policy-server/README.md +++ b/scripts/mock-policy-server/README.md @@ -12,7 +12,7 @@ npm run mock-policy-server Open `http://127.0.0.1:3000`. Managed settings is mocked by default. Use the switch beside each endpoint tab to choose mock or passthrough. Presets apply -immediately; status and JSON edits auto-save. +immediately; response behavior, status, and JSON edits auto-save. The GUI opens on the **Policies** workspace. Select **Setup** in the header to open a modal that guides you through either connection method: @@ -91,9 +91,37 @@ curl -X POST "$BASE/api/state" \ ]}' ``` -A preset sets its status and body and enables mocking. Explicit `status`, `body`, -or `active` values in the same update override the preset. Invalid requests are -rejected before any endpoint changes. +A preset sets its response mode, status, and body and enables mocking. Explicit +`status`, `body`, `mode`, or `active` values in the same update override the +preset. Invalid requests are rejected before any endpoint changes. Supported +response modes are `json`, `malformed-json`, `disconnect`, and `timeout`. + +### Test fail-closed managed-settings refresh + +First serve a successful policy that enables the forced-refresh requirement, +sync it into VS Code, then switch the endpoint to a failure preset and sync +again. Seeding the requirement first mirrors a real deployment where the cached +control self-perpetuates through an outage. + +```sh +curl -X POST "$BASE/api/state" \ + -H 'Content-Type: application/json' \ + -d '{"endpoint":"managedSettings","preset":"customization-lockdown"}' + +# Run "Developer: Sync Account Policy" in VS Code, then choose one: +curl -X POST "$BASE/api/state" -H 'Content-Type: application/json' \ + -d '{"endpoint":"managedSettings","preset":"server-error"}' +curl -X POST "$BASE/api/state" -H 'Content-Type: application/json' \ + -d '{"endpoint":"managedSettings","preset":"malformed-response"}' +curl -X POST "$BASE/api/state" -H 'Content-Type: application/json' \ + -d '{"endpoint":"managedSettings","preset":"disconnect"}' +curl -X POST "$BASE/api/state" -H 'Content-Type: application/json' \ + -d '{"endpoint":"managedSettings","preset":"timeout"}' +``` + +The presets exercise HTTP error, malformed response, immediate network failure, +and client-timeout paths respectively. Clear the policy cache if the request +does not appear in **Live Requests**. | Method | Route | Purpose | | --- | --- | --- | diff --git a/scripts/mock-policy-server/endpoints.ts b/scripts/mock-policy-server/endpoints.ts index 8174804de39a7..92b3a38faa525 100644 --- a/scripts/mock-policy-server/endpoints.ts +++ b/scripts/mock-policy-server/endpoints.ts @@ -29,9 +29,12 @@ export interface EndpointPreset { label: string; description: string; status?: number; + mode?: EndpointResponseMode; body: unknown; } +export type EndpointResponseMode = 'json' | 'malformed-json' | 'disconnect' | 'timeout'; + export interface EndpointDef { /** Stable id used by the API + GUI. */ id: string; @@ -239,6 +242,35 @@ declare var MOCK_POLICY_ENDPOINTS: EndpointDef[]; client_version: '1.132.0', minimum_client_version: '1.133.0' } + }, + { + id: 'server-error', + label: 'Server error (500)', + description: 'Returns an HTTP 500 response to exercise the fail-closed HTTP error path.', + status: 500, + body: { error: 'mock_managed_settings_failure' } + }, + { + id: 'malformed-response', + label: 'Malformed JSON (200)', + description: 'Returns an unterminated JSON response to exercise the malformed-response path.', + status: 200, + mode: 'malformed-json', + body: {} + }, + { + id: 'disconnect', + label: 'Disconnect without response', + description: 'Closes the connection before sending response headers to exercise an immediate network failure.', + mode: 'disconnect', + body: {} + }, + { + id: 'timeout', + label: 'No response (timeout)', + description: 'Leaves the request unanswered until the client times out, then closes the connection.', + mode: 'timeout', + body: {} } ] }, diff --git a/scripts/mock-policy-server/public/app.ts b/scripts/mock-policy-server/public/app.ts index 1765d2a0ae5f2..6940a748ddb25 100644 --- a/scripts/mock-policy-server/public/app.ts +++ b/scripts/mock-policy-server/public/app.ts @@ -12,6 +12,7 @@ * `endpoints.ts` (loaded via an earlier `