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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ the schema's nested
| Schema property (path) | Type in schema | Composition (`x-composition.strategy`) |
|------------------------|----------------|----------------------------------------|
| `permissions.disableBypassPermissionsMode` | string enum `"disable"` | most-restrictive-wins (sticky once set) |
| `model` | string (`auto`, a model family name, or a full model id) | top-level wins over the legacy nested `permissions.model` |
| `permissions.model` | string (legacy location for `model`) | superseded by top-level `model` when both are present; retained for original-schema deployments |
Comment on lines +85 to +86
| `forceRemoteSettingsRefresh` | boolean | MDM wins; controls the server cache rather than a configuration setting |
| `enabledPlugins` | `{ "PLUGIN@MARKETPLACE": boolean }` | deny-wins (false beats true; enterprise denials immutable) |
| `extraKnownMarketplaces` | `{ name: { source, autoUpdate? } }`, source `github` \| `git` \| `directory` | most-restrictive-wins (higher layer is the complete allowlist); explicit `autoUpdate` overrides the client's global plugin auto-update setting for that marketplace |
Expand Down Expand Up @@ -112,6 +114,15 @@ Note the schema's `x-composition` describes the **server/runtime** layering acro
enterprise/org/user. Inside VS Code the bag has already been collapsed to a single
projected `ManagedSettingsData` before a `policy.value()` callback ever sees it.

> **Multi-key precedence (`model`).** The channel merge in `pickManagedSettings` only resolves the
> *same* key across delivery channels; it does not know that top-level `model` supersedes the legacy
> nested `permissions.model`. That cross-key precedence is resolved in the policy's `value()`
> callback (`managedModelValue` in `copilotManagedSettings.ts`), which reads the top-level key first
> and falls back to the legacy key (treating a blank value as unset). Because it is key-level, a
> non-empty top-level `model` wins even when `permissions.model` was supplied by a
> higher-precedence channel. The `ChatDefaultModel` policy declares **both** keys in its
> `managedSettings` so native MDM watches each and projection keeps them.

## Declaring a managed setting on a policy

A policy that should be driven by a managed-settings key declares two things on its
Expand Down Expand Up @@ -234,6 +245,8 @@ Constants (also in `copilotManagedSettings.ts`):
| `GITHUB_COPILOT_WIN32_POLICY_NAME` | `GitHubCopilot` (productName for the watcher) |
| `GITHUB_COPILOT_MACOS_BUNDLE_ID` | `com.github.copilot` (CFPreferences app id) |
| `COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY` | `permissions.disableBypassPermissionsMode` |
| `COPILOT_TOP_LEVEL_MODEL_KEY` | `model` (canonical; wins over the legacy nested key) |
| `COPILOT_MODEL_KEY` | `permissions.model` (legacy; retained for original-schema deployments) |
| `COPILOT_ENABLED_PLUGINS_KEY` | `enabledPlugins` |
| `COPILOT_EXTRA_MARKETPLACES_KEY` | `extraKnownMarketplaces` |
| `COPILOT_STRICT_MARKETPLACES_KEY` | `strictKnownMarketplaces` |
Expand Down
52 changes: 38 additions & 14 deletions src/vs/platform/policy/common/copilotManagedSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,24 @@ export const COPILOT_ALLOW_MANAGED_MCP_SERVERS_ONLY_CONFIG = 'chat.mcp.allowMana
export const COPILOT_ALLOW_MANAGED_HOOKS_ONLY_CONFIG = 'chat.hooks.allowManagedOnly';

/**
* Managed-settings key for the default chat model (carried as a plain string: `auto`, a model
* family name, or a full model id). Nested under `permissions` in the managed-settings schema
* Legacy managed-settings key for the default chat model (carried as a plain string: `auto`, a
* model family name, or a full model id). Nested under `permissions` in the managed-settings schema
* (alongside {@link COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY}), so it flattens to the dot-path
* `permissions.model` in the normalized bag — the key policy `value()` callbacks must read.
* `permissions.model` in the normalized bag. Retained for deployments authored against the original
* schema; new deployments use the top-level {@link COPILOT_TOP_LEVEL_MODEL_KEY}, which wins when
* both are present. See {@link managedModelValue} for the precedence.
Comment on lines +79 to +81
*/
export const COPILOT_MODEL_KEY = 'permissions.model';

