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/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 09d9aa7e09ce41..6f3c5751714bad 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'; @@ -50,7 +50,6 @@ import { isFileResourceRead } from '../common/resourceReadLogging.js'; import { ResourceSet } from '../../../base/common/map.js'; const AHP_CLIENT_CONNECTION_CLOSED = -32000; - /** Initial delay before the first transport-level reconnect attempt. */ const RECONNECT_INITIAL_DELAY_MS = 1_000; @@ -276,6 +275,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 +293,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 @@ -361,6 +367,9 @@ 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(); } @@ -469,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); @@ -516,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) { @@ -620,6 +624,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; @@ -667,6 +675,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC } private _applyInitializeResult(result: CommandMap['initialize']['result']): void { + this._assertManagedPermissionsSupported(result); this._initializeResult.set(result, undefined); this._serverSeq = result.serverSeq; if (result.defaultDirectory) { @@ -692,6 +701,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(); @@ -1202,11 +1212,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; } @@ -1303,11 +1314,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 { @@ -1492,6 +1511,82 @@ 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 an empty-object clear sentinel is forwarded so the + * merge-based root config drops any previously forwarded permissions. + */ + private _updateManagedPermissions(): void { + const permissions = this._deriveManagedPermissions(); + const initializeResult = this._initializeResult.get(); + if (initializeResult && !this._supportsManagedPermissions(initializeResult)) { + if (!permissions) { + return; + } + const error = this._managedPermissionsUnsupportedError(); + 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( + this._configurationService.inspect(GLOBAL_AUTO_APPROVE_SETTING_ID).policyValue, + this._configurationService.inspect(TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID).policyValue, + ); + } + + 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(result: CommandMap['initialize']['result']): void { + if (this._deriveManagedPermissions() && !this._supportsManagedPermissions(result)) { + throw this._managedPermissionsUnsupportedError(); + } + } + + private _managedPermissionsUnsupportedError(): ProtocolError { + return new ProtocolError( + AhpErrorCodes.UnsupportedProtocolVersion, + 'The connected Agent Host does not advertise managed-permissions enforcement support.', + ); + } + + 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..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. @@ -541,8 +551,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/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 0a0fe2c546a633..195ceb67481f96 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -297,6 +297,64 @@ const permissionsProperty = schemaProperty({ sessionMutable: true, }); +/** Managed runtime restrictions synthesized from legacy VS Code enterprise policy. */ +export interface IManagedPermissions { + readonly disableBypassPermissionsMode?: 'disable'; + /** Canonical all-shell prompt rule; active runtime rule policy defaults other governed kinds to ask. */ + readonly ask?: readonly ['Shell']; +} + +export const MANAGED_PERMISSION_TERMINAL_ASK_RULE = 'Shell'; + +/** + * Translate legacy managed auto-approval policy into restrictive runtime settings. + */ +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; +} + +/** + * 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 { + 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({ + 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'], + }, + ask: { + type: 'array', + 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 + // `undefined` before SDK launch, so `managedSettings` is omitted. +}); + /** * Session-config properties owned by the platform itself — i.e. consumed * by the agent host rather than by any particular agent. @@ -433,6 +491,24 @@ 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` 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'; + +/** 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` @@ -714,6 +790,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/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 206824461200a5..798edfa71c1ab8 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; @@ -2137,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..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, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, platformRootSchema } 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'; @@ -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; /** @@ -2596,12 +2598,18 @@ 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; - 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 @@ -2615,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); } @@ -2633,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); } /** @@ -2675,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) { @@ -2693,8 +2711,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 +2741,41 @@ export class AgentService extends Disposable implements IAgentService { this._sideEffects.handleAction(channel, action, clientId, clientContext); } + removeClientManagedPermissions(clientId: string): void { + this._clientDispatchGenerations.set(clientId, (this._clientDispatchGenerations.get(clientId) ?? 0) + 1); + if (this._managedPermissionsByClient.delete(clientId)) { + 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 { + if (permissions) { + this._managedPermissionsByClient.set(clientId, permissions); + } else { + this._managedPermissionsByClient.delete(clientId); + } + return this._getEffectiveManagedPermissions(); + } + + private _getEffectiveManagedPermissions(): IManagedPermissions | undefined { + const permissions = [...this._managedPermissionsByClient.values()]; + const disableBypassPermissionsMode = permissions.some(value => value.disableBypassPermissionsMode === 'disable'); + const askForShell = permissions.some(value => value.ask !== undefined); + return disableBypassPermissionsMode || askForShell ? { + ...(disableBypassPermissionsMode ? { disableBypassPermissionsMode: 'disable' as const } : {}), + ...(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 { if (action.type !== ActionType.ChatTurnStarted && action.type !== ActionType.ChatPendingMessageSet) { return false; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 44e0c98ed10f84..87faebf14307f1 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, 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,17 +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) { - const entry = await this._ensureChatSession(context.session, chat); - if (!entry) { - throw new Error(`[Copilot] sendMessage for unknown chat: ${chat.toString()}`); - } - 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 chatKey = chat.toString(); + await this._queueChat(context.sessionId, chatKey, async () => { + let entry = await this._ensureChatSession(context.session, chat); + if (!entry) { + throw new Error(`[Copilot] sendMessage for unknown chat: ${chatKey}`); + } + 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 () => { @@ -5209,6 +5223,7 @@ class ActiveClient extends Disposable { tools: this.toolSet.merged(), plugins: await this.pluginController.getAppliedPlugins(), mcpServers: this._getMcpServers(), + managedPermissions: this._getManagedPermissions(), }; } @@ -5218,6 +5233,12 @@ class ActiveClient extends Disposable { return structuredClone(servers); } + private _getManagedPermissions(): IManagedPermissions | undefined { + return normalizeManagedPermissions( + 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 +5254,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/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/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 21e569cdb83a33..1c6b76b70023e1 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, 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'; @@ -82,6 +82,7 @@ type McpAuthContext = Parameters[1]; type McpAuthResponse = Awaited>; type PreToolUseHookInput = Parameters>[0]; type PostToolUseHookInput = Parameters>[0]; + /** * Immutable snapshot of the active client's structural contributions at * session creation time. Used to detect when the session needs to be @@ -95,6 +96,8 @@ export interface IActiveClientSnapshot { readonly tools: readonly ToolDefinition[]; readonly plugins: readonly ICopilotPluginInfo[]; readonly mcpServers: AgentHostMcpServers; + /** Startup-only managed permissions included in structural restart detection. */ + readonly managedPermissions?: IManagedPermissions; } /** @@ -579,6 +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 managedPermissions = normalizeManagedPermissions( + this._configurationService.getRootValue(platformRootSchema, AgentHostManagedPermissionsConfigKey), + ); const enableCustomTerminalTool = this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.EnableCustomTerminalTool) === true; let shellTools: Awaited> = []; if (enableCustomTerminalTool) { @@ -677,6 +683,17 @@ 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.disableBypassPermissionsMode ? { disableBypassPermissionsMode: managedPermissions.disableBypassPermissionsMode } : {}), + ...(managedPermissions.ask ? { ask: [...managedPermissions.ask] } : {}), + }, + }, + } : {}), }; } } diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 553161032f013a..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,16 +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._handleClientDisconnected(client.clientId); + this._handleClientDisconnected(clientId); this._onDidChangeConnectionCount.fire(this._connectedClientCount); } this._reportClientDisconnected(client, subscriptionCount); @@ -613,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({ @@ -764,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({ @@ -1016,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)); @@ -1106,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; @@ -1204,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') { @@ -1755,7 +1769,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; @@ -1769,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/common/agentHostSchema.test.ts b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts index 11a8947c33e818..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, migrateLegacyAutopilotConfig, normalizeAgentHostTerminalAutoApproveRulesConfig, platformSessionSchema, schemaProperty, type AgentHostTerminalAutoApproveRules, type AutoApproveLevel, 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'; @@ -414,4 +414,32 @@ suite('agentHostSchema', () => { }); }); }); + + suite('deriveManagedPermissions', () => { + + 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), + 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, + unsupportedRule: false, + clearSentinel: undefined, + normalized: { disableBypassPermissionsMode: 'disable', ask: ['Shell'] }, + }); + }); + }); }); 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/electron-browser/remoteAgentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts index eb3948594d012e..5714e5e16e7012 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,35 @@ 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; + } + + setPolicyValue(key: string, value: unknown): void { + if (value === undefined) { + delete this._policyValues[key]; + } else { + this._policyValues[key] = value; + } + } +} + suite('RemoteAgentHostProtocolClient', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -193,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; } @@ -239,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), }; } @@ -261,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; } @@ -913,6 +960,126 @@ 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], + }); + + 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 explicit host support when restrictive policy is present', async () => { + const configurationService = new ManagedPermissionPolicyConfigurationService({ + [GLOBAL_AUTO_APPROVE_SETTING_ID]: false, + }); + 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, SUPPORTED_PROTOCOL_VERSIONS); + transport.fireMessage({ + jsonrpc: '2.0', + id: initialize.id, + result: { protocolVersion: PROTOCOL_VERSION, serverSeq: 0, snapshots: [] }, + }); + + await assertRemoteProtocolError(connect, { + code: AhpErrorCodes.UnsupportedProtocolVersion, + 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 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(); + const initialize = transport.sentMessages[0] as JsonRpcRequest; + transport.fireMessage({ + jsonrpc: '2.0', + id: initialize.id, + result: { protocolVersion: PROTOCOL_VERSION, 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. + 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.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); @@ -1032,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 () => { @@ -1617,7 +1791,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()); @@ -1625,7 +1799,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 }; } @@ -1706,6 +1880,38 @@ suite('RemoteAgentHostProtocolClient', () => { } }); + 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); + 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, SUPPORTED_PROTOCOL_VERSIONS); + reconnectTransport.fireMessage({ + jsonrpc: '2.0', + id: initialize.id, + result: { protocolVersion: PROTOCOL_VERSION, serverSeq: 0, snapshots: [] }, + }); + 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..d2bf2b1f618acb 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' } }]); @@ -559,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 f08f8de0ad10f0..bb07d8386e74b3 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'; @@ -890,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]: { disableBypassPermissionsMode: 'disable' } }, + }, 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'); @@ -986,17 +1050,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]: { disableBypassPermissionsMode: 'disable' }, + }, }, '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 +1089,55 @@ suite('AgentService (node dispatcher)', () => { } }); + 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 { + traces.push({ message, args }); + } + }; + const svc = disposables.add(new AgentService(logService, fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const managedPermissions = { + ask: ['Shell'] as const, + }; + 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), + traceHasManagedValue: serializedTraces.includes('disableBypassPermissionsMode'), + }, { + beforeDisconnect: { disableBypassPermissionsMode: 'disable', ask: ['Shell'] }, + afterManagedDisconnect: { disableBypassPermissionsMode: 'disable' }, + afterAllManagedDisconnect: {}, + originalPermissions: managedPermissions, + traceHasRedaction: true, + traceHasManagedValue: 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/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index af8bf5363b5d7a..0200db12019466 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'; @@ -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,60 @@ suite('CopilotAgent', () => { } }); + 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'); + 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'); + 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++; + } + }); + (agent as unknown as ChatInternals)._ensureChatSession = async () => { + const existing = getPeerChatStub(agent, chat); + if (existing) { + return existing; + } + setPeerChatStub(agent, chat, fresh.fake); + return fresh.fake; + }; + + configurationService.updateRootConfig({ + [AgentHostManagedPermissionsConfigKey]: { disableBypassPermissionsMode: 'disable' }, + }); + await Promise.all([ + agent.chats.sendMessage(chat, 'first-after-policy-change', undefined), + agent.chats.sendMessage(chat, 'second-after-policy-change', undefined), + ]); + + assert.deepStrictEqual({ + oldDestroyCalls, + freshDisposed: fresh.rec.disposed, + freshPrompts: fresh.rec.sends.map(send => send.prompt), + }, { + oldDestroyCalls: 1, + freshDisposed: false, + freshPrompts: ['first-after-policy-change', 'second-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/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 () => { diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts index 9bbe89c94021aa..5aca0371377eb1 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'); }, @@ -41,9 +42,9 @@ const testRuntime: ICopilotSessionRuntime = { const testWorkingDirectory = URI.file(process.cwd()); -function createTestLauncher(): CopilotSessionLauncher { +function createTestLauncher(values: Record = {}): CopilotSessionLauncher { const configurationService = { - getRootValue: () => undefined, + getRootValue: (_schema: unknown, key: string) => values[key], } as Partial as IAgentConfigurationService; return new CopilotSessionLauncher( configurationService, @@ -423,6 +424,53 @@ suite('CopilotSessionLauncher shared session config', () => { 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 }, + ]); + }); }); suite('CopilotSessionLauncher resume fallback', () => { diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index dcfedd90c7413a..2c9a062c251cc6 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'); @@ -1582,18 +1586,39 @@ 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, }); - - const transport = connectClient('client-d', [sessionUri]); - transport.sent.length = 0; + 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 }; + transport1.simulateClose(); - transport.simulateClose(); + await new Promise(resolve => setTimeout(resolve, 15_000)); + const duringGrace = [...agentService.removedManagedPermissionClients]; - stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionTitleChanged, title: 'After Disconnect' }); + 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)); + const afterOriginalGraceExpiry = [...agentService.removedManagedPermissionClients]; - assert.strictEqual(transport.sent.length, 0); + transport2.simulateClose(); + await new Promise(resolve => setTimeout(resolve, 30_001)); + assert.deepStrictEqual({ + duringGrace, + afterOriginalGraceExpiry, + afterSecondGraceExpiry: agentService.removedManagedPermissionClients, + }, { + duringGrace: [], + afterOriginalGraceExpiry: [], + afterSecondGraceExpiry: ['client-managed-reconnect'], + }); + }); }); test('client disconnect retains active client during grace, then removes it and fails owned tool calls after grace period', () => { diff --git a/src/vs/workbench/browser/actions/developerActions.ts b/src/vs/workbench/browser/actions/developerActions.ts index 04ab38aadb3afc..3f3a116d83abcb 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 } 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,24 @@ 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( + 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`; + + 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) {