From 6f18053a10f9cd675d7320a6426cc79b4247e5b7 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:33:40 -0700 Subject: [PATCH 01/12] Map managed VS Code policies into Agent Host managedSettings.permissions Synthesize a client-agnostic managedSettings.permissions object from VS Code's legacy enterprise Copilot/agent policies and forward it through the Agent Host root config to the Copilot SDK at session create/resume. Derive exclusively from IConfigurationService.inspect(...).policyValue, using only rule boundaries the runtime supports: - managed chat.tools.global.autoApprove === false -> disableBypassPermissionsMode: 'disable' - managed chat.tools.terminal.enableAutoApprove === false -> ask: ['Shell(*)'] Per-tool eligibility (chat.tools.eligibleForAutoApproval) is intentionally not mapped: the runtime rejects generic Tool(...) rules, so there is no supported boundary to express it, and malformed/unknown rules reject session startup. Network/sandbox policies are out of scope. The derived snapshot participates in restart detection so a policy change refreshes the session before the next turn. The published @github/copilot-sdk (^1.0.8) does not yet expose managedSettings, so a precise additive local type mirrors the existing local-type precedent (ICopilotRuntimeManagedSettingsSdk) until the SDK publishes the field. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c111c62a-eff3-4ff6-bb8a-8436a1b4babe --- .../browser/remoteAgentHostProtocolClient.ts | 25 +++- .../agentHost/common/agentHostSchema.ts | 128 ++++++++++++++++++ .../agentHost/node/copilot/copilotAgent.ts | 10 +- .../node/copilot/copilotSessionLauncher.ts | 30 +++- .../test/common/agentHostSchema.test.ts | 52 ++++++- .../remoteAgentHostProtocolClient.test.ts | 52 ++++++- .../agentHost/test/node/copilotAgent.test.ts | 23 +++- .../test/node/copilotSessionLauncher.test.ts | 74 ++++++++++ 8 files changed, 387 insertions(+), 7 deletions(-) diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 09d9aa7e09ce41..ad501c0fac3640 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -38,7 +38,7 @@ import { encodeBase64 } from '../../../base/common/buffer.js'; import { ILoadEstimator, LoadEstimator } from '../../../base/parts/ipc/common/ipc.net.js'; import { TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID } from '../../telemetry/common/telemetry.js'; import { getTelemetryLevel } from '../../telemetry/common/telemetryUtils.js'; -import { AgentHostTelemetryLevelConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, getAgentHostTerminalAutoApproveRulesConfig, PREFER_LONG_CONTEXT_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, DISABLE_REPO_INFO_TELEMETRY_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; +import { AgentHostTelemetryLevelConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostManagedPermissionsConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, getAgentHostTerminalAutoApproveRulesConfig, PREFER_LONG_CONTEXT_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, deriveManagedPermissions, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, DISABLE_REPO_INFO_TELEMETRY_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; import { getAgentHostConfigurationSyncEntries, resolveAgentHostConfigurationSyncPatch, resolveAgentHostConfigurationSyncValue } from '../common/agentHostConfigurationSync.js'; import { toClientConnectionTelemetryMeta } from '../common/agentHostTelemetry.js'; import type { OtlpExportLogsParams } from '../common/state/protocol/channels-otlp/notifications.js'; @@ -361,6 +361,12 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC if (e.affectsConfiguration(TELEMETRY_SETTING_ID) || e.affectsConfiguration(TELEMETRY_OLD_SETTING_ID) || e.affectsConfiguration(TELEMETRY_CRASH_REPORTER_SETTING_ID)) { this._updateTelemetryLevel(); } + if ( + e.affectsConfiguration(GLOBAL_AUTO_APPROVE_SETTING_ID) || + e.affectsConfiguration(TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID) + ) { + this._updateManagedPermissions(); + } if (e.affectsConfiguration(PREFER_LONG_CONTEXT_SETTING_ID)) { this._updatePreferLongContextEnabled(); } @@ -692,6 +698,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC private _forwardClientConfig(): void { this._dispatchRootConfig(resolveAgentHostConfigurationSyncPatch(this._configurationService, this._resourceIdentity === LOCAL_AGENT_HOST_RESOURCE_IDENTITY)); this._updateTelemetryLevel(); + this._updateManagedPermissions(); this._updatePreferLongContextEnabled(); this._updateTerminalAutoApproveEnabled(); this._updateTerminalAutoApproveRules(); @@ -1492,6 +1499,22 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC this._dispatchRootConfig({ [AgentHostDisableRepoInfoTelemetryConfigKey]: disabled }); } + /** + * Forward the enterprise-policy-derived managed permissions to the agent + * host. Derived EXCLUSIVELY from the managed (policy) values of the source + * settings via `inspect(...).policyValue` — user/workspace values are + * ignored so only enterprise policy affects the runtime's + * `managedSettings.permissions`. When no policy applies, the derived value + * is `undefined` and the root config key is cleared. + */ + private _updateManagedPermissions(): void { + const permissions = deriveManagedPermissions({ + globalAutoApprove: this._configurationService.inspect(GLOBAL_AUTO_APPROVE_SETTING_ID).policyValue, + terminalAutoApproveEnabled: this._configurationService.inspect(TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID).policyValue, + }); + this._dispatchRootConfig({ [AgentHostManagedPermissionsConfigKey]: permissions }); + } + private _updatePreferLongContextEnabled(): void { const enabled = this._configurationService.getValue(PREFER_LONG_CONTEXT_SETTING_ID) === true; this._dispatchRootConfig({ [AgentHostPreferLongContextEnabledConfigKey]: enabled }); diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 0a0fe2c546a633..a2aecd85443149 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -297,6 +297,118 @@ const permissionsProperty = schemaProperty({ sessionMutable: true, }); +/** + * The client-agnostic managed-permission shape VS Code synthesizes from its + * legacy enterprise policy values and forwards to the runtime as + * `managedSettings.permissions` at SDK session startup. Field names and rule + * grammar match the runtime managed-permission contract, NOT any VS Code + * setting: `disableBypassPermissionsMode` locks out "Allow all", and + * `deny`/`ask`/`allow` are arrays of runtime permission-rule strings. The + * runtime accepts only a fixed set of rule boundaries (`Bash`, `Shell`, + * `PowerShell`, `Read`, `Edit`, `Write`, `Domain`); unknown/malformed rules + * reject session startup, so VS Code must emit only exact, supported tokens. + * Rules are composed restrictively by the runtime, so an `ask` rule can only + * add friction and never grants approval. + */ +export interface IManagedPermissions { + readonly disableBypassPermissionsMode?: 'disable'; + readonly deny?: readonly string[]; + readonly ask?: readonly string[]; + readonly allow?: readonly string[]; +} + +/** + * The runtime permission-rule string emitted when managed + * `chat.tools.terminal.enableAutoApprove` is `false`. `Shell(*)` is the exact + * all-shell boundary confirmed by the runtime managed-permission parser. + * Centralized as a single constant so the grammar lives in one place. Generic + * `Tool(...)` rules are NOT supported by the runtime and must never be emitted. + */ +export const MANAGED_PERMISSION_TERMINAL_ASK_RULE = 'Shell(*)'; + +/** + * The enterprise-policy inputs — read exclusively from + * `IConfigurationService.inspect(...).policyValue`, never ordinary + * user/workspace values — that {@link deriveManagedPermissions} maps into the + * client-agnostic {@link IManagedPermissions} object. + */ +export interface IManagedPermissionPolicyInputs { + /** Managed value of `chat.tools.global.autoApprove`. `false` disables bypass ("Allow all"). */ + readonly globalAutoApprove: boolean | undefined; + /** Managed value of `chat.tools.terminal.enableAutoApprove`. `false` adds the all-shell `ask` rule. */ + readonly terminalAutoApproveEnabled: boolean | undefined; +} + +/** + * Translate VS Code's legacy enterprise policy values into the client-agnostic + * {@link IManagedPermissions} object. Only mappings backed by exact, + * runtime-supported permission rules are emitted: + * + * - managed `chat.tools.global.autoApprove === false` → `disableBypassPermissionsMode: "disable"`; + * - managed `chat.tools.terminal.enableAutoApprove === false` → the all-shell `ask` rule `Shell(*)`. + * + * Per-tool eligibility (`chat.tools.eligibleForAutoApproval`) is intentionally + * NOT mapped: the runtime rejects generic `Tool(...)` rules, so there is no + * supported boundary to express it. Network/sandbox policies are out of scope + * for this first pass. + * + * Returns `undefined` when no restrictive policy applies, so callers can omit + * the field entirely rather than forward an empty object. + */ +export function deriveManagedPermissions(inputs: IManagedPermissionPolicyInputs): IManagedPermissions | undefined { + const ask: string[] = []; + let disableBypassPermissionsMode: 'disable' | undefined; + + if (inputs.globalAutoApprove === false) { + disableBypassPermissionsMode = 'disable'; + } + if (inputs.terminalAutoApproveEnabled === false) { + ask.push(MANAGED_PERMISSION_TERMINAL_ASK_RULE); + } + + const permissions: { + disableBypassPermissionsMode?: 'disable'; + ask?: string[]; + } = {}; + if (disableBypassPermissionsMode) { + permissions.disableBypassPermissionsMode = disableBypassPermissionsMode; + } + if (ask.length) { + permissions.ask = ask; + } + + return Object.keys(permissions).length ? permissions : undefined; +} + +const managedPermissionsProperty = schemaProperty({ + type: 'object', + title: localize('agentHost.config.managedPermissions.title', "Managed Permissions"), + description: localize('agentHost.config.managedPermissions.description', "Enterprise-policy-derived permission restrictions forwarded to the runtime as `managedSettings.permissions` at session startup. Synthesized by VS Code from managed policy values; not user-configurable."), + properties: { + disableBypassPermissionsMode: { + type: 'string', + title: localize('agentHost.config.managedPermissions.disableBypass', "Disable bypass permissions mode"), + enum: ['disable'], + }, + deny: { + type: 'array', + title: localize('agentHost.config.managedPermissions.deny', "Denied permission rules"), + items: { type: 'string', title: localize('agentHost.config.managedPermissions.rule', "Permission rule") }, + }, + ask: { + type: 'array', + title: localize('agentHost.config.managedPermissions.ask', "Ask permission rules"), + items: { type: 'string', title: localize('agentHost.config.managedPermissions.rule', "Permission rule") }, + }, + allow: { + type: 'array', + title: localize('agentHost.config.managedPermissions.allow', "Allowed permission rules"), + items: { type: 'string', title: localize('agentHost.config.managedPermissions.rule', "Permission rule") }, + }, + }, + default: {}, +}); + /** * Session-config properties owned by the platform itself — i.e. consumed * by the agent host rather than by any particular agent. @@ -433,6 +545,21 @@ export const TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID = 'chat.tools.terminal.ena */ export const AgentHostGlobalAutoApproveEnabledConfigKey = 'globalAutoApproveEnabled'; +/** + * The VS Code setting ID for global auto approve. Defined here so renderer-side + * agent-host clients can forward it without importing from `workbench/contrib/chat`. + */ +export const GLOBAL_AUTO_APPROVE_SETTING_ID = 'chat.tools.global.autoApprove'; + +/** + * Root config key forwarded from the renderer holding the enterprise-policy-derived + * {@link IManagedPermissions} object. Synthesized by VS Code exclusively from managed + * (policy) values of `chat.tools.global.autoApprove`, `chat.tools.eligibleForAutoApproval`, + * and `chat.tools.terminal.enableAutoApprove`, and forwarded to the runtime as + * `managedSettings.permissions` at SDK session startup. Absent when no policy applies. + */ +export const AgentHostManagedPermissionsConfigKey = 'managedPermissions'; + /** * Root config key forwarded from the renderer when VS Code's `chat.autoReply` * setting changes. When `true`, the agent host auto-answers `ask_user` @@ -714,6 +841,7 @@ export const platformRootSchema = createSchema({ description: localize('agentHost.config.globalAutoApproveEnabled.description', "Whether VS Code's global auto-approve setting is enabled. When `true`, every tool call is auto-approved, equivalent to a session using Allow all."), default: false, }), + [AgentHostManagedPermissionsConfigKey]: managedPermissionsProperty, [AgentHostAutoReplyEnabledConfigKey]: schemaProperty({ type: 'boolean', title: localize('agentHost.config.autoReplyEnabled.title', "Auto Reply"), diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 44e0c98ed10f84..71a5704c02d8d5 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -37,7 +37,7 @@ import { createPricingMetaFromBilling, hasLongContextSurcharge, normalizeCAPIBil import { createAgentModelByokMeta } from '../../common/agentModelByokMeta.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema, DEFAULT_SESSION_CUSTOMIZATION_DISCOVERY_MODE, toContainerCustomization } from '../../common/agentHostCustomizationConfig.js'; import { CopilotCliConfigKey, copilotCliConfigSchema, type CopilotSdkLogLevelSetting } from '../../common/copilotCliConfig.js'; -import { AgentHostMcpServersConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AutoApproveLevel, SessionMode, migrateLegacyAutopilotConfig, platformRootSchema, platformSessionSchema, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; +import { AgentHostMcpServersConfigKey, AgentHostManagedPermissionsConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AutoApproveLevel, SessionMode, migrateLegacyAutopilotConfig, platformRootSchema, platformSessionSchema, type AgentHostMcpServers, type IManagedPermissions } from '../../common/agentHostSchema.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { AgentSessionEntry, decodeProviderData, encodeProviderData, prepareSideChatPrompt, stripSideChatContext, type IPersistedChat } from '../agentPeerChats.js'; import { AgentSession, AgentSignal, AuthenticateParams, IActiveClient, IAgent, IAgentChatDataChange, IAgentChats, IAgentLegacyChat, IAgentCreateChatForkSource, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentHostManagedSettingsSnapshot, IAgentHostNetworkEndpoint, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, IRestoredSubagentSession, SubagentChatSignal } from '../../common/agentService.js'; @@ -5209,6 +5209,7 @@ class ActiveClient extends Disposable { tools: this.toolSet.merged(), plugins: await this.pluginController.getAppliedPlugins(), mcpServers: this._getMcpServers(), + managedPermissions: this._getManagedPermissions(), }; } @@ -5218,6 +5219,10 @@ class ActiveClient extends Disposable { return structuredClone(servers); } + private _getManagedPermissions(): IManagedPermissions | undefined { + return this._configurationService.getRootValue(platformRootSchema, AgentHostManagedPermissionsConfigKey); + } + /** * Returns `true` when the SDK session must be disposed and resumed to * pick up a changed config. Compares ONLY plugins and the structural @@ -5233,6 +5238,9 @@ class ActiveClient extends Disposable { if (!equals(snap.mcpServers, this._getMcpServers())) { return true; } + if (!equals(snap.managedPermissions, this._getManagedPermissions())) { + return true; + } return !this.toolSet.structuralEquals(snap.tools); } } diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 21e569cdb83a33..1fdfb86b775672 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -11,7 +11,7 @@ import { IFileService } from '../../../files/common/files.js'; import { ILogService, LogLevel } from '../../../log/common/log.js'; import { CopilotCliConfigKey, applyModelFamilyAlias, copilotCliConfigSchema, normalizeToolSearchDeferThreshold } from '../../common/copilotCliConfig.js'; import { agentHostModelSupportsToolSearch, CLIENT_TOOL_SEARCH_REFERENCE_NAME } from './toolSearchDeferral.js'; -import { AgentHostSessionSyncEnabledConfigKey, platformRootSchema, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; +import { AgentHostManagedPermissionsConfigKey, AgentHostSessionSyncEnabledConfigKey, platformRootSchema, type AgentHostMcpServers, type IManagedPermissions } from '../../common/agentHostSchema.js'; import { AgentSession } from '../../common/agentService.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; @@ -82,6 +82,19 @@ type McpAuthContext = Parameters[1]; type McpAuthResponse = Awaited>; type PreToolUseHookInput = Parameters>[0]; type PostToolUseHookInput = Parameters>[0]; +/** + * Local mirror of the SDK's `managedSettings` session-config field, scoped to + * the `permissions` object VS Code populates. The published + * `@github/copilot-sdk` types (1.0.8) expose `enableManagedSettings` but not + * `managedSettings`; this precise additive type lets VS Code forward + * enterprise-policy-derived permissions until the SDK publishes the field. + * Mirrors the local-type precedent used for `ICopilotRuntimeManagedSettingsSdk` + * in copilotAgent.ts. + */ +interface ICopilotManagedSettingsSdk { + readonly permissions?: IManagedPermissions; +} + /** * Immutable snapshot of the active client's structural contributions at * session creation time. Used to detect when the session needs to be @@ -95,6 +108,14 @@ export interface IActiveClientSnapshot { readonly tools: readonly ToolDefinition[]; readonly plugins: readonly ICopilotPluginInfo[]; readonly mcpServers: AgentHostMcpServers; + /** + * Enterprise-policy-derived managed permissions in effect at snapshot time. + * Participates in restart detection because it is forwarded into the SDK + * session config as `managedSettings.permissions`; a policy change must + * refresh the session so the new permissions apply before the next turn. + * Optional: `undefined` (or absent) means no managed policy applied. + */ + readonly managedPermissions?: IManagedPermissions | undefined; } /** @@ -573,12 +594,13 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { } } - private async _buildSessionConfig(plan: CopilotSessionLaunchPlan, runtime: ICopilotSessionRuntime): Promise { + private async _buildSessionConfig(plan: CopilotSessionLaunchPlan, runtime: ICopilotSessionRuntime): Promise { const plugins = plan.snapshot.plugins; // Synthesize BYOK provider/model config (empty when BYOK is gated off or the // renderer reports no BYOK models), merged into the returned config so both // createSession and resumeSession advertise the models to the runtime. const byok = await this._resolveByokSessionConfig(plan.sessionId); + const managedPermissions = this._configurationService.getRootValue(platformRootSchema, AgentHostManagedPermissionsConfigKey); const enableCustomTerminalTool = this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.EnableCustomTerminalTool) === true; let shellTools: Awaited> = []; if (enableCustomTerminalTool) { @@ -677,6 +699,10 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { // events. Without this, sessions default to "off". remoteSession: this._configurationService.getRootValue(platformRootSchema, AgentHostSessionSyncEnabledConfigKey) === true ? 'export' : undefined, enableManagedSettings: true, + // Forward enterprise-policy-derived managed permissions (synthesized + // by VS Code from managed policy values) as the runtime's + // `managedSettings.permissions`. Omitted when no policy applies. + ...(managedPermissions ? { managedSettings: { permissions: managedPermissions } } : {}), }; } } diff --git a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts index 11a8947c33e818..e0101e8ee814e5 100644 --- a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import type { IConfigurationValue } from '../../../configuration/common/configuration.js'; -import { createSchema, migrateLegacyAutopilotConfig, normalizeAgentHostTerminalAutoApproveRulesConfig, platformSessionSchema, schemaProperty, type AgentHostTerminalAutoApproveRules, type AutoApproveLevel, type IPermissionsValue, type SessionMode } from '../../common/agentHostSchema.js'; +import { createSchema, deriveManagedPermissions, migrateLegacyAutopilotConfig, normalizeAgentHostTerminalAutoApproveRulesConfig, platformRootSchema, platformSessionSchema, schemaProperty, AgentHostManagedPermissionsConfigKey, MANAGED_PERMISSION_TERMINAL_ASK_RULE, type AgentHostTerminalAutoApproveRules, type AutoApproveLevel, type IManagedPermissions, type IPermissionsValue, type SessionMode } from '../../common/agentHostSchema.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js'; @@ -414,4 +414,54 @@ suite('agentHostSchema', () => { }); }); }); + + suite('deriveManagedPermissions', () => { + + test('returns undefined when no policy applies', () => { + assert.strictEqual(deriveManagedPermissions({ + globalAutoApprove: undefined, + terminalAutoApproveEnabled: undefined, + }), undefined); + }); + + test('permissive policy values map to nothing', () => { + assert.strictEqual(deriveManagedPermissions({ + globalAutoApprove: true, + terminalAutoApproveEnabled: true, + }), undefined); + }); + + test('maps restrictive policies into supported rules', () => { + assert.deepStrictEqual(deriveManagedPermissions({ + globalAutoApprove: false, + terminalAutoApproveEnabled: false, + }), { + disableBypassPermissionsMode: 'disable', + ask: [MANAGED_PERMISSION_TERMINAL_ASK_RULE], + } satisfies IManagedPermissions); + }); + + test('emits only disableBypassPermissionsMode when only global auto-approve is denied', () => { + assert.deepStrictEqual(deriveManagedPermissions({ + globalAutoApprove: false, + terminalAutoApproveEnabled: undefined, + }), { disableBypassPermissionsMode: 'disable' } satisfies IManagedPermissions); + }); + + test('emits only the all-shell ask rule when only terminal auto-approve is denied', () => { + assert.deepStrictEqual(deriveManagedPermissions({ + globalAutoApprove: undefined, + terminalAutoApproveEnabled: false, + }), { ask: ['Shell(*)'] } satisfies IManagedPermissions); + }); + + test('derived value validates against the managed-permissions root schema', () => { + const permissions = deriveManagedPermissions({ + globalAutoApprove: false, + terminalAutoApproveEnabled: false, + }); + assert.ok(permissions); + assert.strictEqual(platformRootSchema.validate(AgentHostManagedPermissionsConfigKey, permissions), true); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts index eb3948594d012e..d6cba4529dc4e2 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts @@ -29,7 +29,7 @@ import { CustomizationType, MessageAttachmentKind, MessageKind, PendingMessageKi import type { IClientTransport, IProtocolTransport } from '../../common/state/sessionTransport.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; import { TelemetryLevel } from '../../../telemetry/common/telemetry.js'; -import { AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostTelemetryLevelConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, DISABLE_REPO_INFO_TELEMETRY_SETTING_ID, telemetryLevelToAgentHostConfigValue, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, type AgentHostTerminalAutoApproveRules } from '../../common/agentHostSchema.js'; +import { AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostManagedPermissionsConfigKey, AgentHostTelemetryLevelConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, DISABLE_REPO_INFO_TELEMETRY_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, MANAGED_PERMISSION_TERMINAL_ASK_RULE, telemetryLevelToAgentHostConfigValue, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, type AgentHostTerminalAutoApproveRules } from '../../common/agentHostSchema.js'; import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../configuration/common/configurationRegistry.js'; import { Registry } from '../../../registry/common/platform.js'; @@ -176,6 +176,27 @@ class TerminalAutoApproveConfigurationService extends TestConfigurationService { } } +/** + * Supplies `policyValue` (the managed/enterprise value) for the managed + * permission source settings so that the managed-permissions forwarding, which + * derives EXCLUSIVELY from `inspect(...).policyValue`, can be exercised + * independently of ordinary user/workspace values. + */ +class ManagedPermissionPolicyConfigurationService extends TestConfigurationService { + + constructor(private readonly _policyValues: Record) { + super(); + } + + override inspect(key: string): IConfigurationValue { + const base = super.inspect(key); + if (Object.prototype.hasOwnProperty.call(this._policyValues, key)) { + return { ...base, policyValue: this._policyValues[key] as T }; + } + return base; + } +} + suite('RemoteAgentHostProtocolClient', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -913,6 +934,35 @@ suite('RemoteAgentHostProtocolClient', () => { }); }); + test('derives managed permissions only from policy values and forwards them on connect', async () => { + const configurationService = new ManagedPermissionPolicyConfigurationService({ + [GLOBAL_AUTO_APPROVE_SETTING_ID]: false, + [TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID]: false, + }); + const { client, transport } = createClient(disposables.add(new TestProtocolTransport()), createPermissionService(), undefined, new NullLogService(), configurationService); + await connectClient(client, transport); + + const managed = findRootConfigNotification(transport.sentMessages, AgentHostManagedPermissionsConfigKey); + assert.deepStrictEqual(getRootConfig(managed)[AgentHostManagedPermissionsConfigKey], { + disableBypassPermissionsMode: 'disable', + ask: [MANAGED_PERMISSION_TERMINAL_ASK_RULE], + }); + }); + + test('forwards undefined managed permissions when only non-policy values are set', async () => { + // User/workspace values are restrictive, but no enterprise policy is set — + // only `policyValue` maps, so nothing must be forwarded. + const configurationService = new TestConfigurationService({ + [GLOBAL_AUTO_APPROVE_SETTING_ID]: false, + [TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID]: false, + }); + const { client, transport } = createClient(disposables.add(new TestProtocolTransport()), createPermissionService(), undefined, new NullLogService(), configurationService); + await connectClient(client, transport); + + const managed = findRootConfigNotification(transport.sentMessages, AgentHostManagedPermissionsConfigKey); + assert.strictEqual(getRootConfig(managed)[AgentHostManagedPermissionsConfigKey], undefined); + }); + test('forwards the repo-info telemetry debug switch on connect and change', async () => { const configurationService = new TestConfigurationService({ [DISABLE_REPO_INFO_TELEMETRY_SETTING_ID]: true }); const { client, transport } = createClient(disposables.add(new TestProtocolTransport()), createPermissionService(), undefined, new NullLogService(), configurationService); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index af8bf5363b5d7a..1f3ee8232eae04 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -33,7 +33,7 @@ import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService, NullTelemetryServiceShape } from '../../../telemetry/common/telemetryUtils.js'; import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; import { CopilotCliConfigKey } from '../../common/copilotCliConfig.js'; -import { AgentHostCopilotMultiRootEnabledConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey } from '../../common/agentHostSchema.js'; +import { AgentHostCopilotMultiRootEnabledConfigKey, AgentHostManagedPermissionsConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey } from '../../common/agentHostSchema.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { getTelemetryChatSessionId } from '../../common/agentTelemetryCorrelation.js'; import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, type AgentSignal, type IAgentCreateChatForkSource, type IAgentSessionMetadata, type IAgentSpawnChatEvent } from '../../common/agentService.js'; @@ -5800,6 +5800,27 @@ suite('CopilotAgent', () => { } }); + test('a managed-permissions policy change requires a restart', async () => { + const { agent, configurationService } = createTestAgentContext(disposables); + try { + const session = AgentSession.uri('copilotcli', 'managed-perms-change-session'); + + agent.getOrCreateActiveClient(session, { clientId: 'client-A' }).tools = tools; + const activeClient = getActiveClient(agent, session); + const appliedSnapshot = await activeClient.snapshot(); + assert.strictEqual(await activeClient.requiresRestart(appliedSnapshot), false); + + // An enterprise policy change updates the forwarded managed + // permissions; the SDK session must restart so the new + // `managedSettings.permissions` apply before the next turn. + configurationService.updateRootConfig({ [AgentHostManagedPermissionsConfigKey]: { disableBypassPermissionsMode: 'disable' } }); + + assert.strictEqual(await activeClient.requiresRestart(appliedSnapshot), true); + } finally { + await disposeAgent(agent); + } + }); + test('multiple active clients merge their tools and removal isolates per client', async () => { const agent = createTestAgent(disposables); try { diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts index 9bbe89c94021aa..cc4912098b1800 100644 --- a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -25,6 +25,7 @@ import { ByokLmBridgeRegistry, IByokLmBridgeRegistry } from '../../node/byokLmBr import { ByokLmProxyService, IByokLmProxyService, type IByokLmProxyHandle } from '../../node/copilot/byokLmProxyService.js'; import { CopilotSessionLauncher, getCopilotReasoningEffort, isCopilotReasoningEffort, resolveByokSessionConfig, type CopilotSessionLaunchPlan, type ICopilotSessionRuntime } from '../../node/copilot/copilotSessionLauncher.js'; import type { ICopilotPluginInfo } from '../../node/copilot/copilotAgent.js'; +import { AgentHostManagedPermissionsConfigKey } from '../../common/agentHostSchema.js'; const testRuntime: ICopilotSessionRuntime = { handlePermissionRequest: async () => { throw new Error('Unexpected permission request'); }, @@ -61,6 +62,26 @@ function createTestLauncher(): CopilotSessionLauncher { ); } +function createTestLauncherWithRootValues(values: Record): CopilotSessionLauncher { + const configurationService = { + getRootValue: (_schema: unknown, key: string) => values[key], + } as Partial as IAgentConfigurationService; + return new CopilotSessionLauncher( + configurationService, + {} as IAgentHostTerminalManager, + new NullLogService(), + {} as IFileService, + { _serviceBrand: undefined, start: async () => { throw new Error('Unexpected proxy start'); }, dispose: () => { } }, + new ByokLmBridgeRegistry(), + { + _serviceBrand: undefined, + getSessionTraceContext: () => undefined, + releaseSessionTraceContext: () => { }, + withTraceContext: (_context: undefined, fn: () => T): T => fn(), + } as unknown as IAgentHostOTelService, + ); +} + /** * Covers the BYOK provider/model synthesis the launcher feeds into * `createSession` / `resumeSession`. The first four tests pin the gating and @@ -423,6 +444,59 @@ suite('CopilotSessionLauncher shared session config', () => { await launcher.disposeByokProxyHandle(); } }); + + test('forwards managed permissions from root config into create and resume configs', async () => { + const createConfigs: Parameters[0][] = []; + const resumeConfigs: Parameters[1][] = []; + const session = { + sessionId: 'session-1', + on: () => () => { }, + disconnect: async () => { }, + } as unknown as CopilotSession; + const client = { + createSession: async (config: Parameters[0]) => { + createConfigs.push(config); + return session; + }, + resumeSession: async (_sessionId: string, config: Parameters[1]) => { + resumeConfigs.push(config); + return session; + }, + }; + const permissions = { disableBypassPermissionsMode: 'disable', ask: ['Shell(*)'] }; + const launcher = createTestLauncherWithRootValues({ [AgentHostManagedPermissionsConfigKey]: permissions }); + const basePlan = { + client, + sessionId: 'session-1', + workingDirectory: testWorkingDirectory, + resolvedAgentName: undefined, + snapshot: { tools: [], plugins: [], mcpServers: {} }, + activeClientToolSet: new ActiveClientToolSet(), + shellManager: undefined, + githubToken: undefined, + }; + const createPlan: CopilotSessionLaunchPlan = { ...basePlan, kind: 'create', model: undefined }; + const resumePlan: CopilotSessionLaunchPlan = { ...basePlan, kind: 'resume', fallback: { model: undefined } }; + + const sessions = new DisposableStore(); + try { + sessions.add(await launcher.launch(createPlan, testRuntime)); + sessions.add(await launcher.launch(resumePlan, testRuntime)); + + assert.deepStrictEqual({ + create: (createConfigs[0] as { managedSettings?: unknown }).managedSettings, + createEnable: (createConfigs[0] as { enableManagedSettings?: boolean }).enableManagedSettings, + resume: (resumeConfigs[0] as { managedSettings?: unknown }).managedSettings, + }, { + create: { permissions }, + createEnable: true, + resume: { permissions }, + }); + } finally { + sessions.dispose(); + await launcher.disposeByokProxyHandle(); + } + }); }); suite('CopilotSessionLauncher resume fallback', () => { From 190bddc46bc31edee387a86c26e91d284aeb7678 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:11:47 -0700 Subject: [PATCH 02/12] Address review: drop managedPermissions schema default and stale doc The `managedPermissions` root config key follows omit semantics: it is absent when no restrictive enterprise policy applies. Its schema property carried a `default: {}`, which is seeded into stored root config values by `registerProviderConfiguration` and would let the launcher forward an empty (and misleading) `managedSettings` even with no policy. Remove the default so the key stays `undefined` and `managedSettings` is omitted entirely. Also remove the stale `chat.tools.eligibleForAutoApproval` reference from the `AgentHostManagedPermissionsConfigKey` doc comment (eligibility is no longer synthesized after the runtime-contract correction). Add a launcher test asserting neither create nor resume config carries `managedSettings` when the root value is unset. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c111c62a-eff3-4ff6-bb8a-8436a1b4babe --- .../agentHost/common/agentHostSchema.ts | 9 ++-- .../test/node/copilotSessionLauncher.test.ts | 53 +++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index a2aecd85443149..09fd8b457c44df 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -406,7 +406,10 @@ const managedPermissionsProperty = schemaProperty({ items: { type: 'string', title: localize('agentHost.config.managedPermissions.rule', "Permission rule") }, }, }, - default: {}, + // Intentionally NO `default`: the key follows omit semantics. When no + // restrictive policy applies the renderer forwards `undefined`, so + // `getRootValue` stays `undefined` and the launcher omits `managedSettings` + // entirely rather than forwarding an empty (and misleading) object. }); /** @@ -554,8 +557,8 @@ export const GLOBAL_AUTO_APPROVE_SETTING_ID = 'chat.tools.global.autoApprove'; /** * Root config key forwarded from the renderer holding the enterprise-policy-derived * {@link IManagedPermissions} object. Synthesized by VS Code exclusively from managed - * (policy) values of `chat.tools.global.autoApprove`, `chat.tools.eligibleForAutoApproval`, - * and `chat.tools.terminal.enableAutoApprove`, and forwarded to the runtime as + * (policy) values of `chat.tools.global.autoApprove` and + * `chat.tools.terminal.enableAutoApprove`, and forwarded to the runtime as * `managedSettings.permissions` at SDK session startup. Absent when no policy applies. */ export const AgentHostManagedPermissionsConfigKey = 'managedPermissions'; diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts index cc4912098b1800..bd209844ef4045 100644 --- a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -497,6 +497,59 @@ suite('CopilotSessionLauncher shared session config', () => { await launcher.disposeByokProxyHandle(); } }); + + test('omits managedSettings from create and resume configs when no policy is set', async () => { + const createConfigs: Parameters[0][] = []; + const resumeConfigs: Parameters[1][] = []; + const session = { + sessionId: 'session-1', + on: () => () => { }, + disconnect: async () => { }, + } as unknown as CopilotSession; + const client = { + createSession: async (config: Parameters[0]) => { + createConfigs.push(config); + return session; + }, + resumeSession: async (_sessionId: string, config: Parameters[1]) => { + resumeConfigs.push(config); + return session; + }, + }; + // Root value unset (default launcher's getRootValue returns undefined). + const launcher = createTestLauncher(); + const basePlan = { + client, + sessionId: 'session-1', + workingDirectory: testWorkingDirectory, + resolvedAgentName: undefined, + snapshot: { tools: [], plugins: [], mcpServers: {} }, + activeClientToolSet: new ActiveClientToolSet(), + shellManager: undefined, + githubToken: undefined, + }; + const createPlan: CopilotSessionLaunchPlan = { ...basePlan, kind: 'create', model: undefined }; + const resumePlan: CopilotSessionLaunchPlan = { ...basePlan, kind: 'resume', fallback: { model: undefined } }; + + const sessions = new DisposableStore(); + try { + sessions.add(await launcher.launch(createPlan, testRuntime)); + sessions.add(await launcher.launch(resumePlan, testRuntime)); + + assert.deepStrictEqual({ + create: (createConfigs[0] as { managedSettings?: unknown }).managedSettings, + createEnable: (createConfigs[0] as { enableManagedSettings?: boolean }).enableManagedSettings, + resume: (resumeConfigs[0] as { managedSettings?: unknown }).managedSettings, + }, { + create: undefined, + createEnable: true, + resume: undefined, + }); + } finally { + sessions.dispose(); + await launcher.disposeByokProxyHandle(); + } + }); }); suite('CopilotSessionLauncher resume fallback', () => { From ce39627049c18e03cc9ec6c889548da900f3ce20 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:23:53 -0700 Subject: [PATCH 03/12] fix(agent-host): refresh managed policy sessions Clear removed policy through the merge-based root config, restart peer chats on policy changes, and update rebased launcher tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d5d4d699-33e2-4a55-9d48-57d2e483dd3d --- .../browser/remoteAgentHostProtocolClient.ts | 7 +++-- .../agentHost/common/agentHostSchema.ts | 8 ++++++ .../agentHost/node/copilot/copilotAgent.ts | 19 +++++++++++-- .../node/copilot/copilotSessionLauncher.ts | 14 ++++++---- .../test/common/agentHostSchema.test.ts | 10 ++++++- .../remoteAgentHostProtocolClient.test.ts | 27 ++++++++++++++++-- .../agentHost/test/node/copilotAgent.test.ts | 28 +++++++++++++++++++ .../test/node/copilotSessionLauncher.test.ts | 7 +++-- 8 files changed, 104 insertions(+), 16 deletions(-) diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index ad501c0fac3640..73d6492ed36f67 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -1505,14 +1505,17 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC * settings via `inspect(...).policyValue` — user/workspace values are * ignored so only enterprise policy affects the runtime's * `managedSettings.permissions`. When no policy applies, the derived value - * is `undefined` and the root config key is cleared. + * is `undefined` and an empty-object clear sentinel is forwarded so the + * merge-based root config drops any previously forwarded permissions. */ private _updateManagedPermissions(): void { const permissions = deriveManagedPermissions({ globalAutoApprove: this._configurationService.inspect(GLOBAL_AUTO_APPROVE_SETTING_ID).policyValue, terminalAutoApproveEnabled: this._configurationService.inspect(TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID).policyValue, }); - this._dispatchRootConfig({ [AgentHostManagedPermissionsConfigKey]: permissions }); + // Root config patches merge over existing values. An empty object is + // the wire-safe clear sentinel because JSON drops `undefined`. + this._dispatchRootConfig({ [AgentHostManagedPermissionsConfigKey]: permissions ?? {} }); } private _updatePreferLongContextEnabled(): void { diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 09fd8b457c44df..37ada5875a4a36 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -380,6 +380,14 @@ export function deriveManagedPermissions(inputs: IManagedPermissionPolicyInputs) return Object.keys(permissions).length ? permissions : undefined; } +/** + * Treat the empty object used as the merge-safe root-config clear sentinel as + * no managed policy. + */ +export function normalizeManagedPermissions(permissions: IManagedPermissions | undefined): IManagedPermissions | undefined { + return permissions && Object.keys(permissions).length > 0 ? permissions : undefined; +} + const managedPermissionsProperty = schemaProperty({ type: 'object', title: localize('agentHost.config.managedPermissions.title', "Managed Permissions"), diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 71a5704c02d8d5..eb415c52bd1d3b 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -37,7 +37,7 @@ import { createPricingMetaFromBilling, hasLongContextSurcharge, normalizeCAPIBil import { createAgentModelByokMeta } from '../../common/agentModelByokMeta.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema, DEFAULT_SESSION_CUSTOMIZATION_DISCOVERY_MODE, toContainerCustomization } from '../../common/agentHostCustomizationConfig.js'; import { CopilotCliConfigKey, copilotCliConfigSchema, type CopilotSdkLogLevelSetting } from '../../common/copilotCliConfig.js'; -import { AgentHostMcpServersConfigKey, AgentHostManagedPermissionsConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AutoApproveLevel, SessionMode, migrateLegacyAutopilotConfig, platformRootSchema, platformSessionSchema, type AgentHostMcpServers, type IManagedPermissions } from '../../common/agentHostSchema.js'; +import { AgentHostMcpServersConfigKey, AgentHostManagedPermissionsConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AutoApproveLevel, SessionMode, migrateLegacyAutopilotConfig, normalizeManagedPermissions, platformRootSchema, platformSessionSchema, type AgentHostMcpServers, type IManagedPermissions } from '../../common/agentHostSchema.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { AgentSessionEntry, decodeProviderData, encodeProviderData, prepareSideChatPrompt, stripSideChatContext, type IPersistedChat } from '../agentPeerChats.js'; import { AgentSession, AgentSignal, AuthenticateParams, IActiveClient, IAgent, IAgentChatDataChange, IAgentChats, IAgentLegacyChat, IAgentCreateChatForkSource, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentHostManagedSettingsSnapshot, IAgentHostNetworkEndpoint, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, IRestoredSubagentSession, SubagentChatSignal } from '../../common/agentService.js'; @@ -2654,10 +2654,21 @@ export class CopilotAgent extends Disposable implements IAgent { // Additional (non-default) chats are backed by their own SDK // chat hosted on the owning session entry, keyed by the chat URI. if (context.isPeerChat) { - const entry = await this._ensureChatSession(context.session, chat); + let entry = await this._ensureChatSession(context.session, chat); if (!entry) { throw new Error(`[Copilot] sendMessage for unknown chat: ${chat.toString()}`); } + const activeClient = this._activeClients.get(context.session); + if (activeClient && await activeClient.requiresRestart(entry.appliedSnapshot)) { + this._logService.info(`[Copilot:${context.sessionId}] Peer chat config changed (requiresRestart=true), refreshing ${chat.toString()}`); + this._sdkSessionsById.delete(entry.sessionId); + await entry.destroySession(); + this._sessions.get(context.sessionId)?.disposePeerChat(chat.toString()); + entry = await this._ensureChatSession(context.session, chat); + if (!entry) { + throw new Error(`[Copilot] failed to refresh chat: ${chat.toString()}`); + } + } if (turnId) { entry.resetTurnState(turnId, senderClientId, clientType); } @@ -5220,7 +5231,9 @@ class ActiveClient extends Disposable { } private _getManagedPermissions(): IManagedPermissions | undefined { - return this._configurationService.getRootValue(platformRootSchema, AgentHostManagedPermissionsConfigKey); + return normalizeManagedPermissions( + this._configurationService.getRootValue(platformRootSchema, AgentHostManagedPermissionsConfigKey), + ); } /** diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 1fdfb86b775672..ec06f8421c08fc 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -11,7 +11,7 @@ import { IFileService } from '../../../files/common/files.js'; import { ILogService, LogLevel } from '../../../log/common/log.js'; import { CopilotCliConfigKey, applyModelFamilyAlias, copilotCliConfigSchema, normalizeToolSearchDeferThreshold } from '../../common/copilotCliConfig.js'; import { agentHostModelSupportsToolSearch, CLIENT_TOOL_SEARCH_REFERENCE_NAME } from './toolSearchDeferral.js'; -import { AgentHostManagedPermissionsConfigKey, AgentHostSessionSyncEnabledConfigKey, platformRootSchema, type AgentHostMcpServers, type IManagedPermissions } from '../../common/agentHostSchema.js'; +import { AgentHostManagedPermissionsConfigKey, AgentHostSessionSyncEnabledConfigKey, normalizeManagedPermissions, platformRootSchema, type AgentHostMcpServers, type IManagedPermissions } from '../../common/agentHostSchema.js'; import { AgentSession } from '../../common/agentService.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; @@ -84,10 +84,10 @@ type PreToolUseHookInput = Parameters> type PostToolUseHookInput = Parameters>[0]; /** * Local mirror of the SDK's `managedSettings` session-config field, scoped to - * the `permissions` object VS Code populates. The published - * `@github/copilot-sdk` types (1.0.8) expose `enableManagedSettings` but not - * `managedSettings`; this precise additive type lets VS Code forward - * enterprise-policy-derived permissions until the SDK publishes the field. + * the `permissions` object VS Code populates. The currently published SDK + * exposes `enableManagedSettings` but not `managedSettings`; this precise + * additive type lets VS Code forward enterprise-policy-derived permissions + * until the SDK publishes the field. * Mirrors the local-type precedent used for `ICopilotRuntimeManagedSettingsSdk` * in copilotAgent.ts. */ @@ -600,7 +600,9 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { // renderer reports no BYOK models), merged into the returned config so both // createSession and resumeSession advertise the models to the runtime. const byok = await this._resolveByokSessionConfig(plan.sessionId); - const managedPermissions = this._configurationService.getRootValue(platformRootSchema, AgentHostManagedPermissionsConfigKey); + const managedPermissions = normalizeManagedPermissions( + this._configurationService.getRootValue(platformRootSchema, AgentHostManagedPermissionsConfigKey), + ); const enableCustomTerminalTool = this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.EnableCustomTerminalTool) === true; let shellTools: Awaited> = []; if (enableCustomTerminalTool) { diff --git a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts index e0101e8ee814e5..0d86f18f309d32 100644 --- a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import type { IConfigurationValue } from '../../../configuration/common/configuration.js'; -import { createSchema, deriveManagedPermissions, migrateLegacyAutopilotConfig, normalizeAgentHostTerminalAutoApproveRulesConfig, platformRootSchema, platformSessionSchema, schemaProperty, AgentHostManagedPermissionsConfigKey, MANAGED_PERMISSION_TERMINAL_ASK_RULE, type AgentHostTerminalAutoApproveRules, type AutoApproveLevel, type IManagedPermissions, type IPermissionsValue, type SessionMode } from '../../common/agentHostSchema.js'; +import { createSchema, deriveManagedPermissions, migrateLegacyAutopilotConfig, normalizeAgentHostTerminalAutoApproveRulesConfig, normalizeManagedPermissions, platformRootSchema, platformSessionSchema, schemaProperty, AgentHostManagedPermissionsConfigKey, MANAGED_PERMISSION_TERMINAL_ASK_RULE, type AgentHostTerminalAutoApproveRules, type AutoApproveLevel, type IManagedPermissions, type IPermissionsValue, type SessionMode } from '../../common/agentHostSchema.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js'; @@ -463,5 +463,13 @@ suite('agentHostSchema', () => { assert.ok(permissions); assert.strictEqual(platformRootSchema.validate(AgentHostManagedPermissionsConfigKey, permissions), true); }); + + test('normalizes the root-config clear sentinel to no policy', () => { + assert.strictEqual(normalizeManagedPermissions({}), undefined); + assert.deepStrictEqual( + normalizeManagedPermissions({ disableBypassPermissionsMode: 'disable' }), + { disableBypassPermissionsMode: 'disable' }, + ); + }); }); }); diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts index d6cba4529dc4e2..64646848ad63b6 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts @@ -195,6 +195,14 @@ class ManagedPermissionPolicyConfigurationService extends TestConfigurationServi } return base; } + + setPolicyValue(key: string, value: unknown): void { + if (value === undefined) { + delete this._policyValues[key]; + } else { + this._policyValues[key] = value; + } + } } suite('RemoteAgentHostProtocolClient', () => { @@ -949,7 +957,7 @@ suite('RemoteAgentHostProtocolClient', () => { }); }); - test('forwards undefined managed permissions when only non-policy values are set', async () => { + test('forwards the empty clear sentinel when only non-policy values are set', async () => { // User/workspace values are restrictive, but no enterprise policy is set — // only `policyValue` maps, so nothing must be forwarded. const configurationService = new TestConfigurationService({ @@ -960,7 +968,22 @@ suite('RemoteAgentHostProtocolClient', () => { await connectClient(client, transport); const managed = findRootConfigNotification(transport.sentMessages, AgentHostManagedPermissionsConfigKey); - assert.strictEqual(getRootConfig(managed)[AgentHostManagedPermissionsConfigKey], undefined); + assert.deepStrictEqual(getRootConfig(managed)[AgentHostManagedPermissionsConfigKey], {}); + }); + + test('forwards the empty clear sentinel when restrictive policy is removed', async () => { + const configurationService = new ManagedPermissionPolicyConfigurationService({ + [GLOBAL_AUTO_APPROVE_SETTING_ID]: false, + }); + const { client, transport } = createClient(disposables.add(new TestProtocolTransport()), createPermissionService(), undefined, new NullLogService(), configurationService); + await connectClient(client, transport); + transport.sentMessages.length = 0; + + configurationService.setPolicyValue(GLOBAL_AUTO_APPROVE_SETTING_ID, undefined); + fireConfigurationChange(configurationService, GLOBAL_AUTO_APPROVE_SETTING_ID); + + const managed = findRootConfigNotification(transport.sentMessages, AgentHostManagedPermissionsConfigKey); + assert.deepStrictEqual(getRootConfig(managed)[AgentHostManagedPermissionsConfigKey], {}); }); test('forwards the repo-info telemetry debug switch on connect and change', async () => { diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 1f3ee8232eae04..3245d477ab0be4 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -4453,6 +4453,7 @@ suite('CopilotAgent', () => { _getOrCreateSessionLifetime: (sessionId: string) => { queueSession(task: () => Promise): Promise } | undefined; _forkSdkChat: (client: unknown, sourceEntry: unknown, turnId: string, targetDbDir: URI) => Promise<{ sessionId: string; inheritedTurnCount: number }>; _resolveAgentName: (snapshot: IActiveClientSnapshot, agent: AgentSelection) => string | undefined; + _ensureChatSession: (session: URI, chat: URI) => Promise; }; interface IFakeChatRecorder { @@ -4496,6 +4497,7 @@ suite('CopilotAgent', () => { handleClientToolCallComplete(): void { }, async getNextTurnEventId(): Promise { return undefined; }, getMessages: getMessages ?? (async () => []), + async destroySession(): Promise { rec.disposed = true; }, dispose(): void { rec.disposed = true; owned?.dispose(); }, } as unknown as CopilotAgentSession; return { rec, fake }; @@ -5265,6 +5267,32 @@ suite('CopilotAgent', () => { } }); + test('sendMessage refreshes a peer chat when managed permissions change', async () => { + const { agent, configurationService } = createTestAgentContext(disposables); + try { + const session = AgentSession.uri('copilotcli', 'route-managed-refresh'); + const chat = URI.parse(buildChatUri(session, 'peer-a')); + agent.getOrCreateActiveClient(session, { clientId: 'client-A' }).tools = []; + const old = makeFakeChatSession(session, 'sdk-old'); + const fresh = makeFakeChatSession(session, 'sdk-fresh'); + let ensureCalls = 0; + (agent as unknown as ChatInternals)._ensureChatSession = async () => { + ensureCalls++; + return ensureCalls === 1 ? old.fake : fresh.fake; + }; + + configurationService.updateRootConfig({ + [AgentHostManagedPermissionsConfigKey]: { disableBypassPermissionsMode: 'disable' }, + }); + await agent.chats.sendMessage(chat, 'after-policy-change', undefined); + + assert.strictEqual(old.rec.disposed, true); + assert.deepStrictEqual(fresh.rec.sends.map(send => send.prompt), ['after-policy-change']); + } finally { + await disposeAgent(agent); + } + }); + test('sendMessage throws for a peer chat with no backing chat', async () => { const agent = createTestAgent(disposables); try { diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts index bd209844ef4045..8ecfd0d69fc419 100644 --- a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -516,8 +516,11 @@ suite('CopilotSessionLauncher shared session config', () => { return session; }, }; - // Root value unset (default launcher's getRootValue returns undefined). - const launcher = createTestLauncher(); + // The renderer uses an empty object as the merge-safe wire sentinel when + // policy is cleared; the launcher must still omit managedSettings. + const launcher = createTestLauncherWithRootValues({ + [AgentHostManagedPermissionsConfigKey]: {}, + }); const basePlan = { client, sessionId: 'session-1', From 43067a732e52c8ccf463c4fc4dcf546eeaf8a66c Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Fri, 7 Aug 2026 07:12:58 -0700 Subject: [PATCH 04/12] chore(agent-host): adopt managed settings SDK types Bump @github/copilot-sdk to 1.0.10-preview.0 in the desktop and remote dependency roots and regenerate both lockfiles. Replace the temporary managedSettings session-config shim with the published package-root types. Convert VS Code's readonly common-layer permission snapshot at the SDK boundary while preserving omitted keys, and expose the runtime's client/mixed managed-settings provenance in diagnostics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c111c62a-eff3-4ff6-bb8a-8436a1b4babe --- package-lock.json | 172 +----------------- package.json | 2 +- remote/package-lock.json | 172 +----------------- remote/package.json | 2 +- .../platform/agentHost/common/agentService.ts | 3 +- .../node/copilot/copilotSessionLauncher.ts | 33 ++-- 6 files changed, 31 insertions(+), 353 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0d50fa0952a916..10b9581027cf6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.82.0", "@github/copilot": "1.0.79-6", - "@github/copilot-sdk": "1.0.9", + "@github/copilot-sdk": "^1.0.10-preview.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", @@ -1243,12 +1243,12 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.9.tgz", - "integrity": "sha512-ZQJYbKhQTvpiUOU4rtPjppzVQv41gGymaD+AuWDbiZPYvSMSB4jZ/epvCrDs7jgqWa52r+j2D9eOQoSzdUMO6Q==", + "version": "1.0.10-preview.0", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.10-preview.0.tgz", + "integrity": "sha512-KkbbOu2dlhaKXv9cXkTvLAzlffMkp7+5ai6QBvbqY6X39I1DtUFeD5JelQDNWWBWyi4DdoES4KOdtkiPhNNVrg==", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.78", + "@github/copilot": "^1.0.79-6", "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" @@ -1257,168 +1257,6 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot": { - "version": "1.0.78", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.78.tgz", - "integrity": "sha512-jn+8HLZC3R7d6K1/1g9L1iWNKzBVS3JdVcx40r3aWyS5r+MLV1OPNp0fo5OfRMCDIm3NmEaaoqypi9sQkCXuiQ==", - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { - "detect-libc": "^2.1.2" - }, - "bin": { - "copilot": "npm-loader.js" - }, - "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.78", - "@github/copilot-darwin-x64": "1.0.78", - "@github/copilot-linux-arm64": "1.0.78", - "@github/copilot-linux-x64": "1.0.78", - "@github/copilot-linuxmusl-arm64": "1.0.78", - "@github/copilot-linuxmusl-x64": "1.0.78", - "@github/copilot-win32-arm64": "1.0.78", - "@github/copilot-win32-x64": "1.0.78" - } - }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.78", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.78.tgz", - "integrity": "sha512-P11+VyWg8ad0WlywGtO2d7AxqTLJv4hkUicFg6Ycth5lfk00aCu/74YOOZSPO6C2bBBJhAza7oAdmauM6KEojw==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "darwin" - ], - "bin": { - "copilot-darwin-arm64": "copilot" - } - }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-darwin-x64": { - "version": "1.0.78", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.78.tgz", - "integrity": "sha512-stimP3WDFs2GU8nJzTJbtRpZViV4bsf80yg7QrFq+G4RISQ3Nihg/3/H0U6UQF1+txMJ/Ohmb5RFYxSw1Hj2sw==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "darwin" - ], - "bin": { - "copilot-darwin-x64": "copilot" - } - }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-linux-arm64": { - "version": "1.0.78", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.78.tgz", - "integrity": "sha512-K31PRKGTm252V1Lof7ypjg283R2QSm3BgoCvZfX2taos4wqC3SaTozSQKwW3dgrAx7A3G3SGEoilVCNqfigdZA==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linux-arm64": "copilot" - } - }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-linux-x64": { - "version": "1.0.78", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.78.tgz", - "integrity": "sha512-QK3oMtAn9dIv+1u1kx0xNpZNtZxdI+uZVIyLl7myp+Oh2Uj8BLagVv6a7uP0cDphO3TgfIdlvpepCe5MIcx0fw==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linux-x64": "copilot" - } - }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.78", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.78.tgz", - "integrity": "sha512-F/0cTMsz6ug4yiXn3RKaCAMsLR261U5Njb6G9Y/HeAI7ES/tKEo2t5SHuvgXaIH4mYiZsRvfDKdX7c0WgBX/Jg==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linuxmusl-arm64": "copilot" - } - }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.78", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.78.tgz", - "integrity": "sha512-YMaJaeBGbArGAFYel+yFaFW/0rFgh0Oqki2f2mUtlonTX/xHr8EB4+mTnMJkHYMFy4gOTC3OtSEEe1NaW/cBXQ==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linuxmusl-x64": "copilot" - } - }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-win32-arm64": { - "version": "1.0.78", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.78.tgz", - "integrity": "sha512-ktDkFXaaecEKD3hpM6ydM9lKOdoCfsQsXCmzLzE7DCmSpbbMCdfPfWfZ7MOclmKmpZ5/MNfr4U2l8CUqGerzYA==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "win32" - ], - "bin": { - "copilot-win32-arm64": "copilot.exe" - } - }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-win32-x64": { - "version": "1.0.78", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.78.tgz", - "integrity": "sha512-Gd8l2T4eqYEWlOEPd0SZznQ+YYgYrwOkE0QXodMkhCBbPdgu/uTzb7mnISWwnVAgqs7pONdF1GOpHkTo+ay8CQ==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "win32" - ], - "bin": { - "copilot-win32-x64": "copilot.exe" - } - }, "node_modules/@github/copilot-win32-arm64": { "version": "1.0.79-6", "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.79-6.tgz", diff --git a/package.json b/package.json index 0f6272080b0048..3bf412925c74ff 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.82.0", "@github/copilot": "1.0.79-6", - "@github/copilot-sdk": "1.0.9", + "@github/copilot-sdk": "^1.0.10-preview.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", diff --git a/remote/package-lock.json b/remote/package-lock.json index 8a7c5670ae60cd..61d17f3d35ae6b 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "dependencies": { "@github/copilot": "1.0.79-6", - "@github/copilot-sdk": "1.0.9", + "@github/copilot-sdk": "^1.0.10-preview.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/mxc-sdk": "0.6.1", @@ -191,12 +191,12 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.9.tgz", - "integrity": "sha512-ZQJYbKhQTvpiUOU4rtPjppzVQv41gGymaD+AuWDbiZPYvSMSB4jZ/epvCrDs7jgqWa52r+j2D9eOQoSzdUMO6Q==", + "version": "1.0.10-preview.0", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.10-preview.0.tgz", + "integrity": "sha512-KkbbOu2dlhaKXv9cXkTvLAzlffMkp7+5ai6QBvbqY6X39I1DtUFeD5JelQDNWWBWyi4DdoES4KOdtkiPhNNVrg==", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.78", + "@github/copilot": "^1.0.79-6", "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" @@ -205,168 +205,6 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot": { - "version": "1.0.78", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.78.tgz", - "integrity": "sha512-jn+8HLZC3R7d6K1/1g9L1iWNKzBVS3JdVcx40r3aWyS5r+MLV1OPNp0fo5OfRMCDIm3NmEaaoqypi9sQkCXuiQ==", - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { - "detect-libc": "^2.1.2" - }, - "bin": { - "copilot": "npm-loader.js" - }, - "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.78", - "@github/copilot-darwin-x64": "1.0.78", - "@github/copilot-linux-arm64": "1.0.78", - "@github/copilot-linux-x64": "1.0.78", - "@github/copilot-linuxmusl-arm64": "1.0.78", - "@github/copilot-linuxmusl-x64": "1.0.78", - "@github/copilot-win32-arm64": "1.0.78", - "@github/copilot-win32-x64": "1.0.78" - } - }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.78", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.78.tgz", - "integrity": "sha512-P11+VyWg8ad0WlywGtO2d7AxqTLJv4hkUicFg6Ycth5lfk00aCu/74YOOZSPO6C2bBBJhAza7oAdmauM6KEojw==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "darwin" - ], - "bin": { - "copilot-darwin-arm64": "copilot" - } - }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-darwin-x64": { - "version": "1.0.78", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.78.tgz", - "integrity": "sha512-stimP3WDFs2GU8nJzTJbtRpZViV4bsf80yg7QrFq+G4RISQ3Nihg/3/H0U6UQF1+txMJ/Ohmb5RFYxSw1Hj2sw==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "darwin" - ], - "bin": { - "copilot-darwin-x64": "copilot" - } - }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-linux-arm64": { - "version": "1.0.78", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.78.tgz", - "integrity": "sha512-K31PRKGTm252V1Lof7ypjg283R2QSm3BgoCvZfX2taos4wqC3SaTozSQKwW3dgrAx7A3G3SGEoilVCNqfigdZA==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linux-arm64": "copilot" - } - }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-linux-x64": { - "version": "1.0.78", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.78.tgz", - "integrity": "sha512-QK3oMtAn9dIv+1u1kx0xNpZNtZxdI+uZVIyLl7myp+Oh2Uj8BLagVv6a7uP0cDphO3TgfIdlvpepCe5MIcx0fw==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linux-x64": "copilot" - } - }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.78", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.78.tgz", - "integrity": "sha512-F/0cTMsz6ug4yiXn3RKaCAMsLR261U5Njb6G9Y/HeAI7ES/tKEo2t5SHuvgXaIH4mYiZsRvfDKdX7c0WgBX/Jg==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linuxmusl-arm64": "copilot" - } - }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.78", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.78.tgz", - "integrity": "sha512-YMaJaeBGbArGAFYel+yFaFW/0rFgh0Oqki2f2mUtlonTX/xHr8EB4+mTnMJkHYMFy4gOTC3OtSEEe1NaW/cBXQ==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linuxmusl-x64": "copilot" - } - }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-win32-arm64": { - "version": "1.0.78", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.78.tgz", - "integrity": "sha512-ktDkFXaaecEKD3hpM6ydM9lKOdoCfsQsXCmzLzE7DCmSpbbMCdfPfWfZ7MOclmKmpZ5/MNfr4U2l8CUqGerzYA==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "win32" - ], - "bin": { - "copilot-win32-arm64": "copilot.exe" - } - }, - "node_modules/@github/copilot-sdk/node_modules/@github/copilot-win32-x64": { - "version": "1.0.78", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.78.tgz", - "integrity": "sha512-Gd8l2T4eqYEWlOEPd0SZznQ+YYgYrwOkE0QXodMkhCBbPdgu/uTzb7mnISWwnVAgqs7pONdF1GOpHkTo+ay8CQ==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "win32" - ], - "bin": { - "copilot-win32-x64": "copilot.exe" - } - }, "node_modules/@github/copilot-win32-arm64": { "version": "1.0.79-6", "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.79-6.tgz", diff --git a/remote/package.json b/remote/package.json index d011f67bad8017..39bd7e67754d76 100644 --- a/remote/package.json +++ b/remote/package.json @@ -4,7 +4,7 @@ "private": true, "dependencies": { "@github/copilot": "1.0.79-6", - "@github/copilot-sdk": "1.0.9", + "@github/copilot-sdk": "^1.0.10-preview.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/mxc-sdk": "0.6.1", diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 206824461200a5..c64d1b04a5217c 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -665,8 +665,9 @@ export interface IAgentHostNetworkDiagnosticsInfo { export interface IAgentHostManagedSettingsSnapshot { readonly account?: string; - readonly source: 'server' | 'device' | 'none'; + readonly source: 'server' | 'device' | 'client' | 'mixed' | 'none'; readonly serverManaged: boolean; + readonly clientManaged?: boolean; readonly deviceManaged: boolean; readonly failClosed: boolean; readonly bypassPermissionsDisabled: boolean; diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index ec06f8421c08fc..75ef48dbfd2f54 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { ContextTier, CopilotClient, ElicitationContext, ElicitationResult, ExitPlanModeRequest, ExitPlanModeResult, NamedProviderConfig, PermissionRequest, PermissionRequestResult, ProviderModelConfig, ResumeSessionConfig, SessionConfig, SessionHooks, Tool, Verbosity } from '@github/copilot-sdk'; +import type { ContextTier, CopilotClient, ElicitationContext, ElicitationResult, ExitPlanModeRequest, ExitPlanModeResult, ManagedSettings, ManagedSettingsPermissions, NamedProviderConfig, PermissionRequest, PermissionRequestResult, ProviderModelConfig, ResumeSessionConfig, SessionConfig, SessionHooks, Tool, Verbosity } from '@github/copilot-sdk'; import { coalesce } from '../../../../base/common/arrays.js'; import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; @@ -82,17 +82,18 @@ type McpAuthContext = Parameters[1]; type McpAuthResponse = Awaited>; type PreToolUseHookInput = Parameters>[0]; type PostToolUseHookInput = Parameters>[0]; -/** - * Local mirror of the SDK's `managedSettings` session-config field, scoped to - * the `permissions` object VS Code populates. The currently published SDK - * exposes `enableManagedSettings` but not `managedSettings`; this precise - * additive type lets VS Code forward enterprise-policy-derived permissions - * until the SDK publishes the field. - * Mirrors the local-type precedent used for `ICopilotRuntimeManagedSettingsSdk` - * in copilotAgent.ts. - */ -interface ICopilotManagedSettingsSdk { - readonly permissions?: IManagedPermissions; + +function toSdkManagedSettings(permissions: IManagedPermissions | undefined): ManagedSettings | undefined { + if (!permissions) { + return undefined; + } + const sdkPermissions: ManagedSettingsPermissions = { + ...(permissions.disableBypassPermissionsMode ? { disableBypassPermissionsMode: permissions.disableBypassPermissionsMode } : {}), + ...(permissions.deny ? { deny: [...permissions.deny] } : {}), + ...(permissions.ask ? { ask: [...permissions.ask] } : {}), + ...(permissions.allow ? { allow: [...permissions.allow] } : {}), + }; + return { permissions: sdkPermissions }; } /** @@ -594,15 +595,15 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { } } - private async _buildSessionConfig(plan: CopilotSessionLaunchPlan, runtime: ICopilotSessionRuntime): Promise { + private async _buildSessionConfig(plan: CopilotSessionLaunchPlan, runtime: ICopilotSessionRuntime): Promise { const plugins = plan.snapshot.plugins; // Synthesize BYOK provider/model config (empty when BYOK is gated off or the // renderer reports no BYOK models), merged into the returned config so both // createSession and resumeSession advertise the models to the runtime. const byok = await this._resolveByokSessionConfig(plan.sessionId); - const managedPermissions = normalizeManagedPermissions( + const managedSettings = toSdkManagedSettings(normalizeManagedPermissions( this._configurationService.getRootValue(platformRootSchema, AgentHostManagedPermissionsConfigKey), - ); + )); const enableCustomTerminalTool = this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.EnableCustomTerminalTool) === true; let shellTools: Awaited> = []; if (enableCustomTerminalTool) { @@ -704,7 +705,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { // Forward enterprise-policy-derived managed permissions (synthesized // by VS Code from managed policy values) as the runtime's // `managedSettings.permissions`. Omitted when no policy applies. - ...(managedPermissions ? { managedSettings: { permissions: managedPermissions } } : {}), + ...(managedSettings ? { managedSettings } : {}), }; } } From 06a07cc2d8f15e3465eeb3552917c75a11c7c2fc Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:17:04 -0700 Subject: [PATCH 05/12] fix(agent-host): isolate managed permission policies Aggregate managed permission restrictions per connected client, keep them transient, and redact policy rules from protocol and trace logs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c111c62a-eff3-4ff6-bb8a-8436a1b4babe --- .../agentHost/common/agentHostSchema.ts | 3 + .../platform/agentHost/common/agentService.ts | 3 + .../agentHost/common/ahpJsonlLogger.ts | 6 +- .../platform/agentHost/node/agentService.ts | 78 ++++++++++++++++++- .../agentHost/node/protocolServerHandler.ts | 4 +- .../test/common/ahpJsonlLogger.test.ts | 37 +++++++++ .../agentHost/test/node/agentService.test.ts | 71 +++++++++++++++-- .../test/node/protocolServerHandler.test.ts | 12 ++- 8 files changed, 203 insertions(+), 11 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 37ada5875a4a36..4308850840ec38 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -571,6 +571,9 @@ export const GLOBAL_AUTO_APPROVE_SETTING_ID = 'chat.tools.global.autoApprove'; */ export const AgentHostManagedPermissionsConfigKey = 'managedPermissions'; +/** Marker written to diagnostic logs instead of enterprise-managed permission rules. */ +export const AgentHostManagedPermissionsLogRedaction = ''; + /** * Root config key forwarded from the renderer when VS Code's `chat.autoReply` * setting changes. When `true`, the agent host auto-answers `ask_user` diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index c64d1b04a5217c..798edfa71c1ab8 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -2138,6 +2138,9 @@ export interface IAgentService { */ dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number, clientContext?: IAgentHostClientTelemetryContext): void; + /** Remove the enterprise-managed permission contribution owned by a disconnected client. */ + removeClientManagedPermissions(clientId: string): void; + /** * List the contents of a directory on the agent host's filesystem. * Used by the client to drive a remote folder picker before session creation. diff --git a/src/vs/platform/agentHost/common/ahpJsonlLogger.ts b/src/vs/platform/agentHost/common/ahpJsonlLogger.ts index c1f91dbe30f9b5..4eda64da1c779c 100644 --- a/src/vs/platform/agentHost/common/ahpJsonlLogger.ts +++ b/src/vs/platform/agentHost/common/ahpJsonlLogger.ts @@ -10,6 +10,7 @@ import { joinPath } from '../../../base/common/resources.js'; import { isUriComponents, URI, UriComponents } from '../../../base/common/uri.js'; import { IFileService, IFileStatWithMetadata } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; +import { AgentHostManagedPermissionsConfigKey, AgentHostManagedPermissionsLogRedaction } from './agentHostSchema.js'; export type AhpLogDirection = 'c2s' | 's2c'; @@ -240,7 +241,10 @@ function stringifyAhpLogEntryTruncated(value: unknown, maxStringLength: number): * {@link URI.revive}. This avoids the expensive deep-clone tree walk that * would otherwise be required to find every URI in a message payload. */ -function _ahpReplacer(this: unknown, _key: string, value: unknown): unknown { +function _ahpReplacer(this: unknown, key: string, value: unknown): unknown { + if (key === AgentHostManagedPermissionsConfigKey) { + return AgentHostManagedPermissionsLogRedaction; + } if ( value && typeof value === 'object' diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 854f3fc1ea3b25..48afb83dd0677f 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -77,7 +77,7 @@ import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js'; import { AgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; -import { AgentHostEditTelemetryEnabledConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; +import { AgentHostEditTelemetryEnabledConfigKey, AgentHostManagedPermissionsConfigKey, AgentHostManagedPermissionsLogRedaction, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, normalizeManagedPermissions, platformRootSchema, type IManagedPermissions } from '../common/agentHostSchema.js'; import { AgentHostOctoKitService, IAgentHostOctoKitService } from './shared/agentHostOctoKitService.js'; import { IAgentHostChangesetService, CHANGESET_DB_METADATA_KEYS, META_CHANGES_SUMMARY } from '../common/agentHostChangesetService.js'; import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChangesetSubscriptionService.js'; @@ -293,6 +293,8 @@ export class AgentService extends Disposable implements IAgentService { /** Server-side host for the agent host's server tools. */ private readonly _serverToolHost: AgentServerToolHost; private readonly _configurationService: AgentConfigurationService; + /** Enterprise-managed permission restrictions contributed by each connected client. */ + private readonly _managedPermissionsByClient = new Map(); /** Captures baseline / per-turn git checkpoints backing the changeset pipeline. */ private readonly _checkpointService: IAgentHostCheckpointService; /** @@ -2601,7 +2603,10 @@ export class AgentService extends Disposable implements IAgentService { const clientContext = typeof clientContextOrType === 'string' ? createUnknownAgentHostClientTelemetryContext(clientContextOrType) : clientContextOrType; - this._logService.trace(`[AgentService] dispatchAction: type=${action.type}, clientId=${clientId}, clientSeq=${clientSeq}`, action); + const logAction = action.type === ActionType.RootConfigChanged && Object.hasOwn(action.config, AgentHostManagedPermissionsConfigKey) + ? { ...action, config: { ...action.config, [AgentHostManagedPermissionsConfigKey]: AgentHostManagedPermissionsLogRedaction } } + : action; + this._logService.trace(`[AgentService] dispatchAction: type=${action.type}, clientId=${clientId}, clientSeq=${clientSeq}`, logAction); // Clients dispatch chat (chat) actions against a chat channel // URI. Keep that chat channel for the optimistic state apply and for @@ -2693,8 +2698,27 @@ export class AgentService extends Disposable implements IAgentService { return; } } + let managedPermissionsChanged = false; + let effectiveManagedPermissions: IManagedPermissions | undefined; + if (action.type === ActionType.RootConfigChanged && Object.hasOwn(action.config, AgentHostManagedPermissionsConfigKey)) { + const managedPermissions = action.config[AgentHostManagedPermissionsConfigKey]; + if (!platformRootSchema.validate(AgentHostManagedPermissionsConfigKey, managedPermissions)) { + this._stateManager.rejectClientAction(channel, action, origin, `Invalid ${AgentHostManagedPermissionsConfigKey} root config value.`); + return; + } + effectiveManagedPermissions = this._setClientManagedPermissions(clientId, normalizeManagedPermissions(managedPermissions)); + const config = { ...action.config }; + delete config[AgentHostManagedPermissionsConfigKey]; + action = { ...action, config }; + managedPermissionsChanged = true; + } this._stateManager.dispatchClientAction(channel, action, origin); if (action.type === ActionType.RootConfigChanged) { + if (managedPermissionsChanged) { + this._configurationService.publishRootTransientValues({ + [AgentHostManagedPermissionsConfigKey]: effectiveManagedPermissions ?? {}, + }); + } this._configurationService.persistRootConfig(); const editTelemetryEnabled = action.config[AgentHostEditTelemetryEnabledConfigKey]; if (typeof editTelemetryEnabled === 'boolean') { @@ -2704,6 +2728,56 @@ export class AgentService extends Disposable implements IAgentService { this._sideEffects.handleAction(channel, action, clientId, clientContext); } + removeClientManagedPermissions(clientId: string): void { + if (!this._managedPermissionsByClient.delete(clientId)) { + return; + } + this._configurationService.publishRootTransientValues({ + [AgentHostManagedPermissionsConfigKey]: this._getEffectiveManagedPermissions() ?? {}, + }); + } + + private _setClientManagedPermissions(clientId: string, permissions: IManagedPermissions | undefined): IManagedPermissions | undefined { + if (permissions) { + this._managedPermissionsByClient.set(clientId, permissions); + } else { + this._managedPermissionsByClient.delete(clientId); + } + return this._getEffectiveManagedPermissions(); + } + + private _getEffectiveManagedPermissions(): IManagedPermissions | undefined { + const permissions = [...this._managedPermissionsByClient.values()]; + if (permissions.length === 0) { + return undefined; + } + + const deny = new Set(); + const ask = new Set(); + let allow = new Set(permissions[0].allow ?? []); + let disableBypassPermissionsMode = false; + for (const clientPermissions of permissions) { + disableBypassPermissionsMode ||= clientPermissions.disableBypassPermissionsMode === 'disable'; + for (const rule of clientPermissions.deny ?? []) { + deny.add(rule); + } + for (const rule of clientPermissions.ask ?? []) { + ask.add(rule); + } + // Managed allow rules grant automatic approval, so retain only rules + // explicitly allowed by every managed client. + const clientAllow = new Set(clientPermissions.allow ?? []); + allow = new Set([...allow].filter(rule => clientAllow.has(rule))); + } + + return { + ...(disableBypassPermissionsMode ? { disableBypassPermissionsMode: 'disable' as const } : {}), + ...(deny.size ? { deny: [...deny] } : {}), + ...(ask.size ? { ask: [...ask] } : {}), + ...(allow.size ? { allow: [...allow] } : {}), + }; + } + private _needsAsyncRewrite(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction): action is ChatTurnStartedAction | ChatPendingMessageSetAction { if (action.type !== ActionType.ChatTurnStarted && action.type !== ActionType.ChatPendingMessageSet) { return false; diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 553161032f013a..abcbf3bd56e570 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -536,6 +536,7 @@ export class ProtocolServerHandler extends Disposable { lastSeenAt: Date.now(), disconnectTimeouts: new DisposableMap(), }); + this._agentService.removeClientManagedPermissions(client.clientId); this._handleClientDisconnected(client.clientId); this._onDidChangeConnectionCount.fire(this._connectedClientCount); } @@ -1755,7 +1756,8 @@ export class ProtocolServerHandler extends Disposable { } override dispose(): void { - for (const record of this._clients.values()) { + for (const [clientId, record] of this._clients) { + this._agentService.removeClientManagedPermissions(clientId); if (record.state === 'active') { for (const connection of [...record.connections]) { const subscriptionCount = connection.subscriptions.size; diff --git a/src/vs/platform/agentHost/test/common/ahpJsonlLogger.test.ts b/src/vs/platform/agentHost/test/common/ahpJsonlLogger.test.ts index 79ee5a79249fcc..c036942ebde6d8 100644 --- a/src/vs/platform/agentHost/test/common/ahpJsonlLogger.test.ts +++ b/src/vs/platform/agentHost/test/common/ahpJsonlLogger.test.ts @@ -12,6 +12,7 @@ import { IFileWriteOptions } from '../../../files/common/files.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; import { NullLogService } from '../../../log/common/log.js'; import { AhpJsonlLogger, getAhpLogByteLength, stringifyAhpLogEntry } from '../../common/ahpJsonlLogger.js'; +import { AgentHostManagedPermissionsConfigKey, AgentHostManagedPermissionsLogRedaction } from '../../common/agentHostSchema.js'; suite('AhpJsonlLogger', () => { @@ -113,6 +114,42 @@ suite('AhpJsonlLogger', () => { } }); + test('redacts managed permissions without mutating the protocol message', async () => { + const fileService = store.add(new FileService(new NullLogService())); + store.add(fileService.registerProvider('file', store.add(new InMemoryFileSystemProvider()))); + const logger = store.add(new AhpJsonlLogger( + { logsHome: URI.file('/logs'), connectionId: 'conn-redaction', transport: 'websocket' }, + fileService, + new NullLogService(), + )); + const managedPermissions = { ask: ['Domain(private.example)'] }; + const message = { + jsonrpc: '2.0', + method: 'dispatchAction', + params: { + action: { + type: 'root/configChanged', + config: { [AgentHostManagedPermissionsConfigKey]: managedPermissions }, + }, + }, + }; + + logger.log(message, 'c2s'); + await logger.flush(); + + const content = (await fileService.readFile(logger.resource)).value.toString(); + const logged = JSON.parse(content); + assert.deepStrictEqual({ + loggedPermissions: logged.params.action.config[AgentHostManagedPermissionsConfigKey], + containsRule: content.includes('private.example'), + originalPermissions: message.params.action.config[AgentHostManagedPermissionsConfigKey], + }, { + loggedPermissions: AgentHostManagedPermissionsLogRedaction, + containsRule: false, + originalPermissions: managedPermissions, + }); + }); + test('rotates JSONL files and keeps bounded history', async () => { const fileService = store.add(new FileService(new NullLogService())); store.add(fileService.registerProvider('file', store.add(new InMemoryFileSystemProvider()))); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index f08f8de0ad10f0..677801e2cffadc 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -28,8 +28,9 @@ import { ClaudeSessionConfigKey } from '../../common/claudeSessionConfigKeys.js' import { CodexSessionConfigKey } from '../../common/codexSessionConfigKeys.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +import { AgentHostManagedPermissionsConfigKey, AgentHostManagedPermissionsLogRedaction } from '../../common/agentHostSchema.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; -import { ActionType, ActionEnvelope } from '../../common/state/sessionActions.js'; +import { ActionType, ActionEnvelope, type IRootConfigChangedAction } from '../../common/state/sessionActions.js'; import { ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionMultiRootMetadata, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionWithDefaultChat, type MarkdownResponsePart, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; import { type MessageResourceAttachment } from '../../common/state/protocol/state.js'; import { IProductService } from '../../../product/common/productService.js'; @@ -986,17 +987,23 @@ suite('AgentService (node dispatcher)', () => { const customization = { uri: 'file:///plugin-a', displayName: 'Plugin A' }; svc.dispatchAction(ROOT_STATE_URI, { type: ActionType.RootConfigChanged, - config: { customizations: [customization] }, + config: { + customizations: [customization], + [AgentHostManagedPermissionsConfigKey]: { ask: ['Shell(*)'] }, + }, }, 'test-client', 1); let persisted = false; for (let attempt = 0; attempt < 20; attempt++) { try { const parsed = JSON.parse(readFileSync(rootConfigResource.fsPath, 'utf8')); - assert.deepStrictEqual( - parsed.customizations, - [customization], - ); + assert.deepStrictEqual({ + customizations: parsed.customizations, + managedPermissions: parsed[AgentHostManagedPermissionsConfigKey], + }, { + customizations: [customization], + managedPermissions: undefined, + }); persisted = true; break; } catch { @@ -1019,6 +1026,58 @@ suite('AgentService (node dispatcher)', () => { } }); + test('combines managed permissions restrictively per client and redacts trace logs', () => { + const traces: { readonly message: string; readonly args: readonly unknown[] }[] = []; + const logService = new class extends NullLogService { + override trace(message: string, ...args: unknown[]): void { + traces.push({ message, args }); + } + }; + const svc = disposables.add(new AgentService(logService, fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const managedPermissions = { + ask: ['Domain(private.example)'], + }; + const managedAction = { + type: ActionType.RootConfigChanged, + config: { [AgentHostManagedPermissionsConfigKey]: managedPermissions }, + } satisfies IRootConfigChangedAction; + + svc.dispatchAction(ROOT_STATE_URI, managedAction, 'managed-client', 1); + svc.dispatchAction(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AgentHostManagedPermissionsConfigKey]: { disableBypassPermissionsMode: 'disable' } }, + }, 'restricted-client', 1); + svc.dispatchAction(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AgentHostManagedPermissionsConfigKey]: {} }, + }, 'unmanaged-client', 1); + + const beforeDisconnect = svc.stateManager.rootState.config?.values[AgentHostManagedPermissionsConfigKey]; + svc.removeClientManagedPermissions('managed-client'); + const afterManagedDisconnect = svc.stateManager.rootState.config?.values[AgentHostManagedPermissionsConfigKey]; + svc.removeClientManagedPermissions('restricted-client'); + const afterAllManagedDisconnect = svc.stateManager.rootState.config?.values[AgentHostManagedPermissionsConfigKey]; + const serializedTraces = JSON.stringify(traces); + assert.deepStrictEqual({ + beforeDisconnect, + afterManagedDisconnect, + afterAllManagedDisconnect, + originalPermissions: managedAction.config[AgentHostManagedPermissionsConfigKey], + traceHasRedaction: serializedTraces.includes(AgentHostManagedPermissionsLogRedaction), + traceHasRule: serializedTraces.includes('private.example'), + }, { + beforeDisconnect: { + disableBypassPermissionsMode: 'disable', + ask: ['Domain(private.example)'], + }, + afterManagedDisconnect: { disableBypassPermissionsMode: 'disable' }, + afterAllManagedDisconnect: {}, + originalPermissions: managedPermissions, + traceHasRedaction: true, + traceHasRule: false, + }); + }); + test('generates and persists an AI title after first-turn fallback title', async () => { const copilotApiService = new TestCopilotApiService(); copilotApiService.response = '"Fix TypeScript compile errors."'; diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index dcfedd90c7413a..d6259dd25b14a8 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -126,6 +126,7 @@ class MockAgentService implements IAgentService { readonly listedSessions: IAgentSessionMetadata[] = []; readonly createSessionConfigs: (IAgentCreateSessionConfig | undefined)[] = []; managedSettingsDiagnostics: readonly IAgentHostManagedSettingsDiagnostics[] = []; + readonly removedManagedPermissionClients: string[] = []; shutdownCalls = 0; private readonly _onDidAction = new Emitter(); @@ -149,6 +150,9 @@ class MockAgentService implements IAgentService { const origin = { clientId, clientSeq }; this._stateManager.dispatchClientAction(channel, action, origin); } + removeClientManagedPermissions(clientId: string): void { + this.removedManagedPermissionClients.push(clientId); + } async createSession(config?: IAgentCreateSessionConfig): Promise { this.createSessionConfigs.push(config); const session = config?.session ?? URI.parse('copilot:///new-session'); @@ -1593,7 +1597,13 @@ suite('ProtocolServerHandler', () => { stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionTitleChanged, title: 'After Disconnect' }); - assert.strictEqual(transport.sent.length, 0); + assert.deepStrictEqual({ + sentMessages: transport.sent.length, + removedManagedPermissionClients: agentService.removedManagedPermissionClients, + }, { + sentMessages: 0, + removedManagedPermissionClients: ['client-d'], + }); }); test('client disconnect retains active client during grace, then removes it and fails owned tool calls after grace period', () => { From 800de6fc463ab91aef73cb32081f28ea300d3b67 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:49:48 -0700 Subject: [PATCH 06/12] fix(agent-host): harden managed permission lifecycle Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c111c62a-eff3-4ff6-bb8a-8436a1b4babe --- .../platform/agentHost/node/agentService.ts | 42 +++++++++---- .../agentHost/node/protocolServerHandler.ts | 33 +++++++--- .../agentHost/test/node/agentService.test.ts | 63 +++++++++++++++++++ .../test/node/protocolServerHandler.test.ts | 62 ++++++++++++++---- 4 files changed, 166 insertions(+), 34 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 48afb83dd0677f..67290ab03e4465 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -2598,8 +2598,11 @@ export class AgentService extends Disposable implements IAgentService { * todo@connor4312: we can drop this when sending a message become a command */ private readonly _clientDispatchQueues = new Map>(); + /** Invalidates queued actions when a client's reconnect grace expires. */ + private readonly _clientDispatchGenerations = new Map(); dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number, clientContextOrType: IAgentHostClientTelemetryContext | AgentHostClientType = AgentHostClientType.Unknown): void { + const clientDispatchGeneration = this._clientDispatchGenerations.get(clientId) ?? 0; const clientContext = typeof clientContextOrType === 'string' ? createUnknownAgentHostClientTelemetryContext(clientContextOrType) : clientContextOrType; @@ -2620,10 +2623,13 @@ export class AgentService extends Disposable implements IAgentService { const pending = this._clientDispatchQueues.get(clientId); if (!pending && !requiresPeerResolution && !requiresAttachmentRewrite) { - this._dispatchActionNow(channel, sessionChannel, action, clientId, clientSeq, clientContext); + this._dispatchActionNow(channel, sessionChannel, action, clientId, clientSeq, clientContext, clientDispatchGeneration); return; } const next = (pending ?? Promise.resolve()).then(async () => { + if (!this._isClientDispatchGenerationCurrent(clientId, clientDispatchGeneration)) { + return; + } if (chatChannel && requiresPeerResolution) { await this._stateManager.resolveChatState(chatChannel); } @@ -2638,16 +2644,20 @@ export class AgentService extends Disposable implements IAgentService { } this._changesets.refreshBranchChangeset(changeset.sessionUri); } - this._dispatchActionNow(channel, sessionChannel, rewritten, clientId, clientSeq, clientContext); + this._dispatchActionNow(channel, sessionChannel, rewritten, clientId, clientSeq, clientContext, clientDispatchGeneration); }).catch(err => { this._logService.error(`[AgentService] async dispatchAction failed: ${toErrorMessage(err)}`); }); - this._clientDispatchQueues.set(clientId, next.finally(() => { - if (this._clientDispatchQueues.get(clientId) === next) { + const queue = next.finally(() => { + if (this._clientDispatchQueues.get(clientId) === queue) { this._clientDispatchQueues.delete(clientId); + if (!this._managedPermissionsByClient.has(clientId)) { + this._clientDispatchGenerations.delete(clientId); + } } - })); + }); + this._clientDispatchQueues.set(clientId, queue); } /** @@ -2680,7 +2690,10 @@ export class AgentService extends Disposable implements IAgentService { return resolveSessionWorkingDirectoryAction(action, state.workingDirectories, capability.immutablePrimary === true); } - private _dispatchActionNow(channel: string, sessionChannel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number, clientContext: IAgentHostClientTelemetryContext): void { + private _dispatchActionNow(channel: string, sessionChannel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number, clientContext: IAgentHostClientTelemetryContext, clientDispatchGeneration: number): void { + if (!this._isClientDispatchGenerationCurrent(clientId, clientDispatchGeneration)) { + return; + } const origin = { clientId, clientSeq }; if (action.type === ActionType.SessionWorkingDirectorySet || action.type === ActionType.SessionWorkingDirectoryRemoved) { if (clientContext.clientType !== AgentHostClientType.EditorWindow) { @@ -2729,12 +2742,19 @@ export class AgentService extends Disposable implements IAgentService { } removeClientManagedPermissions(clientId: string): void { - if (!this._managedPermissionsByClient.delete(clientId)) { - return; + this._clientDispatchGenerations.set(clientId, (this._clientDispatchGenerations.get(clientId) ?? 0) + 1); + if (this._managedPermissionsByClient.delete(clientId)) { + this._configurationService.publishRootTransientValues({ + [AgentHostManagedPermissionsConfigKey]: this._getEffectiveManagedPermissions() ?? {}, + }); } - this._configurationService.publishRootTransientValues({ - [AgentHostManagedPermissionsConfigKey]: this._getEffectiveManagedPermissions() ?? {}, - }); + if (!this._clientDispatchQueues.has(clientId)) { + this._clientDispatchGenerations.delete(clientId); + } + } + + private _isClientDispatchGenerationCurrent(clientId: string, generation: number): boolean { + return (this._clientDispatchGenerations.get(clientId) ?? 0) === generation; } private _setClientManagedPermissions(clientId: string, permissions: IManagedPermissions | undefined): IManagedPermissions | undefined { diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index abcbf3bd56e570..3c166fe579bf81 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -6,7 +6,7 @@ import { disposableTimeout } from '../../../base/common/async.js'; import { Emitter } from '../../../base/common/event.js'; import { isJsonRpcResponse } from '../../../base/common/jsonRpcProtocol.js'; -import { Disposable, DisposableMap, DisposableStore } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore, type IDisposable } from '../../../base/common/lifecycle.js'; import { StopWatch } from '../../../base/common/stopwatch.js'; import { hasKey } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; @@ -66,7 +66,7 @@ import { AgentHostTelemetryReporter } from './agentHostTelemetryReporter.js'; /** Default capacity of the server-side action replay buffer. */ const REPLAY_BUFFER_CAPACITY = 1000; -const CLIENT_TOOL_CALL_DISCONNECT_TIMEOUT = 30_000; +const CLIENT_DISCONNECT_GRACE_TIMEOUT = 30_000; /** * Chat-level working-directory subsets are not yet operational in this build. @@ -270,6 +270,8 @@ interface IGraceClientRecord { * is live). Disposing an entry (or the whole map) clears the timer. */ readonly disconnectTimeouts: DisposableMap; + /** Removes the client's managed-permission contribution when reconnect grace expires. */ + readonly managedPermissionsDisconnectTimeout: IDisposable | undefined; } /** @@ -527,17 +529,21 @@ export class ProtocolServerHandler extends Disposable { this._releaseClientSubscriptions(client, record); this._rejectPendingReverseRequestsForConnection(client); if (record.connections.length === 0) { - this._logService.info(`[ProtocolServer] Client disconnected: ${client.clientId}, subscriptions=${subscriptionCount}`); - this._clients.set(client.clientId, { + const clientId = client.clientId; + this._logService.info(`[ProtocolServer] Client disconnected: ${clientId}, subscriptions=${subscriptionCount}`); + this._clients.set(clientId, { state: 'grace', clientInfo: record.clientInfo, telemetryContext: client.telemetryContext, protocolVersion: client.protocolVersion, lastSeenAt: Date.now(), disconnectTimeouts: new DisposableMap(), + managedPermissionsDisconnectTimeout: disposableTimeout( + () => this._agentService.removeClientManagedPermissions(clientId), + CLIENT_DISCONNECT_GRACE_TIMEOUT, + ), }); - this._agentService.removeClientManagedPermissions(client.clientId); - this._handleClientDisconnected(client.clientId); + this._handleClientDisconnected(clientId); this._onDidChangeConnectionCount.fire(this._connectedClientCount); } this._reportClientDisconnected(client, subscriptionCount); @@ -614,7 +620,7 @@ export class ProtocolServerHandler extends Disposable { const counts = this._connectionTelemetryTracker.connect(params.clientId, telemetryTransportToken); client.telemetryConnectionActive = true; if (previousRecord?.state === 'grace') { - previousRecord.disconnectTimeouts.dispose(); + this._disposeGraceTimeouts(previousRecord); } this._onDidChangeConnectionCount.fire(this._connectedClientCount); this._telemetryReporter.clientConnection({ @@ -765,7 +771,7 @@ export class ProtocolServerHandler extends Disposable { const counts = this._connectionTelemetryTracker.connect(params.clientId, telemetryTransportToken); client.telemetryConnectionActive = true; if (existingRecord.state === 'grace') { - existingRecord.disconnectTimeouts.dispose(); + this._disposeGraceTimeouts(existingRecord); } this._onDidChangeConnectionCount.fire(this._connectedClientCount); this._telemetryReporter.clientConnection({ @@ -1017,7 +1023,7 @@ export class ProtocolServerHandler extends Disposable { } record.disconnectTimeouts.deleteAndDispose(chatChannel); const elapsed = Date.now() - record.lastSeenAt; - const delay = Math.max(0, CLIENT_TOOL_CALL_DISCONNECT_TIMEOUT - elapsed); + const delay = Math.max(0, CLIENT_DISCONNECT_GRACE_TIMEOUT - elapsed); record.disconnectTimeouts.set(chatChannel, disposableTimeout(() => { this._releaseActiveClientForSession(session, clientId, chatChannel); }, delay)); @@ -1107,6 +1113,7 @@ export class ProtocolServerHandler extends Disposable { protocolVersion: undefined, lastSeenAt: Date.now(), disconnectTimeouts: new DisposableMap(), + managedPermissionsDisconnectTimeout: undefined, }; this._clients.set(clientId, created); return created; @@ -1205,11 +1212,17 @@ export class ProtocolServerHandler extends Disposable { if (record.state === 'grace' && record.disconnectTimeouts.size === 0 && record.lastSeenAt < cutoff) { + record.managedPermissionsDisconnectTimeout?.dispose(); this._clients.delete(clientId); } } } + private _disposeGraceTimeouts(record: IGraceClientRecord): void { + record.disconnectTimeouts.dispose(); + record.managedPermissionsDisconnectTimeout?.dispose(); + } + private _clearClientToolCallDisconnectTimeout(clientId: string, channel: string): void { const record = this._clients.get(clientId); if (record?.state === 'grace') { @@ -1771,7 +1784,7 @@ export class ProtocolServerHandler extends Disposable { connection.disposables.dispose(); } } else { - record.disconnectTimeouts.dispose(); + this._disposeGraceTimeouts(record); } } this._clients.clear(); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 677801e2cffadc..5e7a0e3fbe825f 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -891,6 +891,69 @@ suite('AgentService (node dispatcher)', () => { listener.dispose(); }); + test('does not apply a queued action from a disconnected client generation', async () => { + const { svc, session } = await createDynamicWorkingDirectorySession(); + const source = URI.from({ scheme: Schemas.inMemory, path: '/workspace/stale-source.txt' }); + await fileService.writeFile(source, VSBuffer.fromString('contents')); + const readStarted = new DeferredPromise(); + const readGate = new DeferredPromise(); + const originalReadFile = fileService.readFile.bind(fileService); + fileService.readFile = async resource => { + if (resource.toString() === source.toString()) { + readStarted.complete(); + await readGate.p; + } + return originalReadFile(resource); + }; + disposables.add(toDisposable(() => fileService.readFile = originalReadFile)); + const clientId = 'disconnected-client'; + const dispatchedClientSequences: number[] = []; + const listener = svc.onDidAction(envelope => { + if (envelope.origin?.clientId === clientId) { + dispatchedClientSequences.push(envelope.origin.clientSeq); + } + }); + + svc.dispatchAction(buildDefaultChatUri(session.toString()), { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', + message: { + text: 'hello', + origin: { kind: MessageKind.User }, + attachments: [{ + type: MessageAttachmentKind.Resource, + uri: source.toString(), + label: 'stale-source.txt', + displayKind: 'document', + }], + }, + }, clientId, 1, AgentHostClientType.EditorWindow); + await readStarted.p; + svc.dispatchAction(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AgentHostManagedPermissionsConfigKey]: { ask: ['Shell(*)'] } }, + }, clientId, 2); + svc.removeClientManagedPermissions(clientId); + const queueDrained = Event.toPromise(Event.filter(svc.onDidAction, envelope => envelope.origin?.clientId === clientId && envelope.origin.clientSeq === 3)); + svc.dispatchAction(session.toString(), { + type: ActionType.SessionWorkingDirectorySet, + directory: URI.file('/workspace/added').toString(), + }, clientId, 3, AgentHostClientType.EditorWindow); + + readGate.complete(); + await queueDrained; + + assert.deepStrictEqual({ + dispatchedClientSequences, + managedPermissions: svc.stateManager.rootState.config?.values[AgentHostManagedPermissionsConfigKey], + }, { + dispatchedClientSequences: [3], + managedPermissions: undefined, + }); + listener.dispose(); + }); + test('reduces working-directory mutations synchronously in dispatch order', async () => { const { svc, session, primary, secondary } = await createDynamicWorkingDirectorySession(); const added = URI.file('/workspace/added'); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index d6259dd25b14a8..37788ac587ff6d 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -1586,23 +1586,59 @@ suite('ProtocolServerHandler', () => { await assert.rejects(readPromise, /Client client-fs-overlap-close disconnected/); }); - test('client disconnect cleans up', () => { - stateManager.createSession(makeSessionSummary()); - stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); + test('client disconnect retains managed permissions through grace and removes them after expiry', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); - const transport = connectClient('client-d', [sessionUri]); - transport.sent.length = 0; + const transport = connectClient('client-d', [sessionUri]); + transport.sent.length = 0; + transport.simulateClose(); + stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionTitleChanged, title: 'After Disconnect' }); - transport.simulateClose(); + await new Promise(resolve => setTimeout(resolve, 29_999)); + const beforeGraceExpiry = [...agentService.removedManagedPermissionClients]; + await new Promise(resolve => setTimeout(resolve, 2)); + + assert.deepStrictEqual({ + sentMessages: transport.sent.length, + beforeGraceExpiry, + afterGraceExpiry: agentService.removedManagedPermissionClients, + }, { + sentMessages: 0, + beforeGraceExpiry: [], + afterGraceExpiry: ['client-d'], + }); + }); + }); - stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionTitleChanged, title: 'After Disconnect' }); + test('reconnect during grace preserves managed permissions', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + const transport1 = connectClient('client-managed-reconnect'); + const initializeResponse = findResponse(transport1.sent, 1) as { result: InitializeResult }; + transport1.simulateClose(); - assert.deepStrictEqual({ - sentMessages: transport.sent.length, - removedManagedPermissionClients: agentService.removedManagedPermissionClients, - }, { - sentMessages: 0, - removedManagedPermissionClients: ['client-d'], + await new Promise(resolve => setTimeout(resolve, 15_000)); + const duringGrace = [...agentService.removedManagedPermissionClients]; + + const transport2 = new MockProtocolTransport(); + server.simulateConnection(transport2); + const reconnectResponse = waitForResponse(transport2, 1); + transport2.simulateMessage(request(1, 'reconnect', { + clientId: 'client-managed-reconnect', + lastSeenServerSeq: initializeResponse.result.serverSeq, + subscriptions: [], + })); + await reconnectResponse; + await new Promise(resolve => setTimeout(resolve, 30_001)); + + assert.deepStrictEqual({ + duringGrace, + afterOriginalGraceExpiry: agentService.removedManagedPermissionClients, + }, { + duringGrace: [], + afterOriginalGraceExpiry: [], + }); }); }); From 77c2ebeb5130720eb2ca462bae9d5e410d42e85f Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:53:20 -0700 Subject: [PATCH 07/12] fix(agent-host): serialize peer chat refresh Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c111c62a-eff3-4ff6-bb8a-8436a1b4babe --- .../agentHost/node/copilot/copilotAgent.ts | 43 +++++++++-------- .../agentHost/test/node/copilotAgent.test.ts | 47 +++++++++++++++++-- 2 files changed, 65 insertions(+), 25 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index eb415c52bd1d3b..87faebf14307f1 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -2654,28 +2654,31 @@ export class CopilotAgent extends Disposable implements IAgent { // Additional (non-default) chats are backed by their own SDK // chat hosted on the owning session entry, keyed by the chat URI. if (context.isPeerChat) { - let entry = await this._ensureChatSession(context.session, chat); - if (!entry) { - throw new Error(`[Copilot] sendMessage for unknown chat: ${chat.toString()}`); - } - const activeClient = this._activeClients.get(context.session); - if (activeClient && await activeClient.requiresRestart(entry.appliedSnapshot)) { - this._logService.info(`[Copilot:${context.sessionId}] Peer chat config changed (requiresRestart=true), refreshing ${chat.toString()}`); - this._sdkSessionsById.delete(entry.sessionId); - await entry.destroySession(); - this._sessions.get(context.sessionId)?.disposePeerChat(chat.toString()); - entry = await this._ensureChatSession(context.session, chat); + const chatKey = chat.toString(); + await this._queueChat(context.sessionId, chatKey, async () => { + let entry = await this._ensureChatSession(context.session, chat); if (!entry) { - throw new Error(`[Copilot] failed to refresh chat: ${chat.toString()}`); + throw new Error(`[Copilot] sendMessage for unknown chat: ${chatKey}`); } - } - if (turnId) { - entry.resetTurnState(turnId, senderClientId, clientType); - } - const sideChat = this._chatBackings.get(chat.toString())?.sideChat; - const existingTurns = sideChat ? await entry.getMessages() : []; - const sdkPrompt = prepareSideChatPrompt(prompt, existingTurns, sideChat); - await entry.send(sdkPrompt, attachments, turnId, this._resolveSdkMode(context.session), senderClientId, clientType); + const activeClient = this._activeClients.get(context.session); + if (activeClient && await activeClient.requiresRestart(entry.appliedSnapshot)) { + this._logService.info(`[Copilot:${context.sessionId}] Peer chat config changed (requiresRestart=true), refreshing ${chatKey}`); + this._sdkSessionsById.delete(entry.sessionId); + await entry.destroySession(); + this._sessions.get(context.sessionId)?.disposePeerChat(chatKey); + entry = await this._ensureChatSession(context.session, chat); + if (!entry) { + throw new Error(`[Copilot] failed to refresh chat: ${chatKey}`); + } + } + if (turnId) { + entry.resetTurnState(turnId, senderClientId, clientType); + } + const sideChat = this._chatBackings.get(chatKey)?.sideChat; + const existingTurns = sideChat ? await entry.getMessages() : []; + const sdkPrompt = prepareSideChatPrompt(prompt, existingTurns, sideChat); + await entry.send(sdkPrompt, attachments, turnId, this._resolveSdkMode(context.session), senderClientId, clientType); + }); return; } await this._queueSession(context.sessionId, async () => { diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 3245d477ab0be4..c665a658f8c232 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -5267,7 +5267,7 @@ suite('CopilotAgent', () => { } }); - test('sendMessage refreshes a peer chat when managed permissions change', async () => { + test('sendMessage serializes concurrent peer chat refreshes when managed permissions change', async () => { const { agent, configurationService } = createTestAgentContext(disposables); try { const session = AgentSession.uri('copilotcli', 'route-managed-refresh'); @@ -5275,19 +5275,56 @@ suite('CopilotAgent', () => { agent.getOrCreateActiveClient(session, { clientId: 'client-A' }).tools = []; const old = makeFakeChatSession(session, 'sdk-old'); const fresh = makeFakeChatSession(session, 'sdk-fresh'); + Object.assign(fresh.fake, { + appliedSnapshot: { + tools: [], + plugins: [], + mcpServers: {}, + managedPermissions: { disableBypassPermissionsMode: 'disable' }, + } satisfies IActiveClientSnapshot, + }); + setPeerChatStub(agent, chat, old.fake); + let oldDestroyCalls = 0; + Object.assign(old.fake, { + async destroySession(): Promise { + oldDestroyCalls++; + old.rec.disposed = true; + } + }); let ensureCalls = 0; (agent as unknown as ChatInternals)._ensureChatSession = async () => { ensureCalls++; - return ensureCalls === 1 ? old.fake : fresh.fake; + const existing = getPeerChatStub(agent, chat); + if (existing) { + return existing; + } + setPeerChatStub(agent, chat, fresh.fake); + return fresh.fake; }; configurationService.updateRootConfig({ [AgentHostManagedPermissionsConfigKey]: { disableBypassPermissionsMode: 'disable' }, }); - await agent.chats.sendMessage(chat, 'after-policy-change', undefined); + await Promise.all([ + agent.chats.sendMessage(chat, 'first-after-policy-change', undefined), + agent.chats.sendMessage(chat, 'second-after-policy-change', undefined), + ]); - assert.strictEqual(old.rec.disposed, true); - assert.deepStrictEqual(fresh.rec.sends.map(send => send.prompt), ['after-policy-change']); + assert.deepStrictEqual({ + oldDestroyCalls, + oldDisposed: old.rec.disposed, + freshDisposed: fresh.rec.disposed, + freshPrompts: fresh.rec.sends.map(send => send.prompt), + ensureCalls, + livePeerIsFresh: getPeerChatStub(agent, chat) === fresh.fake, + }, { + oldDestroyCalls: 1, + oldDisposed: true, + freshDisposed: false, + freshPrompts: ['first-after-policy-change', 'second-after-policy-change'], + ensureCalls: 3, + livePeerIsFresh: true, + }); } finally { await disposeAgent(agent); } From c516a47c8d65a7903587f890bd86e84efbf11b5d Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:31:33 -0700 Subject: [PATCH 08/12] docs(agent-host): clarify managed permission clear Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c111c62a-eff3-4ff6-bb8a-8436a1b4babe --- src/vs/platform/agentHost/common/agentHostSchema.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 4308850840ec38..ee84e1d9f8bdac 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -414,10 +414,8 @@ const managedPermissionsProperty = schemaProperty({ items: { type: 'string', title: localize('agentHost.config.managedPermissions.rule', "Permission rule") }, }, }, - // Intentionally NO `default`: the key follows omit semantics. When no - // restrictive policy applies the renderer forwards `undefined`, so - // `getRootValue` stays `undefined` and the launcher omits `managedSettings` - // entirely rather than forwarding an empty (and misleading) object. + // No default: `{}` is the wire-level clear sentinel and is normalized to + // `undefined` before SDK launch, so `managedSettings` is omitted. }); /** From f1dcbecf185ac1d1bb97b7eb55823410ccca5090 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:32:40 -0700 Subject: [PATCH 09/12] fix(agent-host): require managed policy support Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d5d4d699-33e2-4a55-9d48-57d2e483dd3d --- .../browser/remoteAgentHostProtocolClient.ts | 105 ++++++++++++++++-- .../browser/remoteAgentHostServiceImpl.ts | 9 +- .../remoteAgentHostProtocolClient.test.ts | 101 ++++++++++++++++- .../remoteAgentHostService.test.ts | 35 +++++- 4 files changed, 233 insertions(+), 17 deletions(-) diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 73d6492ed36f67..70342399704852 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -27,7 +27,7 @@ import { AgentHostResourceIdentity, AgentHostResourcePermissionError, IAgentHost import type { ClientNotificationMap, CommandMap, JsonRpcErrorResponse, JsonRpcRequest } from '../common/state/protocol/messages.js'; import { ActionType, type ActionEnvelope, type ChatAction, type ClientAnnotationsAction, type ClientChangesetAction, type INotification, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js'; import { MessageAttachmentKind, SessionSummary, ROOT_STATE_URI, StateComponents, isAhpRootChannel, type ClientPluginCustomization, type Message, type RootState } from '../common/state/sessionState.js'; -import { SUPPORTED_PROTOCOL_VERSIONS } from '../common/state/protocol/version/registry.js'; +import { compareProtocolVersions, SUPPORTED_PROTOCOL_VERSIONS } from '../common/state/protocol/version/registry.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, ProtocolError, ReconnectResultType, type ProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { type IVscodeUpgradeResult } from '../common/state/protocolUpgrade.js'; import { isClientTransport, type IProtocolTransport } from '../common/state/sessionTransport.js'; @@ -50,6 +50,7 @@ import { isFileResourceRead } from '../common/resourceReadLogging.js'; import { ResourceSet } from '../../../base/common/map.js'; const AHP_CLIENT_CONNECTION_CLOSED = -32000; +const MANAGED_PERMISSIONS_MIN_PROTOCOL_VERSION = '0.8.0'; /** Initial delay before the first transport-level reconnect attempt. */ const RECONNECT_INITIAL_DELAY_MS = 1_000; @@ -276,6 +277,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC */ private readonly _grantedImplicitReadUris = new ResourceSet(); private readonly _implicitReadGrants = this._register(new DisposableStore()); + private _didCloseConnectionResources = false; get clientId(): string { return this._clientId; @@ -293,6 +295,12 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC return this._state.kind; } + get connectionError(): ProtocolError | undefined { + return this._state.kind === AgentHostClientState.Incompatible || this._state.kind === AgentHostClientState.Closed + ? this._state.error + : undefined; + } + /** * The latest `initialize` response from the host, or `undefined` if * the handshake has not completed yet. Exposed observably so callers can @@ -447,7 +455,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC // Advertise every version this client can negotiate, most-preferred first, so an // older host (a cloud sandbox running a 0.5.x `copilotd`) can negotiate down // instead of rejecting the connection. A current host still picks the newest. - protocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS], + protocolVersions: this._supportedProtocolVersions(), clientId: this._clientId, clientInfo: this._clientInfo, ...this._clientConnectionTelemetryMeta(), @@ -626,6 +634,10 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC this._logService.info(`[RemoteAgentHostProtocol] Reconnected to ${this._address}.`); } catch (err) { this._logService.warn(`[RemoteAgentHostProtocol] Reconnect attempt failed for ${this._address}: ${err instanceof Error ? err.message : String(err)}`); + if (err instanceof ProtocolError && err.code === AhpErrorCodes.UnsupportedProtocolVersion) { + this._markIncompatible(err); + return; + } transport?.dispose(); if (this._state.kind !== AgentHostClientState.Reconnecting) { return; @@ -657,7 +669,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC this._logService.info(`[RemoteAgentHostProtocol] Server forgot client ${this._clientId}; initializing a fresh connection.`); const initializeResult = await this._dispatchRequest('initialize', { channel: ROOT_STATE_URI, - protocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS], + protocolVersions: this._supportedProtocolVersions(), clientId: this._clientId, clientInfo: this._clientInfo, ...this._clientConnectionTelemetryMeta(), @@ -673,6 +685,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC } private _applyInitializeResult(result: CommandMap['initialize']['result']): void { + this._assertManagedPermissionsSupported(result.protocolVersion); this._initializeResult.set(result, undefined); this._serverSeq = result.serverSeq; if (result.defaultDirectory) { @@ -1209,11 +1222,12 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC } private _handleMessage(msg: ProtocolMessage): void { - if (this._state.kind === AgentHostClientState.Closed) { + if (this._state.kind === AgentHostClientState.Closed + || (this._state.kind === AgentHostClientState.Incompatible && !isJsonRpcResponse(msg))) { // After close, the transport may still emit late messages (e.g. // because the same shared event source is also feeding a newer - // transport for the same connectionId). Drop them so they can't - // trigger any side effects. + // transport for the same connectionId). An incompatible connection + // only accepts responses for the explicit upgrade request. return; } @@ -1310,11 +1324,19 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC this._state.outbox.length = 0; } this._rejectPendingRequests(error); + this._closeConnectionResources(); + this._transitionTo({ kind: AgentHostClientState.Closed, error }); + this._onDidClose.fire(); + } + + private _closeConnectionResources(): void { + if (this._didCloseConnectionResources) { + return; + } + this._didCloseConnectionResources = true; this._grantedImplicitReadUris.clear(); this._implicitReadGrants.clear(); this._resourceService.connectionClosed(this._resourceIdentity); - this._transitionTo({ kind: AgentHostClientState.Closed, error }); - this._onDidClose.fire(); } private async _raceClose(promise: Promise): Promise { @@ -1509,15 +1531,74 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC * merge-based root config drops any previously forwarded permissions. */ private _updateManagedPermissions(): void { - const permissions = deriveManagedPermissions({ - globalAutoApprove: this._configurationService.inspect(GLOBAL_AUTO_APPROVE_SETTING_ID).policyValue, - terminalAutoApproveEnabled: this._configurationService.inspect(TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID).policyValue, - }); + const permissions = this._deriveManagedPermissions(); + const protocolVersion = this._initializeResult.get()?.protocolVersion; + if (protocolVersion && compareProtocolVersions(protocolVersion, MANAGED_PERMISSIONS_MIN_PROTOCOL_VERSION) < 0) { + if (!permissions) { + return; + } + const error = this._managedPermissionsUnsupportedError(protocolVersion); + const wasConnected = this._state.kind === AgentHostClientState.Connected; + this._markIncompatible(error); + if (!wasConnected) { + throw error; + } + return; + } // Root config patches merge over existing values. An empty object is // the wire-safe clear sentinel because JSON drops `undefined`. this._dispatchRootConfig({ [AgentHostManagedPermissionsConfigKey]: permissions ?? {} }); } + private _deriveManagedPermissions() { + return deriveManagedPermissions({ + globalAutoApprove: this._configurationService.inspect(GLOBAL_AUTO_APPROVE_SETTING_ID).policyValue, + terminalAutoApproveEnabled: this._configurationService.inspect(TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID).policyValue, + }); + } + + private _supportedProtocolVersions(): string[] { + if (!this._deriveManagedPermissions()) { + return [...SUPPORTED_PROTOCOL_VERSIONS]; + } + return SUPPORTED_PROTOCOL_VERSIONS.filter(version => + compareProtocolVersions(version, MANAGED_PERMISSIONS_MIN_PROTOCOL_VERSION) >= 0); + } + + private _assertManagedPermissionsSupported(protocolVersion: string): void { + if (this._deriveManagedPermissions() + && compareProtocolVersions(protocolVersion, MANAGED_PERMISSIONS_MIN_PROTOCOL_VERSION) < 0) { + throw this._managedPermissionsUnsupportedError(protocolVersion); + } + } + + private _managedPermissionsUnsupportedError(protocolVersion: string): ProtocolError { + return new ProtocolError( + AhpErrorCodes.UnsupportedProtocolVersion, + `Managed permissions require Agent Host protocol ${MANAGED_PERMISSIONS_MIN_PROTOCOL_VERSION} or newer; negotiated ${protocolVersion}.`, + { supportedVersions: [`>=${MANAGED_PERMISSIONS_MIN_PROTOCOL_VERSION}`] }, + ); + } + + private _markIncompatible(error: ProtocolError): void { + this._cancelLivenessTimers(); + if (this._state.kind === AgentHostClientState.Connecting) { + this._state.outbox.length = 0; + } else if (this._state.kind === AgentHostClientState.Reconnecting) { + const reconnect = this._state.reconnect; + if (reconnect.timeoutHandle !== undefined) { + clearTimeout(reconnect.timeoutHandle); + } + if (!reconnect.gate.isSettled) { + reconnect.gate.error(error); + } + reconnect.outbox.length = 0; + } + this._rejectPendingRequests(error); + this._closeConnectionResources(); + this._transitionTo({ kind: AgentHostClientState.Incompatible, error }); + } + private _updatePreferLongContextEnabled(): void { const enabled = this._configurationService.getValue(PREFER_LONG_CONTEXT_SETTING_ID) === true; this._dispatchRootConfig({ [AgentHostPreferLongContextEnabledConfigKey]: enabled }); diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts index 0647e08a673651..3f8dc43d17f46c 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts @@ -541,8 +541,15 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo entry.status = RemoteAgentHostConnectionStatus.connected; this._onDidChangeConnections.fire(); break; - case AgentHostClientState.Connecting: case AgentHostClientState.Incompatible: + entry.connected = false; + entry.status = client.connectionError + ? RemoteAgentHostConnectionStatus.fromConnectError(client.connectionError, [PROTOCOL_VERSION]) ?? RemoteAgentHostConnectionStatus.disconnected + : RemoteAgentHostConnectionStatus.disconnected; + this._reconnectAttempts.delete(address); + this._onDidChangeConnections.fire(); + break; + case AgentHostClientState.Connecting: case AgentHostClientState.Closed: break; } diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts index 64646848ad63b6..ea890a60eecad7 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts @@ -957,6 +957,71 @@ suite('RemoteAgentHostProtocolClient', () => { }); }); + test('requires a managed-permissions-capable protocol when restrictive policy is present', async () => { + const configurationService = new ManagedPermissionPolicyConfigurationService({ + [GLOBAL_AUTO_APPROVE_SETTING_ID]: false, + }); + const { client, transport } = createClient(disposables.add(new TestProtocolTransport()), createPermissionService(), undefined, new NullLogService(), configurationService); + const connect = client.connect(); + const initialize = transport.sentMessages[0] as JsonRpcRequest; + + assert.deepStrictEqual((initialize.params as { protocolVersions: string[] }).protocolVersions, [PROTOCOL_VERSION]); + transport.fireMessage({ + jsonrpc: '2.0', + id: initialize.id, + result: { protocolVersion: '0.7.0', serverSeq: 0, snapshots: [] }, + }); + + await assertRemoteProtocolError(connect, { + code: AhpErrorCodes.UnsupportedProtocolVersion, + message: 'Managed permissions require Agent Host protocol 0.8.0 or newer; negotiated 0.7.0.', + data: { supportedVersions: ['>=0.8.0'] }, + }); + assert.strictEqual(client.connectionState, AgentHostClientState.Incompatible); + assert.strictEqual(transport.sentMessages.length, 1); + }); + + test('fails closed when restrictive policy appears on an older connected host', async () => { + const configurationService = new ManagedPermissionPolicyConfigurationService({}); + const { client, transport } = createClient(disposables.add(new TestProtocolTransport()), createPermissionService(), undefined, new NullLogService(), configurationService); + const connect = client.connect(); + const initialize = transport.sentMessages[0] as JsonRpcRequest; + transport.fireMessage({ + jsonrpc: '2.0', + id: initialize.id, + result: { protocolVersion: '0.7.0', serverSeq: 0, snapshots: [] }, + }); + await connect; + assert.strictEqual( + transport.sentMessages.some(message => + hasKey(message, { method: true }) + && message.method === 'dispatchAction' + && Object.hasOwn(getRootConfig(message as JsonRpcNotification), AgentHostManagedPermissionsConfigKey)), + false, + ); + + configurationService.setPolicyValue(GLOBAL_AUTO_APPROVE_SETTING_ID, false); + fireConfigurationChange(configurationService, GLOBAL_AUTO_APPROVE_SETTING_ID); + + assert.strictEqual(client.connectionState, AgentHostClientState.Incompatible); + const sentBeforeReverseRequest = transport.sentMessages.length; + transport.fireMessage({ + jsonrpc: '2.0', + id: 42, + method: 'resourceRead', + params: { channel: ROOT_STATE_URI, uri: URI.file('/workspace/blocked.txt').toString() }, + }); + await flushMicrotasks(); + assert.strictEqual(transport.sentMessages.length, sentBeforeReverseRequest); + assert.strictEqual( + transport.sentMessages.some(message => + hasKey(message, { method: true }) + && message.method === 'dispatchAction' + && Object.hasOwn(getRootConfig(message as JsonRpcNotification), AgentHostManagedPermissionsConfigKey)), + false, + ); + }); + test('forwards the empty clear sentinel when only non-policy values are set', async () => { // User/workspace values are restrictive, but no enterprise policy is set — // only `policyValue` maps, so nothing must be forwarded. @@ -1690,7 +1755,7 @@ suite('RemoteAgentHostProtocolClient', () => { * client plus a `transports` array recording each transport handed * out, so tests can drive handshake/reconnect interactions. */ - function createFactoryClient(permissionService = createPermissionService(), clientInfo?: Implementation): { client: RemoteAgentHostProtocolClient; transports: TestClientProtocolTransport[] } { + function createFactoryClient(permissionService = createPermissionService(), clientInfo?: Implementation, configurationService: TestConfigurationService = new TestConfigurationService()): { client: RemoteAgentHostProtocolClient; transports: TestClientProtocolTransport[] } { const transports: TestClientProtocolTransport[] = []; const factory = () => { const t = disposables.add(new TestClientProtocolTransport()); @@ -1698,7 +1763,7 @@ suite('RemoteAgentHostProtocolClient', () => { return t; }; const client = disposables.add(new RemoteAgentHostProtocolClient( - 'test.example:1234', factory, undefined, undefined, clientInfo, new NullLogService(), permissionService, new TestConfigurationService(), + 'test.example:1234', factory, undefined, undefined, clientInfo, new NullLogService(), permissionService, configurationService, )); return { client, transports }; } @@ -1779,6 +1844,38 @@ suite('RemoteAgentHostProtocolClient', () => { } }); + test('treats managed-permission protocol incompatibility during reconnect fallback as terminal', async function () { + this.timeout(10_000); + const configurationService = new ManagedPermissionPolicyConfigurationService({}); + const { client, transports } = createFactoryClient(createPermissionService(), undefined, configurationService); + const connectPromise = client.connect(); + await completeHandshake(transports[0], connectPromise); + + transports[0].fireClose(); + await waitForReconnecting(client); + configurationService.setPolicyValue(GLOBAL_AUTO_APPROVE_SETTING_ID, false); + const reconnectTransport = await waitForTransport(transports, 1); + reconnectTransport.connectDeferred.complete(); + const reconnect = await waitForRequest(reconnectTransport, 'reconnect'); + reconnectTransport.fireMessage({ + jsonrpc: '2.0', + id: reconnect.id, + error: { code: AhpErrorCodes.NotFound, message: 'Reconnect client not found' }, + }); + + const initialize = await waitForRequest(reconnectTransport, 'initialize'); + assert.deepStrictEqual((initialize.params as { protocolVersions: string[] }).protocolVersions, [PROTOCOL_VERSION]); + reconnectTransport.fireMessage({ + jsonrpc: '2.0', + id: initialize.id, + error: { code: AhpErrorCodes.UnsupportedProtocolVersion, message: 'Protocol versions do not match' }, + }); + await flushMicrotasks(); + + assert.strictEqual(client.connectionState, AgentHostClientState.Incompatible); + assert.strictEqual(transports.length, 2); + }); + test('replays pending optimistic actions after reconnect', async function () { this.timeout(10_000); return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts index aeb890ff574307..bfd77011ee57bd 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts @@ -15,12 +15,15 @@ import { IConfigurationService, type IConfigurationChangeEvent } from '../../../ import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILabelService, type ResourceLabelFormatter } from '../../../label/common/label.js'; import { AgentsWindowRemoteAgentHostService, RemoteAgentHostService } from '../../browser/remoteAgentHostServiceImpl.js'; +import { AgentHostClientState } from '../../browser/remoteAgentHostProtocolClient.js'; import { parseRemoteAgentHostInput, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, entryToRawEntry, type IRawRemoteAgentHostEntry, type IRemoteAgentHostEntry } from '../../common/remoteAgentHostService.js'; import { AGENT_HOST_SCHEME, agentHostAuthority } from '../../common/agentHostUri.js'; import { DeferredPromise } from '../../../../base/common/async.js'; import { InMemoryStorageService, IStorageService } from '../../../storage/common/storage.js'; import type { Implementation } from '../../common/state/protocol/common/commands.js'; import { agentsWindowAgentHostClientInfo, editorWindowAgentHostClientInfo } from '../../common/agentHostClientInfo.js'; +import { AhpErrorCodes } from '../../common/state/protocol/errors.js'; +import { ProtocolError } from '../../common/state/sessionProtocol.js'; // ---- Mock transport --------------------------------------------------------- @@ -41,11 +44,14 @@ class MockProtocolClient extends Disposable { private readonly _onDidClose = this._register(new Emitter()); readonly onDidClose = this._onDidClose.event; + private readonly _onDidChangeConnectionState = this._register(new Emitter()); + readonly onDidChangeConnectionState = this._onDidChangeConnectionState.event; readonly onDidAction = Event.None; readonly onDidNotification = Event.None; - readonly onDidChangeConnectionState = Event.None; readonly onDidReceiveOtlpLogs = Event.None; - readonly connectionState = 'connecting' as const; + private _connectionState = AgentHostClientState.Connecting; + get connectionState(): AgentHostClientState { return this._connectionState; } + connectionError: ProtocolError | undefined; readonly initializeResult = undefined; readonly telemetryCapabilities = undefined; readonly triggerVscodeUpgradeCalls: string[] = []; @@ -68,6 +74,12 @@ class MockProtocolClient extends Disposable { fireClose(): void { this._onDidClose.fire(); } + + fireConnectionState(state: AgentHostClientState, error?: ProtocolError): void { + this._connectionState = state; + this.connectionError = error; + this._onDidChangeConnectionState.fire(state); + } } // ---- Test configuration service --------------------------------------------- @@ -271,6 +283,25 @@ suite('RemoteAgentHostService', () => { assert.strictEqual(connection.clientId, createdClients[0].clientId); }); + test('incompatible transition removes a previously connected client without reconnecting', async () => { + configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); + createdClients[0].connectDeferred.complete(); + await waitForConnected(); + + const changed = Event.toPromise(service.onDidChangeConnections); + createdClients[0].fireConnectionState( + AgentHostClientState.Incompatible, + new ProtocolError(AhpErrorCodes.UnsupportedProtocolVersion, 'Managed permissions require a newer host.', { supportedVersions: ['>=0.8.0'] }), + ); + await changed; + + assert.strictEqual(service.getConnection('ws://host1:8080'), undefined); + const entry = service.connections.find(connection => connection.address === 'host1:8080'); + assert.ok(entry); + assert.strictEqual(entry.status.kind, 'incompatible'); + assert.strictEqual(createdClients.length, 1); + }); + test('removes connection when setting entry is removed', async () => { // Add a connection configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); From 241afebabaf9727b074396038028dd4e815ecd9b Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:09:52 -0700 Subject: [PATCH 10/12] fix(agent-host): use canonical managed shell rule Emit the runtime's kind-only Shell rule for terminal managed policy, validate the supported managed permission grammar in policy diagnostics, and distinguish client injection from the provider account/device baseline. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/common/agentHostSchema.ts | 68 +++++++++++++++++-- .../test/common/agentHostSchema.test.ts | 20 +++++- .../agentHost/test/node/agentService.test.ts | 4 +- .../test/node/copilotSessionLauncher.test.ts | 2 +- .../browser/actions/developerActions.ts | 30 +++++++- 5 files changed, 112 insertions(+), 12 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index ee84e1d9f8bdac..2bfb1251e12b1d 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -319,12 +319,12 @@ export interface IManagedPermissions { /** * The runtime permission-rule string emitted when managed - * `chat.tools.terminal.enableAutoApprove` is `false`. `Shell(*)` is the exact - * all-shell boundary confirmed by the runtime managed-permission parser. + * `chat.tools.terminal.enableAutoApprove` is `false`. The kind-only `Shell` + * rule matches all shell commands in the runtime managed-permission parser. * Centralized as a single constant so the grammar lives in one place. Generic * `Tool(...)` rules are NOT supported by the runtime and must never be emitted. */ -export const MANAGED_PERMISSION_TERMINAL_ASK_RULE = 'Shell(*)'; +export const MANAGED_PERMISSION_TERMINAL_ASK_RULE = 'Shell'; /** * The enterprise-policy inputs — read exclusively from @@ -345,7 +345,7 @@ export interface IManagedPermissionPolicyInputs { * runtime-supported permission rules are emitted: * * - managed `chat.tools.global.autoApprove === false` → `disableBypassPermissionsMode: "disable"`; - * - managed `chat.tools.terminal.enableAutoApprove === false` → the all-shell `ask` rule `Shell(*)`. + * - managed `chat.tools.terminal.enableAutoApprove === false` → the all-shell `ask` rule `Shell`. * * Per-tool eligibility (`chat.tools.eligibleForAutoApproval`) is intentionally * NOT mapped: the runtime rejects generic `Tool(...)` rules, so there is no @@ -388,6 +388,66 @@ export function normalizeManagedPermissions(permissions: IManagedPermissions | u return permissions && Object.keys(permissions).length > 0 ? permissions : undefined; } +const managedPermissionRuleFamilies = new Set(['bash', 'shell', 'powershell', 'read', 'edit', 'write', 'domain']); +const managedPermissionShellRuleFamilies = new Set(['bash', 'shell', 'powershell']); + +/** + * Return parser-compatible validation issues for managed permission rules. + * Provider runtimes remain authoritative for family-specific path and domain patterns. + */ +export function validateManagedPermissionRules(permissions: IManagedPermissions | undefined): readonly string[] { + if (!permissions) { + return []; + } + + const issues: string[] = []; + for (const list of ['deny', 'ask', 'allow'] as const) { + for (const [index, rule] of (permissions[list] ?? []).entries()) { + const issue = validateManagedPermissionRule(rule); + if (issue) { + issues.push(`${list}.${index}: ${issue}`); + } + } + } + return issues; +} + +function validateManagedPermissionRule(rule: string): string | undefined { + const openParenthesis = rule.indexOf('('); + let family = rule; + let argument: string | undefined; + if (openParenthesis !== -1) { + if (!rule.endsWith(')')) { + return `Invalid rule format: ${rule}`; + } + family = rule.slice(0, openParenthesis); + argument = rule.slice(openParenthesis + 1, -1); + if (!argument || argument.includes(')')) { + return `Invalid rule format: ${rule}`; + } + } + if (!family || ![...family].every(character => /[a-zA-Z0-9_./@-]/.test(character))) { + return `Invalid rule format: ${rule}`; + } + + const normalizedFamily = family.toLowerCase(); + if (!managedPermissionRuleFamilies.has(normalizedFamily)) { + return `Unsupported managed permission rule family '${family}'; expected Bash, Shell, PowerShell, Read, Edit, Write, or Domain`; + } + if (!argument || !managedPermissionShellRuleFamilies.has(normalizedFamily)) { + return undefined; + } + if (argument.endsWith(' *')) { + return argument.slice(0, -2).trimEnd() + ? undefined + : 'Invalid managed shell permission rule: wildcard requires a command prefix'; + } + if (argument.includes('*') && !argument.endsWith(':*')) { + return `Unsupported managed shell wildcard pattern '${argument}'; use ' *' or the canonical ':*' suffix`; + } + return undefined; +} + const managedPermissionsProperty = schemaProperty({ type: 'object', title: localize('agentHost.config.managedPermissions.title', "Managed Permissions"), diff --git a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts index 0d86f18f309d32..a9224d54e68906 100644 --- a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import type { IConfigurationValue } from '../../../configuration/common/configuration.js'; -import { createSchema, deriveManagedPermissions, migrateLegacyAutopilotConfig, normalizeAgentHostTerminalAutoApproveRulesConfig, normalizeManagedPermissions, platformRootSchema, platformSessionSchema, schemaProperty, AgentHostManagedPermissionsConfigKey, MANAGED_PERMISSION_TERMINAL_ASK_RULE, type AgentHostTerminalAutoApproveRules, type AutoApproveLevel, type IManagedPermissions, type IPermissionsValue, type SessionMode } from '../../common/agentHostSchema.js'; +import { createSchema, deriveManagedPermissions, migrateLegacyAutopilotConfig, normalizeAgentHostTerminalAutoApproveRulesConfig, normalizeManagedPermissions, platformRootSchema, platformSessionSchema, schemaProperty, AgentHostManagedPermissionsConfigKey, MANAGED_PERMISSION_TERMINAL_ASK_RULE, validateManagedPermissionRules, type AgentHostTerminalAutoApproveRules, type AutoApproveLevel, type IManagedPermissions, type IPermissionsValue, type SessionMode } from '../../common/agentHostSchema.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js'; @@ -452,7 +452,7 @@ suite('agentHostSchema', () => { assert.deepStrictEqual(deriveManagedPermissions({ globalAutoApprove: undefined, terminalAutoApproveEnabled: false, - }), { ask: ['Shell(*)'] } satisfies IManagedPermissions); + }), { ask: ['Shell'] } satisfies IManagedPermissions); }); test('derived value validates against the managed-permissions root schema', () => { @@ -461,7 +461,21 @@ suite('agentHostSchema', () => { terminalAutoApproveEnabled: false, }); assert.ok(permissions); - assert.strictEqual(platformRootSchema.validate(AgentHostManagedPermissionsConfigKey, permissions), true); + assert.deepStrictEqual({ + schema: platformRootSchema.validate(AgentHostManagedPermissionsConfigKey, permissions), + rules: validateManagedPermissionRules(permissions), + }, { + schema: true, + rules: [], + }); + }); + + test('reports runtime-incompatible managed shell wildcards', () => { + assert.deepStrictEqual(validateManagedPermissionRules({ + ask: ['Shell(*)', 'Shell(git *)', 'PowerShell(Get-Item:*)'], + }), [ + `ask.0: Unsupported managed shell wildcard pattern '*'; use ' *' or the canonical ':*' suffix`, + ]); }); test('normalizes the root-config clear sentinel to no policy', () => { diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 5e7a0e3fbe825f..ab7ecc24ef45d6 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -932,7 +932,7 @@ suite('AgentService (node dispatcher)', () => { await readStarted.p; svc.dispatchAction(ROOT_STATE_URI, { type: ActionType.RootConfigChanged, - config: { [AgentHostManagedPermissionsConfigKey]: { ask: ['Shell(*)'] } }, + config: { [AgentHostManagedPermissionsConfigKey]: { ask: ['Shell'] } }, }, clientId, 2); svc.removeClientManagedPermissions(clientId); const queueDrained = Event.toPromise(Event.filter(svc.onDidAction, envelope => envelope.origin?.clientId === clientId && envelope.origin.clientSeq === 3)); @@ -1052,7 +1052,7 @@ suite('AgentService (node dispatcher)', () => { type: ActionType.RootConfigChanged, config: { customizations: [customization], - [AgentHostManagedPermissionsConfigKey]: { ask: ['Shell(*)'] }, + [AgentHostManagedPermissionsConfigKey]: { ask: ['Shell'] }, }, }, 'test-client', 1); diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts index 8ecfd0d69fc419..e3a0225f386f93 100644 --- a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -463,7 +463,7 @@ suite('CopilotSessionLauncher shared session config', () => { return session; }, }; - const permissions = { disableBypassPermissionsMode: 'disable', ask: ['Shell(*)'] }; + const permissions = { disableBypassPermissionsMode: 'disable', ask: ['Shell'] }; const launcher = createTestLauncherWithRootValues({ [AgentHostManagedPermissionsConfigKey]: permissions }); const basePlan = { client, diff --git a/src/vs/workbench/browser/actions/developerActions.ts b/src/vs/workbench/browser/actions/developerActions.ts index 04ab38aadb3afc..8e55dfc0186787 100644 --- a/src/vs/workbench/browser/actions/developerActions.ts +++ b/src/vs/workbench/browser/actions/developerActions.ts @@ -55,6 +55,7 @@ import * as json from '../../../base/common/json.js'; import { getParseErrorMessage } from '../../../base/common/jsonErrorMessages.js'; import { IAgentHostService } from '../../../platform/agentHost/common/agentService.js'; import { IAgentHostEnablementService } from '../../../platform/agentHost/common/agentHostEnablementService.js'; +import { deriveManagedPermissions, GLOBAL_AUTO_APPROVE_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, validateManagedPermissionRules } from '../../../platform/agentHost/common/agentHostSchema.js'; class InspectContextKeysAction extends Action2 { @@ -864,6 +865,7 @@ class PolicyDiagnosticsAction extends Action2 { } content += '## Managed Settings\n\n'; + content += '*This section covers GitHub Copilot managed-settings delivery channels. Traditional VS Code policies from a configuration profile are reported under Policy-Controlled Settings and may synthesize the Agent Host client injection shown below even when no Copilot managed-settings channel is active.*\n\n'; try { const policyData = defaultAccountService.policyData; const serverManagedSettings = policyData?.managedSettings ?? {}; @@ -981,11 +983,35 @@ class PolicyDiagnosticsAction extends Action2 { content += '*No managed-settings keys are supplied by any channel.*\n\n'; } - content += '### Agent Runtime Resolution\n\n'; - content += '*Resolved independently by each provider through its own SDK/runtime. This may include runtime-owned keys that VS Code does not declare as configuration policies.*\n\n'; + content += '### Agent Host Client Injection\n\n'; + content += '*Synthesized by VS Code from effective managed policy values and forwarded to supporting Agent Host providers as session-local managed permissions.*\n\n'; + const agentHostManagedPermissions = deriveManagedPermissions({ + globalAutoApprove: configurationService.inspect(GLOBAL_AUTO_APPROVE_SETTING_ID).policyValue, + terminalAutoApproveEnabled: configurationService.inspect(TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID).policyValue, + }); + content += '**Synthesized managed permissions**\n\n'; + content += jsonBlock(agentHostManagedPermissions ?? {}); + content += `**Expected session runtime provenance**: ${agentHostManagedPermissions ? '`client` when no account/device policy contributes; `mixed` otherwise' : 'the account/device baseline shown below'}\n\n`; + const agentHostManagedPermissionIssues = validateManagedPermissionRules(agentHostManagedPermissions); + content += `**Rule validation issues (${agentHostManagedPermissionIssues.length})**\n\n`; + if (agentHostManagedPermissionIssues.length > 0) { + for (const issue of agentHostManagedPermissionIssues) { + content += `- ${issue}\n`; + parseErrors.push({ stage: 'agentHost: client permissions', message: issue }); + } + content += '\n'; + } else { + content += '*None.*\n\n'; + } + + content += '### Agent Runtime Account and Device Baseline\n\n'; + content += '*Queried from each provider when this report is generated. The SDK query covers account/server and device policy, but intentionally excludes the session-local Agent Host client injection above and may use the provider runtime\'s own policy cache. Therefore `source: none` here does not mean that synthesized client permissions are inactive; a created session reports `client` or `mixed` provenance after applying them.*\n\n'; if (!agentHostEnablementService.enabled.get()) { content += '*Agent Host is disabled; runtime managed-settings diagnostics were not queried.*\n\n'; } else { + content += PROPERTY_VALUE_TABLE_HEADER; + content += `| Queried | ${new Date().toISOString()} |\n`; + content += '| Force refresh | Not supported by the provider runtime API |\n\n'; try { const runtimeDiagnostics = await agentHostService.getManagedSettingsDiagnostics(); if (runtimeDiagnostics.length === 0) { From 0c61d2051129da6496b704d08760f644d06c655b Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:00:19 -0700 Subject: [PATCH 11/12] refactor(agent-host): narrow managed policy bridge Limit the legacy bridge to its two exact restrictive outputs, use root-schema feature detection instead of protocol-version inference, and consolidate overlapping tests while preserving disconnect, refresh, and diagnostics coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/remoteAgentHostProtocolClient.ts | 62 +++---- .../browser/remoteAgentHostServiceImpl.ts | 10 + .../agentHost/common/agentHostSchema.ts | 169 +++-------------- .../platform/agentHost/node/agentService.ts | 34 +--- .../node/copilot/copilotSessionLauncher.ts | 38 ++-- .../test/common/agentHostSchema.test.ts | 82 ++------- .../remoteAgentHostProtocolClient.test.ts | 94 +++++++--- .../remoteAgentHostService.test.ts | 12 +- .../agentHost/test/node/agentService.test.ts | 17 +- .../agentHost/test/node/copilotAgent.test.ts | 30 --- .../test/node/copilotSessionLauncher.test.ts | 172 +++++------------- .../test/node/protocolServerHandler.test.ts | 35 +--- .../browser/actions/developerActions.ts | 21 +-- 13 files changed, 233 insertions(+), 543 deletions(-) diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 70342399704852..6f3c5751714bad 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -27,7 +27,7 @@ import { AgentHostResourceIdentity, AgentHostResourcePermissionError, IAgentHost import type { ClientNotificationMap, CommandMap, JsonRpcErrorResponse, JsonRpcRequest } from '../common/state/protocol/messages.js'; import { ActionType, type ActionEnvelope, type ChatAction, type ClientAnnotationsAction, type ClientChangesetAction, type INotification, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js'; import { MessageAttachmentKind, SessionSummary, ROOT_STATE_URI, StateComponents, isAhpRootChannel, type ClientPluginCustomization, type Message, type RootState } from '../common/state/sessionState.js'; -import { compareProtocolVersions, SUPPORTED_PROTOCOL_VERSIONS } from '../common/state/protocol/version/registry.js'; +import { SUPPORTED_PROTOCOL_VERSIONS } from '../common/state/protocol/version/registry.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, ProtocolError, ReconnectResultType, type ProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { type IVscodeUpgradeResult } from '../common/state/protocolUpgrade.js'; import { isClientTransport, type IProtocolTransport } from '../common/state/sessionTransport.js'; @@ -50,8 +50,6 @@ import { isFileResourceRead } from '../common/resourceReadLogging.js'; import { ResourceSet } from '../../../base/common/map.js'; const AHP_CLIENT_CONNECTION_CLOSED = -32000; -const MANAGED_PERMISSIONS_MIN_PROTOCOL_VERSION = '0.8.0'; - /** Initial delay before the first transport-level reconnect attempt. */ const RECONNECT_INITIAL_DELAY_MS = 1_000; @@ -369,10 +367,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC if (e.affectsConfiguration(TELEMETRY_SETTING_ID) || e.affectsConfiguration(TELEMETRY_OLD_SETTING_ID) || e.affectsConfiguration(TELEMETRY_CRASH_REPORTER_SETTING_ID)) { this._updateTelemetryLevel(); } - if ( - e.affectsConfiguration(GLOBAL_AUTO_APPROVE_SETTING_ID) || - e.affectsConfiguration(TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID) - ) { + if (e.affectsConfiguration(GLOBAL_AUTO_APPROVE_SETTING_ID) || e.affectsConfiguration(TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID)) { this._updateManagedPermissions(); } if (e.affectsConfiguration(PREFER_LONG_CONTEXT_SETTING_ID)) { @@ -455,7 +450,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC // Advertise every version this client can negotiate, most-preferred first, so an // older host (a cloud sandbox running a 0.5.x `copilotd`) can negotiate down // instead of rejecting the connection. A current host still picks the newest. - protocolVersions: this._supportedProtocolVersions(), + protocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS], clientId: this._clientId, clientInfo: this._clientInfo, ...this._clientConnectionTelemetryMeta(), @@ -483,12 +478,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC ? error : new ProtocolError(AHP_CLIENT_CONNECTION_CLOSED, error instanceof Error ? error.message : String(error)); if (protocolError.code === AhpErrorCodes.UnsupportedProtocolVersion) { - this._cancelLivenessTimers(); - if (this._state.kind === AgentHostClientState.Connecting) { - this._state.outbox.length = 0; - } - this._rejectPendingRequests(protocolError); - this._transitionTo({ kind: AgentHostClientState.Incompatible, error: protocolError }); + this._markIncompatible(protocolError); throw error; } this._handleClose(protocolError); @@ -530,7 +520,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC this._handleClose(connectionClosedError(this._address)); return; case AgentHostClientState.Incompatible: - this._handleClose(connectionClosedError(this._address)); + this._rejectPendingRequests(connectionClosedError(this._address)); return; case AgentHostClientState.Connected: { if (!this._transportFactory) { @@ -669,7 +659,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC this._logService.info(`[RemoteAgentHostProtocol] Server forgot client ${this._clientId}; initializing a fresh connection.`); const initializeResult = await this._dispatchRequest('initialize', { channel: ROOT_STATE_URI, - protocolVersions: this._supportedProtocolVersions(), + protocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS], clientId: this._clientId, clientInfo: this._clientInfo, ...this._clientConnectionTelemetryMeta(), @@ -685,7 +675,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC } private _applyInitializeResult(result: CommandMap['initialize']['result']): void { - this._assertManagedPermissionsSupported(result.protocolVersion); + this._assertManagedPermissionsSupported(result); this._initializeResult.set(result, undefined); this._serverSeq = result.serverSeq; if (result.defaultDirectory) { @@ -1532,12 +1522,12 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC */ private _updateManagedPermissions(): void { const permissions = this._deriveManagedPermissions(); - const protocolVersion = this._initializeResult.get()?.protocolVersion; - if (protocolVersion && compareProtocolVersions(protocolVersion, MANAGED_PERMISSIONS_MIN_PROTOCOL_VERSION) < 0) { + const initializeResult = this._initializeResult.get(); + if (initializeResult && !this._supportsManagedPermissions(initializeResult)) { if (!permissions) { return; } - const error = this._managedPermissionsUnsupportedError(protocolVersion); + const error = this._managedPermissionsUnsupportedError(); const wasConnected = this._state.kind === AgentHostClientState.Connected; this._markIncompatible(error); if (!wasConnected) { @@ -1551,32 +1541,30 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC } private _deriveManagedPermissions() { - return deriveManagedPermissions({ - globalAutoApprove: this._configurationService.inspect(GLOBAL_AUTO_APPROVE_SETTING_ID).policyValue, - terminalAutoApproveEnabled: this._configurationService.inspect(TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID).policyValue, - }); + return deriveManagedPermissions( + this._configurationService.inspect(GLOBAL_AUTO_APPROVE_SETTING_ID).policyValue, + this._configurationService.inspect(TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID).policyValue, + ); } - private _supportedProtocolVersions(): string[] { - if (!this._deriveManagedPermissions()) { - return [...SUPPORTED_PROTOCOL_VERSIONS]; - } - return SUPPORTED_PROTOCOL_VERSIONS.filter(version => - compareProtocolVersions(version, MANAGED_PERMISSIONS_MIN_PROTOCOL_VERSION) >= 0); + private _supportsManagedPermissions(result: CommandMap['initialize']['result']): boolean { + // Host builds expose this schema key only when shipped with a compatible runtime. + return result.snapshots?.some(snapshot => + isAhpRootChannel(snapshot.resource) + && Object.hasOwn((snapshot.state as RootState).config?.schema.properties ?? {}, AgentHostManagedPermissionsConfigKey) + ) === true; } - private _assertManagedPermissionsSupported(protocolVersion: string): void { - if (this._deriveManagedPermissions() - && compareProtocolVersions(protocolVersion, MANAGED_PERMISSIONS_MIN_PROTOCOL_VERSION) < 0) { - throw this._managedPermissionsUnsupportedError(protocolVersion); + private _assertManagedPermissionsSupported(result: CommandMap['initialize']['result']): void { + if (this._deriveManagedPermissions() && !this._supportsManagedPermissions(result)) { + throw this._managedPermissionsUnsupportedError(); } } - private _managedPermissionsUnsupportedError(protocolVersion: string): ProtocolError { + private _managedPermissionsUnsupportedError(): ProtocolError { return new ProtocolError( AhpErrorCodes.UnsupportedProtocolVersion, - `Managed permissions require Agent Host protocol ${MANAGED_PERMISSIONS_MIN_PROTOCOL_VERSION} or newer; negotiated ${protocolVersion}.`, - { supportedVersions: [`>=${MANAGED_PERMISSIONS_MIN_PROTOCOL_VERSION}`] }, + 'The connected Agent Host does not advertise managed-permissions enforcement support.', ); } diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts index 3f8dc43d17f46c..65a2141d92aa00 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts @@ -341,6 +341,16 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo this._onDidChangeConnections.fire(); } })); + store.add(protocolClient.onDidChangeConnectionState(state => { + if (this._entries.get(address) !== connEntry || state !== AgentHostClientState.Incompatible) { + return; + } + connEntry.connected = false; + connEntry.status = protocolClient.connectionError + ? RemoteAgentHostConnectionStatus.fromConnectError(protocolClient.connectionError, [PROTOCOL_VERSION]) ?? RemoteAgentHostConnectionStatus.disconnected + : RemoteAgentHostConnectionStatus.disconnected; + this._onDidChangeConnections.fire(); + })); // Persist entries — await so that the config is written before // onDidChangeConnections fires, ensuring _reconcile creates the provider. diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 2bfb1251e12b1d..195ceb67481f96 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -297,87 +297,25 @@ const permissionsProperty = schemaProperty({ sessionMutable: true, }); -/** - * The client-agnostic managed-permission shape VS Code synthesizes from its - * legacy enterprise policy values and forwards to the runtime as - * `managedSettings.permissions` at SDK session startup. Field names and rule - * grammar match the runtime managed-permission contract, NOT any VS Code - * setting: `disableBypassPermissionsMode` locks out "Allow all", and - * `deny`/`ask`/`allow` are arrays of runtime permission-rule strings. The - * runtime accepts only a fixed set of rule boundaries (`Bash`, `Shell`, - * `PowerShell`, `Read`, `Edit`, `Write`, `Domain`); unknown/malformed rules - * reject session startup, so VS Code must emit only exact, supported tokens. - * Rules are composed restrictively by the runtime, so an `ask` rule can only - * add friction and never grants approval. - */ +/** Managed runtime restrictions synthesized from legacy VS Code enterprise policy. */ export interface IManagedPermissions { readonly disableBypassPermissionsMode?: 'disable'; - readonly deny?: readonly string[]; - readonly ask?: readonly string[]; - readonly allow?: readonly string[]; + /** Canonical all-shell prompt rule; active runtime rule policy defaults other governed kinds to ask. */ + readonly ask?: readonly ['Shell']; } -/** - * The runtime permission-rule string emitted when managed - * `chat.tools.terminal.enableAutoApprove` is `false`. The kind-only `Shell` - * rule matches all shell commands in the runtime managed-permission parser. - * Centralized as a single constant so the grammar lives in one place. Generic - * `Tool(...)` rules are NOT supported by the runtime and must never be emitted. - */ export const MANAGED_PERMISSION_TERMINAL_ASK_RULE = 'Shell'; /** - * The enterprise-policy inputs — read exclusively from - * `IConfigurationService.inspect(...).policyValue`, never ordinary - * user/workspace values — that {@link deriveManagedPermissions} maps into the - * client-agnostic {@link IManagedPermissions} object. - */ -export interface IManagedPermissionPolicyInputs { - /** Managed value of `chat.tools.global.autoApprove`. `false` disables bypass ("Allow all"). */ - readonly globalAutoApprove: boolean | undefined; - /** Managed value of `chat.tools.terminal.enableAutoApprove`. `false` adds the all-shell `ask` rule. */ - readonly terminalAutoApproveEnabled: boolean | undefined; -} - -/** - * Translate VS Code's legacy enterprise policy values into the client-agnostic - * {@link IManagedPermissions} object. Only mappings backed by exact, - * runtime-supported permission rules are emitted: - * - * - managed `chat.tools.global.autoApprove === false` → `disableBypassPermissionsMode: "disable"`; - * - managed `chat.tools.terminal.enableAutoApprove === false` → the all-shell `ask` rule `Shell`. - * - * Per-tool eligibility (`chat.tools.eligibleForAutoApproval`) is intentionally - * NOT mapped: the runtime rejects generic `Tool(...)` rules, so there is no - * supported boundary to express it. Network/sandbox policies are out of scope - * for this first pass. - * - * Returns `undefined` when no restrictive policy applies, so callers can omit - * the field entirely rather than forward an empty object. + * Translate legacy managed auto-approval policy into restrictive runtime settings. */ -export function deriveManagedPermissions(inputs: IManagedPermissionPolicyInputs): IManagedPermissions | undefined { - const ask: string[] = []; - let disableBypassPermissionsMode: 'disable' | undefined; - - if (inputs.globalAutoApprove === false) { - disableBypassPermissionsMode = 'disable'; - } - if (inputs.terminalAutoApproveEnabled === false) { - ask.push(MANAGED_PERMISSION_TERMINAL_ASK_RULE); - } - - const permissions: { - disableBypassPermissionsMode?: 'disable'; - ask?: string[]; - } = {}; - if (disableBypassPermissionsMode) { - permissions.disableBypassPermissionsMode = disableBypassPermissionsMode; - } - if (ask.length) { - permissions.ask = ask; - } - - return Object.keys(permissions).length ? permissions : undefined; +export function deriveManagedPermissions(globalAutoApprovePolicyValue: boolean | undefined, terminalAutoApprovePolicyValue: boolean | undefined): IManagedPermissions | undefined { + const disableBypassPermissionsMode = globalAutoApprovePolicyValue === false; + const askForShell = terminalAutoApprovePolicyValue === false; + return disableBypassPermissionsMode || askForShell ? { + ...(disableBypassPermissionsMode ? { disableBypassPermissionsMode: 'disable' as const } : {}), + ...(askForShell ? { ask: [MANAGED_PERMISSION_TERMINAL_ASK_RULE] as const } : {}), + } : undefined; } /** @@ -385,67 +323,12 @@ export function deriveManagedPermissions(inputs: IManagedPermissionPolicyInputs) * no managed policy. */ export function normalizeManagedPermissions(permissions: IManagedPermissions | undefined): IManagedPermissions | undefined { - return permissions && Object.keys(permissions).length > 0 ? permissions : undefined; -} - -const managedPermissionRuleFamilies = new Set(['bash', 'shell', 'powershell', 'read', 'edit', 'write', 'domain']); -const managedPermissionShellRuleFamilies = new Set(['bash', 'shell', 'powershell']); - -/** - * Return parser-compatible validation issues for managed permission rules. - * Provider runtimes remain authoritative for family-specific path and domain patterns. - */ -export function validateManagedPermissionRules(permissions: IManagedPermissions | undefined): readonly string[] { - if (!permissions) { - return []; - } - - const issues: string[] = []; - for (const list of ['deny', 'ask', 'allow'] as const) { - for (const [index, rule] of (permissions[list] ?? []).entries()) { - const issue = validateManagedPermissionRule(rule); - if (issue) { - issues.push(`${list}.${index}: ${issue}`); - } - } - } - return issues; -} - -function validateManagedPermissionRule(rule: string): string | undefined { - const openParenthesis = rule.indexOf('('); - let family = rule; - let argument: string | undefined; - if (openParenthesis !== -1) { - if (!rule.endsWith(')')) { - return `Invalid rule format: ${rule}`; - } - family = rule.slice(0, openParenthesis); - argument = rule.slice(openParenthesis + 1, -1); - if (!argument || argument.includes(')')) { - return `Invalid rule format: ${rule}`; - } - } - if (!family || ![...family].every(character => /[a-zA-Z0-9_./@-]/.test(character))) { - return `Invalid rule format: ${rule}`; - } - - const normalizedFamily = family.toLowerCase(); - if (!managedPermissionRuleFamilies.has(normalizedFamily)) { - return `Unsupported managed permission rule family '${family}'; expected Bash, Shell, PowerShell, Read, Edit, Write, or Domain`; - } - if (!argument || !managedPermissionShellRuleFamilies.has(normalizedFamily)) { - return undefined; - } - if (argument.endsWith(' *')) { - return argument.slice(0, -2).trimEnd() - ? undefined - : 'Invalid managed shell permission rule: wildcard requires a command prefix'; - } - if (argument.includes('*') && !argument.endsWith(':*')) { - return `Unsupported managed shell wildcard pattern '${argument}'; use ' *' or the canonical ':*' suffix`; - } - return undefined; + const disableBypassPermissionsMode = permissions?.disableBypassPermissionsMode === 'disable'; + const askForShell = permissions?.ask?.includes(MANAGED_PERMISSION_TERMINAL_ASK_RULE) === true; + return disableBypassPermissionsMode || askForShell ? { + ...(disableBypassPermissionsMode ? { disableBypassPermissionsMode: 'disable' as const } : {}), + ...(askForShell ? { ask: [MANAGED_PERMISSION_TERMINAL_ASK_RULE] as const } : {}), + } : undefined; } const managedPermissionsProperty = schemaProperty({ @@ -458,20 +341,14 @@ const managedPermissionsProperty = schemaProperty({ title: localize('agentHost.config.managedPermissions.disableBypass', "Disable bypass permissions mode"), enum: ['disable'], }, - deny: { - type: 'array', - title: localize('agentHost.config.managedPermissions.deny', "Denied permission rules"), - items: { type: 'string', title: localize('agentHost.config.managedPermissions.rule', "Permission rule") }, - }, ask: { type: 'array', - title: localize('agentHost.config.managedPermissions.ask', "Ask permission rules"), - items: { type: 'string', title: localize('agentHost.config.managedPermissions.rule', "Permission rule") }, - }, - allow: { - type: 'array', - title: localize('agentHost.config.managedPermissions.allow', "Allowed permission rules"), - items: { type: 'string', title: localize('agentHost.config.managedPermissions.rule', "Permission rule") }, + title: localize('agentHost.config.managedPermissions.ask', "Required permission prompts"), + items: { + type: 'string', + title: localize('agentHost.config.managedPermissions.rule', "Permission rule"), + enum: [MANAGED_PERMISSION_TERMINAL_ASK_RULE], + }, }, }, // No default: `{}` is the wire-level clear sentinel and is normalized to diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 67290ab03e4465..dfd3925b9d5553 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -77,7 +77,7 @@ import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js'; import { AgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; -import { AgentHostEditTelemetryEnabledConfigKey, AgentHostManagedPermissionsConfigKey, AgentHostManagedPermissionsLogRedaction, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, normalizeManagedPermissions, platformRootSchema, type IManagedPermissions } from '../common/agentHostSchema.js'; +import { AgentHostEditTelemetryEnabledConfigKey, AgentHostManagedPermissionsConfigKey, AgentHostManagedPermissionsLogRedaction, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, MANAGED_PERMISSION_TERMINAL_ASK_RULE, normalizeManagedPermissions, platformRootSchema, type IManagedPermissions } from '../common/agentHostSchema.js'; import { AgentHostOctoKitService, IAgentHostOctoKitService } from './shared/agentHostOctoKitService.js'; import { IAgentHostChangesetService, CHANGESET_DB_METADATA_KEYS, META_CHANGES_SUMMARY } from '../common/agentHostChangesetService.js'; import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChangesetSubscriptionService.js'; @@ -2768,34 +2768,12 @@ export class AgentService extends Disposable implements IAgentService { private _getEffectiveManagedPermissions(): IManagedPermissions | undefined { const permissions = [...this._managedPermissionsByClient.values()]; - if (permissions.length === 0) { - return undefined; - } - - const deny = new Set(); - const ask = new Set(); - let allow = new Set(permissions[0].allow ?? []); - let disableBypassPermissionsMode = false; - for (const clientPermissions of permissions) { - disableBypassPermissionsMode ||= clientPermissions.disableBypassPermissionsMode === 'disable'; - for (const rule of clientPermissions.deny ?? []) { - deny.add(rule); - } - for (const rule of clientPermissions.ask ?? []) { - ask.add(rule); - } - // Managed allow rules grant automatic approval, so retain only rules - // explicitly allowed by every managed client. - const clientAllow = new Set(clientPermissions.allow ?? []); - allow = new Set([...allow].filter(rule => clientAllow.has(rule))); - } - - return { + const disableBypassPermissionsMode = permissions.some(value => value.disableBypassPermissionsMode === 'disable'); + const askForShell = permissions.some(value => value.ask !== undefined); + return disableBypassPermissionsMode || askForShell ? { ...(disableBypassPermissionsMode ? { disableBypassPermissionsMode: 'disable' as const } : {}), - ...(deny.size ? { deny: [...deny] } : {}), - ...(ask.size ? { ask: [...ask] } : {}), - ...(allow.size ? { allow: [...allow] } : {}), - }; + ...(askForShell ? { ask: [MANAGED_PERMISSION_TERMINAL_ASK_RULE] as const } : {}), + } : undefined; } private _needsAsyncRewrite(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction): action is ChatTurnStartedAction | ChatPendingMessageSetAction { diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 75ef48dbfd2f54..1c6b76b70023e1 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { ContextTier, CopilotClient, ElicitationContext, ElicitationResult, ExitPlanModeRequest, ExitPlanModeResult, ManagedSettings, ManagedSettingsPermissions, NamedProviderConfig, PermissionRequest, PermissionRequestResult, ProviderModelConfig, ResumeSessionConfig, SessionConfig, SessionHooks, Tool, Verbosity } from '@github/copilot-sdk'; +import type { ContextTier, CopilotClient, ElicitationContext, ElicitationResult, ExitPlanModeRequest, ExitPlanModeResult, NamedProviderConfig, PermissionRequest, PermissionRequestResult, ProviderModelConfig, ResumeSessionConfig, SessionConfig, SessionHooks, Tool, Verbosity } from '@github/copilot-sdk'; import { coalesce } from '../../../../base/common/arrays.js'; import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; @@ -83,19 +83,6 @@ type McpAuthResponse = Awaited>; type PreToolUseHookInput = Parameters>[0]; type PostToolUseHookInput = Parameters>[0]; -function toSdkManagedSettings(permissions: IManagedPermissions | undefined): ManagedSettings | undefined { - if (!permissions) { - return undefined; - } - const sdkPermissions: ManagedSettingsPermissions = { - ...(permissions.disableBypassPermissionsMode ? { disableBypassPermissionsMode: permissions.disableBypassPermissionsMode } : {}), - ...(permissions.deny ? { deny: [...permissions.deny] } : {}), - ...(permissions.ask ? { ask: [...permissions.ask] } : {}), - ...(permissions.allow ? { allow: [...permissions.allow] } : {}), - }; - return { permissions: sdkPermissions }; -} - /** * Immutable snapshot of the active client's structural contributions at * session creation time. Used to detect when the session needs to be @@ -109,14 +96,8 @@ export interface IActiveClientSnapshot { readonly tools: readonly ToolDefinition[]; readonly plugins: readonly ICopilotPluginInfo[]; readonly mcpServers: AgentHostMcpServers; - /** - * Enterprise-policy-derived managed permissions in effect at snapshot time. - * Participates in restart detection because it is forwarded into the SDK - * session config as `managedSettings.permissions`; a policy change must - * refresh the session so the new permissions apply before the next turn. - * Optional: `undefined` (or absent) means no managed policy applied. - */ - readonly managedPermissions?: IManagedPermissions | undefined; + /** Startup-only managed permissions included in structural restart detection. */ + readonly managedPermissions?: IManagedPermissions; } /** @@ -601,9 +582,9 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { // renderer reports no BYOK models), merged into the returned config so both // createSession and resumeSession advertise the models to the runtime. const byok = await this._resolveByokSessionConfig(plan.sessionId); - const managedSettings = toSdkManagedSettings(normalizeManagedPermissions( + const managedPermissions = normalizeManagedPermissions( this._configurationService.getRootValue(platformRootSchema, AgentHostManagedPermissionsConfigKey), - )); + ); const enableCustomTerminalTool = this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.EnableCustomTerminalTool) === true; let shellTools: Awaited> = []; if (enableCustomTerminalTool) { @@ -705,7 +686,14 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { // Forward enterprise-policy-derived managed permissions (synthesized // by VS Code from managed policy values) as the runtime's // `managedSettings.permissions`. Omitted when no policy applies. - ...(managedSettings ? { managedSettings } : {}), + ...(managedPermissions ? { + managedSettings: { + permissions: { + ...(managedPermissions.disableBypassPermissionsMode ? { disableBypassPermissionsMode: managedPermissions.disableBypassPermissionsMode } : {}), + ...(managedPermissions.ask ? { ask: [...managedPermissions.ask] } : {}), + }, + }, + } : {}), }; } } diff --git a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts index a9224d54e68906..ebced469505ab5 100644 --- a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import type { IConfigurationValue } from '../../../configuration/common/configuration.js'; -import { createSchema, deriveManagedPermissions, migrateLegacyAutopilotConfig, normalizeAgentHostTerminalAutoApproveRulesConfig, normalizeManagedPermissions, platformRootSchema, platformSessionSchema, schemaProperty, AgentHostManagedPermissionsConfigKey, MANAGED_PERMISSION_TERMINAL_ASK_RULE, validateManagedPermissionRules, type AgentHostTerminalAutoApproveRules, type AutoApproveLevel, type IManagedPermissions, type IPermissionsValue, type SessionMode } from '../../common/agentHostSchema.js'; +import { createSchema, deriveManagedPermissions, migrateLegacyAutopilotConfig, normalizeAgentHostTerminalAutoApproveRulesConfig, normalizeManagedPermissions, platformRootSchema, platformSessionSchema, schemaProperty, AgentHostManagedPermissionsConfigKey, type AgentHostTerminalAutoApproveRules, type AutoApproveLevel, type IPermissionsValue, type SessionMode } from '../../common/agentHostSchema.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js'; @@ -417,73 +417,29 @@ suite('agentHostSchema', () => { suite('deriveManagedPermissions', () => { - test('returns undefined when no policy applies', () => { - assert.strictEqual(deriveManagedPermissions({ - globalAutoApprove: undefined, - terminalAutoApproveEnabled: undefined, - }), undefined); - }); - - test('permissive policy values map to nothing', () => { - assert.strictEqual(deriveManagedPermissions({ - globalAutoApprove: true, - terminalAutoApproveEnabled: true, - }), undefined); - }); - - test('maps restrictive policies into supported rules', () => { - assert.deepStrictEqual(deriveManagedPermissions({ - globalAutoApprove: false, - terminalAutoApproveEnabled: false, - }), { - disableBypassPermissionsMode: 'disable', - ask: [MANAGED_PERMISSION_TERMINAL_ASK_RULE], - } satisfies IManagedPermissions); - }); - - test('emits only disableBypassPermissionsMode when only global auto-approve is denied', () => { - assert.deepStrictEqual(deriveManagedPermissions({ - globalAutoApprove: false, - terminalAutoApproveEnabled: undefined, - }), { disableBypassPermissionsMode: 'disable' } satisfies IManagedPermissions); - }); - - test('emits only the all-shell ask rule when only terminal auto-approve is denied', () => { - assert.deepStrictEqual(deriveManagedPermissions({ - globalAutoApprove: undefined, - terminalAutoApproveEnabled: false, - }), { ask: ['Shell'] } satisfies IManagedPermissions); - }); - - test('derived value validates against the managed-permissions root schema', () => { - const permissions = deriveManagedPermissions({ - globalAutoApprove: false, - terminalAutoApproveEnabled: false, - }); - assert.ok(permissions); + test('maps only restrictive auto-approve policy', () => { + const permissions = deriveManagedPermissions(false, false); assert.deepStrictEqual({ + unset: deriveManagedPermissions(undefined, undefined), + permissive: deriveManagedPermissions(true, true), + global: deriveManagedPermissions(false, true), + terminal: deriveManagedPermissions(true, false), + restrictive: permissions, schema: platformRootSchema.validate(AgentHostManagedPermissionsConfigKey, permissions), - rules: validateManagedPermissionRules(permissions), + unsupportedRule: platformRootSchema.validate(AgentHostManagedPermissionsConfigKey, { ask: ['Shell(*)'] }), + clearSentinel: normalizeManagedPermissions({}), + normalized: normalizeManagedPermissions(permissions), }, { + unset: undefined, + permissive: undefined, + global: { disableBypassPermissionsMode: 'disable' }, + terminal: { ask: ['Shell'] }, + restrictive: { disableBypassPermissionsMode: 'disable', ask: ['Shell'] }, schema: true, - rules: [], + unsupportedRule: false, + clearSentinel: undefined, + normalized: { disableBypassPermissionsMode: 'disable', ask: ['Shell'] }, }); }); - - test('reports runtime-incompatible managed shell wildcards', () => { - assert.deepStrictEqual(validateManagedPermissionRules({ - ask: ['Shell(*)', 'Shell(git *)', 'PowerShell(Get-Item:*)'], - }), [ - `ask.0: Unsupported managed shell wildcard pattern '*'; use ' *' or the canonical ':*' suffix`, - ]); - }); - - test('normalizes the root-config clear sentinel to no policy', () => { - assert.strictEqual(normalizeManagedPermissions({}), undefined); - assert.deepStrictEqual( - normalizeManagedPermissions({ disableBypassPermissionsMode: 'disable' }), - { disableBypassPermissionsMode: 'disable' }, - ); - }); }); }); diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts index ea890a60eecad7..5714e5e16e7012 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts @@ -222,6 +222,7 @@ suite('RemoteAgentHostProtocolClient', () => { onGrantImplicitRead?: (identity: AgentHostResourceIdentity, uri: URI) => void; /** Test hook that observes disposal of the implicit-read grant. */ onRevokeImplicitRead?: (identity: AgentHostResourceIdentity, uri: URI) => void; + onConnectionClosed?: (identity: AgentHostResourceIdentity) => void; readBytes?: VSBuffer; } @@ -268,7 +269,7 @@ suite('RemoteAgentHostProtocolClient', () => { opts.onGrantImplicitRead?.(address, uri); return opts.onRevokeImplicitRead ? toDisposable(() => opts.onRevokeImplicitRead?.(address, uri)) : Disposable.None; }, - connectionClosed: () => { }, + connectionClosed: identity => opts.onConnectionClosed?.(identity), }; } @@ -290,7 +291,24 @@ suite('RemoteAgentHostProtocolClient', () => { transport.fireMessage({ jsonrpc: '2.0', id: sent.id, - result: { protocolVersion: PROTOCOL_VERSION, serverSeq: 0, snapshots: [] }, + result: { + protocolVersion: PROTOCOL_VERSION, + serverSeq: 0, + snapshots: [{ + resource: ROOT_STATE_URI, + fromSeq: 0, + state: { + agents: [], + config: { + schema: { + type: 'object', + properties: { [AgentHostManagedPermissionsConfigKey]: { type: 'object' } }, + }, + values: {}, + }, + }, + }], + }, }); await connectPromise; } @@ -955,33 +973,59 @@ suite('RemoteAgentHostProtocolClient', () => { disableBypassPermissionsMode: 'disable', ask: [MANAGED_PERMISSION_TERMINAL_ASK_RULE], }); + + transport.sentMessages.length = 0; + configurationService.setPolicyValue(GLOBAL_AUTO_APPROVE_SETTING_ID, undefined); + fireConfigurationChange(configurationService, GLOBAL_AUTO_APPROVE_SETTING_ID); + assert.deepStrictEqual( + findRootConfigValue(transport.sentMessages, AgentHostManagedPermissionsConfigKey), + { ask: [MANAGED_PERMISSION_TERMINAL_ASK_RULE] }, + ); + + transport.sentMessages.length = 0; + configurationService.setPolicyValue(TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, undefined); + fireConfigurationChange(configurationService, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID); + assert.deepStrictEqual( + findRootConfigValue(transport.sentMessages, AgentHostManagedPermissionsConfigKey), + {}, + ); }); - test('requires a managed-permissions-capable protocol when restrictive policy is present', async () => { + test('requires explicit host support when restrictive policy is present', async () => { const configurationService = new ManagedPermissionPolicyConfigurationService({ [GLOBAL_AUTO_APPROVE_SETTING_ID]: false, }); - const { client, transport } = createClient(disposables.add(new TestProtocolTransport()), createPermissionService(), undefined, new NullLogService(), configurationService); + const closedIdentities: AgentHostResourceIdentity[] = []; + const permissionService = createResourceServiceStub({ + onConnectionClosed: identity => closedIdentities.push(identity), + }); + const { client, transport } = createClient(disposables.add(new TestProtocolTransport()), permissionService, undefined, new NullLogService(), configurationService); + let closeCount = 0; + disposables.add(client.onDidClose(() => closeCount++)); const connect = client.connect(); const initialize = transport.sentMessages[0] as JsonRpcRequest; - assert.deepStrictEqual((initialize.params as { protocolVersions: string[] }).protocolVersions, [PROTOCOL_VERSION]); + assert.deepStrictEqual((initialize.params as { protocolVersions: string[] }).protocolVersions, SUPPORTED_PROTOCOL_VERSIONS); transport.fireMessage({ jsonrpc: '2.0', id: initialize.id, - result: { protocolVersion: '0.7.0', serverSeq: 0, snapshots: [] }, + result: { protocolVersion: PROTOCOL_VERSION, serverSeq: 0, snapshots: [] }, }); await assertRemoteProtocolError(connect, { code: AhpErrorCodes.UnsupportedProtocolVersion, - message: 'Managed permissions require Agent Host protocol 0.8.0 or newer; negotiated 0.7.0.', - data: { supportedVersions: ['>=0.8.0'] }, + message: 'The connected Agent Host does not advertise managed-permissions enforcement support.', }); assert.strictEqual(client.connectionState, AgentHostClientState.Incompatible); assert.strictEqual(transport.sentMessages.length, 1); + assert.deepStrictEqual(closedIdentities, ['test.example:1234']); + + transport.fireClose(); + assert.strictEqual(client.connectionState, AgentHostClientState.Incompatible); + assert.strictEqual(closeCount, 0); }); - test('fails closed when restrictive policy appears on an older connected host', async () => { + test('fails closed when restrictive policy appears on an unsupported connected host', async () => { const configurationService = new ManagedPermissionPolicyConfigurationService({}); const { client, transport } = createClient(disposables.add(new TestProtocolTransport()), createPermissionService(), undefined, new NullLogService(), configurationService); const connect = client.connect(); @@ -989,7 +1033,7 @@ suite('RemoteAgentHostProtocolClient', () => { transport.fireMessage({ jsonrpc: '2.0', id: initialize.id, - result: { protocolVersion: '0.7.0', serverSeq: 0, snapshots: [] }, + result: { protocolVersion: PROTOCOL_VERSION, serverSeq: 0, snapshots: [] }, }); await connect; assert.strictEqual( @@ -1036,21 +1080,6 @@ suite('RemoteAgentHostProtocolClient', () => { assert.deepStrictEqual(getRootConfig(managed)[AgentHostManagedPermissionsConfigKey], {}); }); - test('forwards the empty clear sentinel when restrictive policy is removed', async () => { - const configurationService = new ManagedPermissionPolicyConfigurationService({ - [GLOBAL_AUTO_APPROVE_SETTING_ID]: false, - }); - const { client, transport } = createClient(disposables.add(new TestProtocolTransport()), createPermissionService(), undefined, new NullLogService(), configurationService); - await connectClient(client, transport); - transport.sentMessages.length = 0; - - configurationService.setPolicyValue(GLOBAL_AUTO_APPROVE_SETTING_ID, undefined); - fireConfigurationChange(configurationService, GLOBAL_AUTO_APPROVE_SETTING_ID); - - const managed = findRootConfigNotification(transport.sentMessages, AgentHostManagedPermissionsConfigKey); - assert.deepStrictEqual(getRootConfig(managed)[AgentHostManagedPermissionsConfigKey], {}); - }); - test('forwards the repo-info telemetry debug switch on connect and change', async () => { const configurationService = new TestConfigurationService({ [DISABLE_REPO_INFO_TELEMETRY_SETTING_ID]: true }); const { client, transport } = createClient(disposables.add(new TestProtocolTransport()), createPermissionService(), undefined, new NullLogService(), configurationService); @@ -1170,8 +1199,15 @@ suite('RemoteAgentHostProtocolClient', () => { }); transport.fireMessage({ jsonrpc: '2.0', id: request.id, result: { ok: true, upgradeStarted: true } }); assert.deepStrictEqual(await upgrade, { ok: true, upgradeStarted: true }); + + const interruptedUpgrade = client.triggerVscodeUpgrade('_vscodeUpgrade'); + const interruptedError = assertRemoteProtocolError(interruptedUpgrade, { + code: -32000, + message: 'Connection closed: test.example:1234', + }); transport.fireClose(); - assert.strictEqual(client.connectionState, AgentHostClientState.Closed); + await interruptedError; + assert.strictEqual(client.connectionState, AgentHostClientState.Incompatible); }); test('sends shutdown as a JSON-RPC request shape', async () => { @@ -1844,7 +1880,7 @@ suite('RemoteAgentHostProtocolClient', () => { } }); - test('treats managed-permission protocol incompatibility during reconnect fallback as terminal', async function () { + test('treats missing managed-permission support during reconnect fallback as terminal', async function () { this.timeout(10_000); const configurationService = new ManagedPermissionPolicyConfigurationService({}); const { client, transports } = createFactoryClient(createPermissionService(), undefined, configurationService); @@ -1864,11 +1900,11 @@ suite('RemoteAgentHostProtocolClient', () => { }); const initialize = await waitForRequest(reconnectTransport, 'initialize'); - assert.deepStrictEqual((initialize.params as { protocolVersions: string[] }).protocolVersions, [PROTOCOL_VERSION]); + assert.deepStrictEqual((initialize.params as { protocolVersions: string[] }).protocolVersions, SUPPORTED_PROTOCOL_VERSIONS); reconnectTransport.fireMessage({ jsonrpc: '2.0', id: initialize.id, - error: { code: AhpErrorCodes.UnsupportedProtocolVersion, message: 'Protocol versions do not match' }, + result: { protocolVersion: PROTOCOL_VERSION, serverSeq: 0, snapshots: [] }, }); await flushMicrotasks(); diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts index bfd77011ee57bd..d2bf2b1f618acb 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts @@ -590,19 +590,23 @@ suite('RemoteAgentHostService', () => { }, }, mockClient as unknown as Parameters[1], - undefined, - RemoteAgentHostConnectionStatus.incompatible('Unsupported protocol version', ['0.3.0'], ['^0.2.0'], '_vscodeUpgrade'), ); + const changed = Event.toPromise(service.onDidChangeConnections); + mockClient.fireConnectionState( + AgentHostClientState.Incompatible, + new ProtocolError(AhpErrorCodes.UnsupportedProtocolVersion, 'Managed permissions are unsupported.'), + ); + await changed; const upgradeResult = await service.triggerServerUpgrade('ssh:remote.example', '_vscodeUpgrade'); assert.deepStrictEqual({ - status: service.connections[0].status, + status: service.connections[0].status.kind, connectedConnection: service.getConnection('ssh:remote.example'), upgradeCalls: mockClient.triggerVscodeUpgradeCalls, upgradeResult, }, { - status: RemoteAgentHostConnectionStatus.incompatible('Unsupported protocol version', ['0.3.0'], ['^0.2.0'], '_vscodeUpgrade'), + status: 'incompatible', connectedConnection: undefined, upgradeCalls: ['_vscodeUpgrade'], upgradeResult: { ok: true, upgradeStarted: true }, diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index ab7ecc24ef45d6..bb07d8386e74b3 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -932,7 +932,7 @@ suite('AgentService (node dispatcher)', () => { await readStarted.p; svc.dispatchAction(ROOT_STATE_URI, { type: ActionType.RootConfigChanged, - config: { [AgentHostManagedPermissionsConfigKey]: { ask: ['Shell'] } }, + config: { [AgentHostManagedPermissionsConfigKey]: { disableBypassPermissionsMode: 'disable' } }, }, clientId, 2); svc.removeClientManagedPermissions(clientId); const queueDrained = Event.toPromise(Event.filter(svc.onDidAction, envelope => envelope.origin?.clientId === clientId && envelope.origin.clientSeq === 3)); @@ -1052,7 +1052,7 @@ suite('AgentService (node dispatcher)', () => { type: ActionType.RootConfigChanged, config: { customizations: [customization], - [AgentHostManagedPermissionsConfigKey]: { ask: ['Shell'] }, + [AgentHostManagedPermissionsConfigKey]: { disableBypassPermissionsMode: 'disable' }, }, }, 'test-client', 1); @@ -1089,7 +1089,7 @@ suite('AgentService (node dispatcher)', () => { } }); - test('combines managed permissions restrictively per client and redacts trace logs', () => { + test('isolates managed permissions per client and redacts trace logs', () => { const traces: { readonly message: string; readonly args: readonly unknown[] }[] = []; const logService = new class extends NullLogService { override trace(message: string, ...args: unknown[]): void { @@ -1098,7 +1098,7 @@ suite('AgentService (node dispatcher)', () => { }; const svc = disposables.add(new AgentService(logService, fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); const managedPermissions = { - ask: ['Domain(private.example)'], + ask: ['Shell'] as const, }; const managedAction = { type: ActionType.RootConfigChanged, @@ -1127,17 +1127,14 @@ suite('AgentService (node dispatcher)', () => { afterAllManagedDisconnect, originalPermissions: managedAction.config[AgentHostManagedPermissionsConfigKey], traceHasRedaction: serializedTraces.includes(AgentHostManagedPermissionsLogRedaction), - traceHasRule: serializedTraces.includes('private.example'), + traceHasManagedValue: serializedTraces.includes('disableBypassPermissionsMode'), }, { - beforeDisconnect: { - disableBypassPermissionsMode: 'disable', - ask: ['Domain(private.example)'], - }, + beforeDisconnect: { disableBypassPermissionsMode: 'disable', ask: ['Shell'] }, afterManagedDisconnect: { disableBypassPermissionsMode: 'disable' }, afterAllManagedDisconnect: {}, originalPermissions: managedPermissions, traceHasRedaction: true, - traceHasRule: false, + traceHasManagedValue: false, }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index c665a658f8c232..0200db12019466 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -5288,12 +5288,9 @@ suite('CopilotAgent', () => { Object.assign(old.fake, { async destroySession(): Promise { oldDestroyCalls++; - old.rec.disposed = true; } }); - let ensureCalls = 0; (agent as unknown as ChatInternals)._ensureChatSession = async () => { - ensureCalls++; const existing = getPeerChatStub(agent, chat); if (existing) { return existing; @@ -5312,18 +5309,12 @@ suite('CopilotAgent', () => { assert.deepStrictEqual({ oldDestroyCalls, - oldDisposed: old.rec.disposed, freshDisposed: fresh.rec.disposed, freshPrompts: fresh.rec.sends.map(send => send.prompt), - ensureCalls, - livePeerIsFresh: getPeerChatStub(agent, chat) === fresh.fake, }, { oldDestroyCalls: 1, - oldDisposed: true, freshDisposed: false, freshPrompts: ['first-after-policy-change', 'second-after-policy-change'], - ensureCalls: 3, - livePeerIsFresh: true, }); } finally { await disposeAgent(agent); @@ -5865,27 +5856,6 @@ suite('CopilotAgent', () => { } }); - test('a managed-permissions policy change requires a restart', async () => { - const { agent, configurationService } = createTestAgentContext(disposables); - try { - const session = AgentSession.uri('copilotcli', 'managed-perms-change-session'); - - agent.getOrCreateActiveClient(session, { clientId: 'client-A' }).tools = tools; - const activeClient = getActiveClient(agent, session); - const appliedSnapshot = await activeClient.snapshot(); - assert.strictEqual(await activeClient.requiresRestart(appliedSnapshot), false); - - // An enterprise policy change updates the forwarded managed - // permissions; the SDK session must restart so the new - // `managedSettings.permissions` apply before the next turn. - configurationService.updateRootConfig({ [AgentHostManagedPermissionsConfigKey]: { disableBypassPermissionsMode: 'disable' } }); - - assert.strictEqual(await activeClient.requiresRestart(appliedSnapshot), true); - } finally { - await disposeAgent(agent); - } - }); - test('multiple active clients merge their tools and removal isolates per client', async () => { const agent = createTestAgent(disposables); try { diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts index e3a0225f386f93..5aca0371377eb1 100644 --- a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -42,27 +42,7 @@ const testRuntime: ICopilotSessionRuntime = { const testWorkingDirectory = URI.file(process.cwd()); -function createTestLauncher(): CopilotSessionLauncher { - const configurationService = { - getRootValue: () => undefined, - } as Partial as IAgentConfigurationService; - return new CopilotSessionLauncher( - configurationService, - {} as IAgentHostTerminalManager, - new NullLogService(), - {} as IFileService, - { _serviceBrand: undefined, start: async () => { throw new Error('Unexpected proxy start'); }, dispose: () => { } }, - new ByokLmBridgeRegistry(), - { - _serviceBrand: undefined, - getSessionTraceContext: () => undefined, - releaseSessionTraceContext: () => { }, - withTraceContext: (_context: undefined, fn: () => T): T => fn(), - } as unknown as IAgentHostOTelService, - ); -} - -function createTestLauncherWithRootValues(values: Record): CopilotSessionLauncher { +function createTestLauncher(values: Record = {}): CopilotSessionLauncher { const configurationService = { getRootValue: (_schema: unknown, key: string) => values[key], } as Partial as IAgentConfigurationService; @@ -445,113 +425,51 @@ suite('CopilotSessionLauncher shared session config', () => { } }); - test('forwards managed permissions from root config into create and resume configs', async () => { - const createConfigs: Parameters[0][] = []; - const resumeConfigs: Parameters[1][] = []; - const session = { - sessionId: 'session-1', - on: () => () => { }, - disconnect: async () => { }, - } as unknown as CopilotSession; - const client = { - createSession: async (config: Parameters[0]) => { - createConfigs.push(config); - return session; - }, - resumeSession: async (_sessionId: string, config: Parameters[1]) => { - resumeConfigs.push(config); - return session; - }, - }; - const permissions = { disableBypassPermissionsMode: 'disable', ask: ['Shell'] }; - const launcher = createTestLauncherWithRootValues({ [AgentHostManagedPermissionsConfigKey]: permissions }); - const basePlan = { - client, - sessionId: 'session-1', - workingDirectory: testWorkingDirectory, - resolvedAgentName: undefined, - snapshot: { tools: [], plugins: [], mcpServers: {} }, - activeClientToolSet: new ActiveClientToolSet(), - shellManager: undefined, - githubToken: undefined, - }; - const createPlan: CopilotSessionLaunchPlan = { ...basePlan, kind: 'create', model: undefined }; - const resumePlan: CopilotSessionLaunchPlan = { ...basePlan, kind: 'resume', fallback: { model: undefined } }; - - const sessions = new DisposableStore(); - try { - sessions.add(await launcher.launch(createPlan, testRuntime)); - sessions.add(await launcher.launch(resumePlan, testRuntime)); - - assert.deepStrictEqual({ - create: (createConfigs[0] as { managedSettings?: unknown }).managedSettings, - createEnable: (createConfigs[0] as { enableManagedSettings?: boolean }).enableManagedSettings, - resume: (resumeConfigs[0] as { managedSettings?: unknown }).managedSettings, - }, { - create: { permissions }, - createEnable: true, - resume: { permissions }, - }); - } finally { - sessions.dispose(); - await launcher.disposeByokProxyHandle(); - } - }); - - test('omits managedSettings from create and resume configs when no policy is set', async () => { - const createConfigs: Parameters[0][] = []; - const resumeConfigs: Parameters[1][] = []; - const session = { - sessionId: 'session-1', - on: () => () => { }, - disconnect: async () => { }, - } as unknown as CopilotSession; - const client = { - createSession: async (config: Parameters[0]) => { - createConfigs.push(config); - return session; - }, - resumeSession: async (_sessionId: string, config: Parameters[1]) => { - resumeConfigs.push(config); - return session; - }, - }; - // The renderer uses an empty object as the merge-safe wire sentinel when - // policy is cleared; the launcher must still omit managedSettings. - const launcher = createTestLauncherWithRootValues({ - [AgentHostManagedPermissionsConfigKey]: {}, - }); - const basePlan = { - client, - sessionId: 'session-1', - workingDirectory: testWorkingDirectory, - resolvedAgentName: undefined, - snapshot: { tools: [], plugins: [], mcpServers: {} }, - activeClientToolSet: new ActiveClientToolSet(), - shellManager: undefined, - githubToken: undefined, - }; - const createPlan: CopilotSessionLaunchPlan = { ...basePlan, kind: 'create', model: undefined }; - const resumePlan: CopilotSessionLaunchPlan = { ...basePlan, kind: 'resume', fallback: { model: undefined } }; - - const sessions = new DisposableStore(); - try { - sessions.add(await launcher.launch(createPlan, testRuntime)); - sessions.add(await launcher.launch(resumePlan, testRuntime)); - - assert.deepStrictEqual({ - create: (createConfigs[0] as { managedSettings?: unknown }).managedSettings, - createEnable: (createConfigs[0] as { enableManagedSettings?: boolean }).enableManagedSettings, - resume: (resumeConfigs[0] as { managedSettings?: unknown }).managedSettings, - }, { - create: undefined, - createEnable: true, - resume: undefined, - }); - } finally { - sessions.dispose(); - await launcher.disposeByokProxyHandle(); + test('forwards managed permissions on create and resume and omits the clear sentinel', async () => { + const observed: unknown[] = []; + for (const rootValue of [{ disableBypassPermissionsMode: 'disable', ask: ['Shell'] }, {}] as const) { + const session = { + sessionId: 'session-1', + on: () => () => { }, + disconnect: async () => { }, + } as unknown as CopilotSession; + const client = { + createSession: async (config: Parameters[0]) => { + observed.push({ kind: 'create', managedSettings: config.managedSettings, enabled: config.enableManagedSettings }); + return session; + }, + resumeSession: async (_sessionId: string, config: Parameters[1]) => { + observed.push({ kind: 'resume', managedSettings: config.managedSettings, enabled: config.enableManagedSettings }); + return session; + }, + }; + const launcher = createTestLauncher({ [AgentHostManagedPermissionsConfigKey]: rootValue }); + const basePlan = { + client, + sessionId: 'session-1', + workingDirectory: testWorkingDirectory, + resolvedAgentName: undefined, + snapshot: { tools: [], plugins: [], mcpServers: {} }, + activeClientToolSet: new ActiveClientToolSet(), + shellManager: undefined, + githubToken: undefined, + }; + const sessions = new DisposableStore(); + try { + sessions.add(await launcher.launch({ ...basePlan, kind: 'create', model: undefined }, testRuntime)); + sessions.add(await launcher.launch({ ...basePlan, kind: 'resume', fallback: { model: undefined } }, testRuntime)); + } finally { + sessions.dispose(); + await launcher.disposeByokProxyHandle(); + } } + const managedSettings = { permissions: { disableBypassPermissionsMode: 'disable', ask: ['Shell'] } }; + assert.deepStrictEqual(observed, [ + { kind: 'create', managedSettings, enabled: true }, + { kind: 'resume', managedSettings, enabled: true }, + { kind: 'create', managedSettings: undefined, enabled: true }, + { kind: 'resume', managedSettings: undefined, enabled: true }, + ]); }); }); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 37788ac587ff6d..2c9a062c251cc6 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -1586,33 +1586,7 @@ suite('ProtocolServerHandler', () => { await assert.rejects(readPromise, /Client client-fs-overlap-close disconnected/); }); - test('client disconnect retains managed permissions through grace and removes them after expiry', () => { - return runWithFakedTimers({ useFakeTimers: true }, async () => { - stateManager.createSession(makeSessionSummary()); - stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); - - const transport = connectClient('client-d', [sessionUri]); - transport.sent.length = 0; - transport.simulateClose(); - stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionTitleChanged, title: 'After Disconnect' }); - - await new Promise(resolve => setTimeout(resolve, 29_999)); - const beforeGraceExpiry = [...agentService.removedManagedPermissionClients]; - await new Promise(resolve => setTimeout(resolve, 2)); - - assert.deepStrictEqual({ - sentMessages: transport.sent.length, - beforeGraceExpiry, - afterGraceExpiry: agentService.removedManagedPermissionClients, - }, { - sentMessages: 0, - beforeGraceExpiry: [], - afterGraceExpiry: ['client-d'], - }); - }); - }); - - test('reconnect during grace preserves managed permissions', () => { + test('reconnect preserves managed permissions until the next disconnect grace expires', () => { return runWithFakedTimers({ useFakeTimers: true }, async () => { const transport1 = connectClient('client-managed-reconnect'); const initializeResponse = findResponse(transport1.sent, 1) as { result: InitializeResult }; @@ -1631,13 +1605,18 @@ suite('ProtocolServerHandler', () => { })); await reconnectResponse; await new Promise(resolve => setTimeout(resolve, 30_001)); + const afterOriginalGraceExpiry = [...agentService.removedManagedPermissionClients]; + transport2.simulateClose(); + await new Promise(resolve => setTimeout(resolve, 30_001)); assert.deepStrictEqual({ duringGrace, - afterOriginalGraceExpiry: agentService.removedManagedPermissionClients, + afterOriginalGraceExpiry, + afterSecondGraceExpiry: agentService.removedManagedPermissionClients, }, { duringGrace: [], afterOriginalGraceExpiry: [], + afterSecondGraceExpiry: ['client-managed-reconnect'], }); }); }); diff --git a/src/vs/workbench/browser/actions/developerActions.ts b/src/vs/workbench/browser/actions/developerActions.ts index 8e55dfc0186787..3f3a116d83abcb 100644 --- a/src/vs/workbench/browser/actions/developerActions.ts +++ b/src/vs/workbench/browser/actions/developerActions.ts @@ -55,7 +55,7 @@ import * as json from '../../../base/common/json.js'; import { getParseErrorMessage } from '../../../base/common/jsonErrorMessages.js'; import { IAgentHostService } from '../../../platform/agentHost/common/agentService.js'; import { IAgentHostEnablementService } from '../../../platform/agentHost/common/agentHostEnablementService.js'; -import { deriveManagedPermissions, GLOBAL_AUTO_APPROVE_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, validateManagedPermissionRules } from '../../../platform/agentHost/common/agentHostSchema.js'; +import { deriveManagedPermissions, GLOBAL_AUTO_APPROVE_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID } from '../../../platform/agentHost/common/agentHostSchema.js'; class InspectContextKeysAction extends Action2 { @@ -985,24 +985,13 @@ class PolicyDiagnosticsAction extends Action2 { content += '### Agent Host Client Injection\n\n'; content += '*Synthesized by VS Code from effective managed policy values and forwarded to supporting Agent Host providers as session-local managed permissions.*\n\n'; - const agentHostManagedPermissions = deriveManagedPermissions({ - globalAutoApprove: configurationService.inspect(GLOBAL_AUTO_APPROVE_SETTING_ID).policyValue, - terminalAutoApproveEnabled: configurationService.inspect(TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID).policyValue, - }); + const agentHostManagedPermissions = deriveManagedPermissions( + configurationService.inspect(GLOBAL_AUTO_APPROVE_SETTING_ID).policyValue, + configurationService.inspect(TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID).policyValue, + ); content += '**Synthesized managed permissions**\n\n'; content += jsonBlock(agentHostManagedPermissions ?? {}); content += `**Expected session runtime provenance**: ${agentHostManagedPermissions ? '`client` when no account/device policy contributes; `mixed` otherwise' : 'the account/device baseline shown below'}\n\n`; - const agentHostManagedPermissionIssues = validateManagedPermissionRules(agentHostManagedPermissions); - content += `**Rule validation issues (${agentHostManagedPermissionIssues.length})**\n\n`; - if (agentHostManagedPermissionIssues.length > 0) { - for (const issue of agentHostManagedPermissionIssues) { - content += `- ${issue}\n`; - parseErrors.push({ stage: 'agentHost: client permissions', message: issue }); - } - content += '\n'; - } else { - content += '*None.*\n\n'; - } content += '### Agent Runtime Account and Device Baseline\n\n'; content += '*Queried from each provider when this report is generated. The SDK query covers account/server and device policy, but intentionally excludes the session-local Agent Host client injection above and may use the provider runtime\'s own policy cache. Therefore `source: none` here does not mean that synthesized client permissions are inactive; a created session reports `client` or `mixed` provenance after applying them.*\n\n'; From 4fc24998c99d1b2cc3a76bf64f03c3658ddc892a Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:15:09 -0700 Subject: [PATCH 12/12] fix(agent-host): clamp live managed bypass Apply managed bypass restrictions to the live SDK permission mode immediately when root policy changes, rather than waiting for session refresh on the next send. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/copilot/copilotAgentSession.ts | 5 ++++- .../agentHost/test/node/copilotAgentSession.test.ts | 12 +++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 8bf5469889e867..8fbdbb52261291 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -32,7 +32,7 @@ import { CopilotCliConfigKey, applyModelFamilyAlias, copilotCliConfigSchema } fr import type { ChatInputRequestWithPlanReview, IAgentHostPlanReviewAction } from '../../common/agentHostPlanReview.js'; import { gitHubMcpServerUrl } from '../../common/githubEndpoints.js'; import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; -import { AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyAnswer, AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, platformRootSchema, platformSessionSchema } from '../../common/agentHostSchema.js'; +import { AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyAnswer, AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostManagedPermissionsConfigKey, platformRootSchema, platformSessionSchema } from '../../common/agentHostSchema.js'; import { AgentSession, AgentSignal, AuthenticateParams, IMcpNotification, IRestoredSubagentSession, subagentChatTitle, type IAgentToolPendingConfirmationSignal } from '../../common/agentService.js'; import { META_DIFF_BASE_BRANCH } from '../../common/agentHostGitService.js'; import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; @@ -2992,6 +2992,9 @@ export class CopilotAgentSession extends Disposable { * level. Agent mode is an orthogonal axis and does not affect approvals. */ private _isBypassApprovals(): boolean { + if (this._configurationService.getRootValue(platformRootSchema, AgentHostManagedPermissionsConfigKey)?.disableBypassPermissionsMode === 'disable') { + return false; + } if (this._configurationService.getRootValue(platformRootSchema, AgentHostGlobalAutoApproveEnabledConfigKey) === true) { return true; } diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index b32a1f1226c74d..6afdb23bd9d2e1 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -47,7 +47,7 @@ import { TestAgentHostTerminalManager } from './testAgentHostTerminalManager.js' import { buildCopilotSystemNotification } from '../../node/copilot/copilotSystemNotification.js'; import { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; -import { AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey } from '../../common/agentHostSchema.js'; +import { AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostManagedPermissionsConfigKey } from '../../common/agentHostSchema.js'; import { CopilotCliConfigKey } from '../../common/copilotCliConfig.js'; import { AgentHostSandboxConfigKey, AgentHostSandboxKey } from '../../common/sandboxConfigSchema.js'; import { AgentSandboxEnabledValue } from '../../../sandbox/common/settings.js'; @@ -3480,15 +3480,17 @@ suite('CopilotAgentSession', () => { assert.deepStrictEqual(mockSession.permissionModeSetCalls, ['auto']); }); - test('syncs permission mode when root approval configuration changes', async () => { - const { session, mockSession, setRootValue, fireRootConfigChange } = await createAgentSession(disposables); + test('managed policy clamps a live allow-all session', async () => { + const { session, mockSession, setRootValue, fireRootConfigChange } = await createAgentSession(disposables, { + configValues: { [SessionConfigKey.AutoApprove]: 'autoApprove' }, + }); await session.syncPermissionMode('turn-start'); - setRootValue(AgentHostGlobalAutoApproveEnabledConfigKey, true); + setRootValue(AgentHostManagedPermissionsConfigKey, { disableBypassPermissionsMode: 'disable' }); fireRootConfigChange(); await timeout(0); - assert.deepStrictEqual(mockSession.permissionModeSetCalls, ['off', 'on']); + assert.deepStrictEqual(mockSession.permissionModeSetCalls, ['on', 'off']); }); test('aborts when a live permission mode update fails', async () => {