/**
* Top-level managed-settings key for the default chat model (carried as a plain string: `auto`, a
* model family name, or a full model id). This is the canonical location in the current
* managed-settings schema; it flattens to the bag key `model`. It supersedes the legacy nested
* {@link COPILOT_MODEL_KEY} — when both are present the top-level value wins (see
* {@link managedModelValue}).
*/
export const COPILOT_TOP_LEVEL_MODEL_KEY = 'model';

/**
* Enterprise OTel managed-settings keys. These are the scalar leaves of the canonical
* `telemetry` block from the cross-client managed-settings schema (see the CLI
Expand Down Expand Up @@ -150,23 +161,36 @@ export function shouldForceRemoteSettingsRefresh(nativeMdm: ManagedSettingsData
let managedModelValueCallback: ((policyData: IPolicyData) => ManagedSettingValue | undefined) | undefined;

/**
* `value` callback for the default-chat-model managed setting ({@link COPILOT_MODEL_KEY}). Like
* {@link managedSettingValue} it locks the setting to the managed value and otherwise falls through
* to the user's own value, but it additionally trims the string and treats a blank/whitespace-only
* value as "unset" (returns `undefined`) — an admin clearing the field must not lock the setting to
* an empty string. The model-specific normalization lives here, alongside the other managed-settings
* handling, rather than inline at the policy declaration, so every managed-settings control is wired
* the same way.
* Trim a managed-settings model value, treating a blank/whitespace-only string as "unset". An admin
* clearing the field must not lock the setting to an empty string, and a blank top-level value must
* fall through to the legacy key rather than mask it.
*/
function normalizeModelValue(value: ManagedSettingValue | undefined): string | undefined {
const trimmed = typeof value === 'string' ? value.trim() : undefined;
return trimmed ? trimmed : undefined;
}

/**
* `value` callback for the default-chat-model managed setting. Resolves the top-level
* {@link COPILOT_TOP_LEVEL_MODEL_KEY} first and falls back to the legacy nested
* {@link COPILOT_MODEL_KEY} — so a deployment on either schema shape works, and when both are
* present the top-level value wins. Like {@link managedSettingValue} it locks the setting to the
* managed value and otherwise falls through to the user's own value, but it additionally trims each
* candidate and treats a blank/whitespace-only value as "unset" (an admin clearing the top-level
* field falls through to the legacy key, and clearing both returns `undefined`). This precedence is
* key-level: a non-empty top-level `model` wins even when the legacy key was supplied by a
* higher-precedence delivery channel. The model-specific normalization lives here, alongside the
* other managed-settings handling, rather than inline at the policy declaration, so every
* managed-settings control is wired the same way.
*
* Memoized (single key) so repeated calls return the SAME function reference, matching the
* Memoized (single callback) so repeated calls return the SAME function reference, matching the
* reference-identity contract {@link managedSettingValue} relies on for `isSamePolicyDefinition`.
*/
export function managedModelValue(): (policyData: IPolicyData) => ManagedSettingValue | undefined {
if (!managedModelValueCallback) {
managedModelValueCallback = policyData => {
const model = policyData.managedSettings?.[COPILOT_MODEL_KEY];
const trimmed = typeof model === 'string' ? model.trim() : undefined;
return trimmed ? trimmed : undefined;
const topLevel = normalizeModelValue(policyData.managedSettings?.[COPILOT_TOP_LEVEL_MODEL_KEY]);
return topLevel ?? normalizeModelValue(policyData.managedSettings?.[COPILOT_MODEL_KEY]);
Comment on lines +192 to +193
};
}
return managedModelValueCallback;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, hasManagedSettingsDefinitions, 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, shouldForceRemoteSettingsRefresh } from '../../common/copilotManagedSettings.js';
import { PolicyDefinition } from '../../common/policy.js';

suite('Copilot managed settings projection', () => {
Expand Down Expand Up @@ -74,6 +74,38 @@ suite('Copilot managed settings projection', () => {
);
});

test('managedModelValue prefers the top-level key, falls back to the legacy nested key', () => {
const value = managedModelValue();
assert.deepStrictEqual(
{
bothPresent: value({ managedSettings: { [COPILOT_TOP_LEVEL_MODEL_KEY]: 'opus', [COPILOT_MODEL_KEY]: 'gemini' } } as IPolicyData),
topLevelOnly: value({ managedSettings: { [COPILOT_TOP_LEVEL_MODEL_KEY]: 'opus' } } as IPolicyData),
legacyOnly: value({ managedSettings: { [COPILOT_MODEL_KEY]: 'gemini' } } as IPolicyData),
neither: value({ managedSettings: { 'other.key': 'x' } } as IPolicyData),
noBag: value({} as IPolicyData),
},
{ bothPresent: 'opus', topLevelOnly: 'opus', legacyOnly: 'gemini', neither: undefined, noBag: undefined },
);
});

test('managedModelValue trims values and treats a blank top-level value as unset (falls through to legacy)', () => {
const value = managedModelValue();
assert.deepStrictEqual(
{
trimsTopLevel: value({ managedSettings: { [COPILOT_TOP_LEVEL_MODEL_KEY]: ' opus ' } } as IPolicyData),
trimsLegacy: value({ managedSettings: { [COPILOT_MODEL_KEY]: ' gemini ' } } as IPolicyData),
blankTopLevelFallsBack: value({ managedSettings: { [COPILOT_TOP_LEVEL_MODEL_KEY]: ' ', [COPILOT_MODEL_KEY]: 'gemini' } } as IPolicyData),
bothBlank: value({ managedSettings: { [COPILOT_TOP_LEVEL_MODEL_KEY]: ' ', [COPILOT_MODEL_KEY]: ' ' } } as IPolicyData),
nonString: value({ managedSettings: { [COPILOT_TOP_LEVEL_MODEL_KEY]: 42 } } as IPolicyData),
},
{ trimsTopLevel: 'opus', trimsLegacy: 'gemini', blankTopLevelFallsBack: 'gemini', bothBlank: undefined, nonString: undefined },
);
});

test('managedModelValue returns the same memoized callback (stable reference identity)', () => {
assert.strictEqual(managedModelValue(), managedModelValue());
});

test('forceRemoteSettingsRefresh uses native MDM over the cached server value', () => {
assert.deepStrictEqual({
serverTrue: shouldForceRemoteSettingsRefresh(undefined, { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: true }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesy
import { NullLogService } from '../../../log/common/log.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js';
import { COPILOT_ALLOW_MANAGED_HOOKS_ONLY_KEY, COPILOT_ALLOW_MANAGED_MCP_SERVERS_ONLY_KEY, COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_ENABLED_PLUGINS_KEY, COPILOT_EXTRA_MARKETPLACES_KEY, COPILOT_MODEL_KEY, COPILOT_STRICT_PLUGIN_ONLY_CUSTOMIZATION_KEY, managedModelValue, normalizeManagedSettings, RawManagedSettingsData } from '../../common/copilotManagedSettings.js';
import { COPILOT_ALLOW_MANAGED_HOOKS_ONLY_KEY, COPILOT_ALLOW_MANAGED_MCP_SERVERS_ONLY_KEY, COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_ENABLED_PLUGINS_KEY, COPILOT_EXTRA_MARKETPLACES_KEY, COPILOT_MODEL_KEY, COPILOT_STRICT_PLUGIN_ONLY_CUSTOMIZATION_KEY, COPILOT_TOP_LEVEL_MODEL_KEY, managedModelValue, normalizeManagedSettings, RawManagedSettingsData } from '../../common/copilotManagedSettings.js';
import { FileManagedSettingsService } from '../../common/fileManagedSettingsService.js';
import { FileManagedSettingsChannelClient } from '../../common/fileManagedSettingsIpc.js';

Expand Down Expand Up @@ -129,6 +129,34 @@ suite('normalizeManagedSettings', () => {
assert.strictEqual(managedModelValue()({ managedSettings: result }), 'auto');
});

test('carries the top-level model setting as the `model` bag key', () => {
// The current managed-settings schema carries `model` at the top level; as a scalar leaf it
// flattens to the bag key `model`, which the ChatDefaultModel policy value callback reads
// with precedence over the legacy nested key.
Comment on lines +133 to +135
const result = normalizeManagedSettings({
model: 'auto'
});
assert.deepStrictEqual(result, {
'model': 'auto'
});
assert.strictEqual(COPILOT_TOP_LEVEL_MODEL_KEY, 'model');
assert.strictEqual(managedModelValue()({ managedSettings: result }), 'auto');
});

test('keeps top-level and legacy model keys distinct, with the top-level value winning', () => {
// A payload authored against both schema shapes flattens to two distinct bag keys; the
// policy value callback resolves the top-level one.
const result = normalizeManagedSettings({
model: 'opus',
permissions: { model: 'gemini' }
});
assert.deepStrictEqual(result, {
'model': 'opus',
'permissions.model': 'gemini'
});
assert.strictEqual(managedModelValue()({ managedSettings: result }), 'opus');
});

test('handles empty object', () => {
assert.deepStrictEqual(normalizeManagedSettings({}), {});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { AgentHostMapLegacySettingsToManagedSettingsSettingId } from '../../../.
import { DEFAULT_LOCAL_TRANSCRIPTION_MODEL } from '../../../../platform/localTranscription/common/localTranscription.js';
import { AgentNetworkFilterService, IAgentNetworkFilterService } from '../../../../platform/networkFilter/common/networkFilterService.js';
import { AgentNetworkDomainSettingId } from '../../../../platform/networkFilter/common/settings.js';
import { COPILOT_ALLOWED_MCP_SERVERS_KEY, COPILOT_ALLOW_MANAGED_HOOKS_ONLY_CONFIG, COPILOT_ALLOW_MANAGED_HOOKS_ONLY_KEY, COPILOT_ALLOW_MANAGED_MCP_SERVERS_ONLY_CONFIG, COPILOT_ALLOW_MANAGED_MCP_SERVERS_ONLY_KEY, COPILOT_DENIED_MCP_SERVERS_KEY, COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_ENABLED_PLUGINS_KEY, COPILOT_EXTRA_MARKETPLACES_KEY, COPILOT_MODEL_KEY, COPILOT_STRICT_MARKETPLACES_KEY, COPILOT_STRICT_PLUGIN_ONLY_CUSTOMIZATION_CONFIG, COPILOT_STRICT_PLUGIN_ONLY_CUSTOMIZATION_KEY, managedModelValue, managedSettingValue } from '../../../../platform/policy/common/copilotManagedSettings.js';
import { COPILOT_ALLOWED_MCP_SERVERS_KEY, COPILOT_ALLOW_MANAGED_HOOKS_ONLY_CONFIG, COPILOT_ALLOW_MANAGED_HOOKS_ONLY_KEY, COPILOT_ALLOW_MANAGED_MCP_SERVERS_ONLY_CONFIG, COPILOT_ALLOW_MANAGED_MCP_SERVERS_ONLY_KEY, COPILOT_DENIED_MCP_SERVERS_KEY, COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_ENABLED_PLUGINS_KEY, COPILOT_EXTRA_MARKETPLACES_KEY, COPILOT_MODEL_KEY, COPILOT_STRICT_MARKETPLACES_KEY, COPILOT_STRICT_PLUGIN_ONLY_CUSTOMIZATION_CONFIG, COPILOT_STRICT_PLUGIN_ONLY_CUSTOMIZATION_KEY, COPILOT_TOP_LEVEL_MODEL_KEY, managedModelValue, managedSettingValue } from '../../../../platform/policy/common/copilotManagedSettings.js';
import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../platform/sandbox/common/settings.js';
import { ChatSessionArchiveActionWordingSettingId } from '../../../../platform/chat/common/sessionArchiveActions.js';
import { registerEditorFeature } from '../../../../editor/common/editorFeatures.js';
Expand Down Expand Up @@ -661,6 +661,7 @@ configurationRegistry.registerConfiguration({
value: managedModelValue(),
managedSettings: {
[COPILOT_MODEL_KEY]: { type: 'string' },
[COPILOT_TOP_LEVEL_MODEL_KEY]: { type: 'string' },
},
localization: {
description: {
Expand Down
11 changes: 11 additions & 0 deletions src/vs/workbench/services/accounts/browser/managedSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,18 @@ export type IManagedMcpServerMatcher =
export interface IManagedSettingsResponse {
readonly permissions?: {
readonly disableBypassPermissionsMode?: string;
/**
* Legacy location for the default chat model. Retained for deployments authored against
* the original schema; the top-level {@link IManagedSettingsResponse.model} wins when both
* are present.
*/
readonly model?: string;
};
/**
* Default chat model (`auto`, a model family name, or a full model id). Canonical top-level
* location in the current schema; supersedes the legacy nested `permissions.model`.
*/
readonly model?: string;
readonly enabledPlugins?: Record<string, boolean>;
readonly extraKnownMarketplaces?: Record<string, {
readonly source:
Expand Down
Loading