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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 61 additions & 29 deletions src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ 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, 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 { managedPermissionsSettingMappings, resolveManagedPermissions } from '../common/agentHostManagedSettings.js';
import { AgentHostClientConnectionKind, toClientConnectionTelemetryMeta } from '../common/agentHostTelemetry.js';
import type { OtlpExportLogsParams } from '../common/state/protocol/channels-otlp/notifications.js';
import type { TelemetryCapabilities } from '../common/state/protocol/channels-otlp/state.js';
Expand All @@ -50,7 +51,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;

Expand Down Expand Up @@ -362,6 +362,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 (managedPermissionsSettingMappings.some(entry => e.affectsConfiguration(entry.settingId))) {
this._updateManagedPermissions();
}
if (e.affectsConfiguration(PREFER_LONG_CONTEXT_SETTING_ID)) {
this._updatePreferLongContextEnabled();
}
Expand Down Expand Up @@ -616,6 +619,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC

this._applyReconnectResult(result, freshInitialize);
if (freshInitialize && result.type === ReconnectResultType.Snapshot) {
this._forwardClientConfig(true);
await this._restoreAuthenticationAfterFreshInitialize();
await this._restoreSubscriptionsAfterFreshInitialize(result.snapshots);
}
Expand All @@ -627,7 +631,9 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
// be a freshly restarted process that never received these values (the
// reconnect result itself carries none), which would otherwise leave
// early-read config like the migrate flag at its host-side default.
this._forwardClientConfig();
if (!freshInitialize) {
this._forwardClientConfig();
Comment thread
joshspicer marked this conversation as resolved.
}

// Drain the outbox BEFORE the transition so listeners reacting to
// {@link onDidChangeConnectionState} that synchronously dispatch see
Expand All @@ -643,6 +649,14 @@ 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._cancelLivenessTimers();
reconnect.outbox.length = 0;
this._rejectPendingRequests(err);
this._transitionTo({ kind: AgentHostClientState.Incompatible, error: err });
reconnect.gate.error(err);
return;
}
transport?.dispose();
if (this._state.kind !== AgentHostClientState.Reconnecting) {
return;
Expand Down Expand Up @@ -681,7 +695,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
...this._clientConnectionTelemetryMeta(),
initialSubscriptions: subscriptions,
}, { bypassReconnectGate: true });
this._applyInitializeResult(initializeResult);
this._applyInitializeResult(initializeResult, false);
return {
result: { type: ReconnectResultType.Snapshot, snapshots: initializeResult.snapshots ?? [] },
freshInitialize: true,
Expand Down Expand Up @@ -737,14 +751,16 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
return meta ? { _meta: meta } : {};
}

private _applyInitializeResult(result: CommandMap['initialize']['result']): void {
private _applyInitializeResult(result: CommandMap['initialize']['result'], forwardClientConfig = true): void {
this._initializeResult.set(result, undefined);
this._serverSeq = result.serverSeq;
if (result.defaultDirectory) {
const directory = result.defaultDirectory;
this._defaultDirectory = typeof directory === 'string' ? URI.parse(directory).path : URI.revive(directory).path;
}
this._forwardClientConfig();
if (forwardClientConfig) {
this._forwardClientConfig();
}
}

/**
Expand All @@ -760,13 +776,14 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
* key-plus-transform can't express: values derived from several settings, and
* settings contributed by an extension rather than by core.
*/
private _forwardClientConfig(): void {
this._dispatchRootConfig(resolveAgentHostConfigurationSyncPatch(this._configurationService, this._resourceIdentity === LOCAL_AGENT_HOST_RESOURCE_IDENTITY));
this._updateTelemetryLevel();
this._updatePreferLongContextEnabled();
this._updateTerminalAutoApproveEnabled();
this._updateTerminalAutoApproveRules();
this._updateDisableRepoInfoTelemetry();
private _forwardClientConfig(sendDuringReconnect = false): void {
this._dispatchRootConfig(resolveAgentHostConfigurationSyncPatch(this._configurationService, this._resourceIdentity === LOCAL_AGENT_HOST_RESOURCE_IDENTITY), sendDuringReconnect);
this._updateTelemetryLevel(sendDuringReconnect);
this._updateManagedPermissions(sendDuringReconnect);
this._updatePreferLongContextEnabled(sendDuringReconnect);
this._updateTerminalAutoApproveEnabled(sendDuringReconnect);
this._updateTerminalAutoApproveRules(sendDuringReconnect);
this._updateDisableRepoInfoTelemetry(sendDuringReconnect);
}

/**
Expand Down Expand Up @@ -935,9 +952,9 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
/**
* Dispatch a client action to the server. Returns the clientSeq used.
*/
private dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, _clientId: string, clientSeq: number): void {
private dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, _clientId: string, clientSeq: number, sendDuringReconnect = false): void {
this._grantImplicitReadsForOutgoingAction(action);
this._sendNotification('dispatchAction', { channel, clientSeq, action });
this._sendNotification('dispatchAction', { channel, clientSeq, action }, sendDuringReconnect);
}

/**
Expand Down Expand Up @@ -1515,7 +1532,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
}

/** Send a typed JSON-RPC notification for a protocol-defined method. */
private _sendNotification<M extends keyof ClientNotificationMap>(method: M, params: ClientNotificationMap[M]['params']): void {
private _sendNotification<M extends keyof ClientNotificationMap>(method: M, params: ClientNotificationMap[M]['params'], sendDuringReconnect = false): void {
if (this._state.kind === AgentHostClientState.Closed || this._state.kind === AgentHostClientState.Incompatible) {
return;
}
Expand All @@ -1526,7 +1543,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
this._state.outbox.push(message);
return;
}
if (this._state.kind === AgentHostClientState.Reconnecting) {
if (this._state.kind === AgentHostClientState.Reconnecting && !sendDuringReconnect) {
// Queue for the new transport — drained by {@link _drainAfterReconnect}
// once the soft-reconnect handshake completes. The outbox persists
// across failed attempts so a message rides through retry cycles
Expand All @@ -1547,40 +1564,55 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
return this._dispatchRequest<IRemoteAgentHostExtensionCommandMap[M]['result']>(method, params);
}

private _updateTelemetryLevel(): void {
this._dispatchRootConfig({ [AgentHostTelemetryLevelConfigKey]: telemetryLevelToAgentHostConfigValue(getTelemetryLevel(this._configurationService)) });
private _updateTelemetryLevel(sendDuringReconnect = false): void {
this._dispatchRootConfig({ [AgentHostTelemetryLevelConfigKey]: telemetryLevelToAgentHostConfigValue(getTelemetryLevel(this._configurationService)) }, sendDuringReconnect);
}

/** Merge a patch into the agent host's root configuration. */
private _dispatchRootConfig(config: Record<string, unknown>): void {
private _dispatchRootConfig(config: Record<string, unknown>, sendDuringReconnect = false): void {
this.dispatchAction(ROOT_STATE_URI, {
type: ActionType.RootConfigChanged,
config,
}, this._clientId, 0);
}, this._clientId, 0, sendDuringReconnect);
}

private _updateDisableRepoInfoTelemetry(): void {
private _updateDisableRepoInfoTelemetry(sendDuringReconnect = false): void {
const disabled = this._configurationService.getValue<boolean>(DISABLE_REPO_INFO_TELEMETRY_SETTING_ID) === true;
this._dispatchRootConfig({ [AgentHostDisableRepoInfoTelemetryConfigKey]: disabled });
this._dispatchRootConfig({ [AgentHostDisableRepoInfoTelemetryConfigKey]: disabled }, sendDuringReconnect);
}

/**
* Forward permission restrictions derived from the effective VS Code
* settings. The SDK/runtime owns validation and enforcement.
*/
private _updateManagedPermissions(sendDuringReconnect = false): void {
const permissions = this._deriveManagedPermissions();
// Root config patches merge over existing values. An empty object is
// the wire-safe clear sentinel because JSON drops `undefined`.
this._dispatchRootConfig({ [AgentHostManagedPermissionsConfigKey]: permissions ?? {} }, sendDuringReconnect);
Comment thread
joshspicer marked this conversation as resolved.
}

private _deriveManagedPermissions() {
return resolveManagedPermissions(this._configurationService);
}

private _updatePreferLongContextEnabled(): void {
private _updatePreferLongContextEnabled(sendDuringReconnect = false): void {
const enabled = this._configurationService.getValue<boolean>(PREFER_LONG_CONTEXT_SETTING_ID) === true;
this._dispatchRootConfig({ [AgentHostPreferLongContextEnabledConfigKey]: enabled });
this._dispatchRootConfig({ [AgentHostPreferLongContextEnabledConfigKey]: enabled }, sendDuringReconnect);
}

private _updateTerminalAutoApproveEnabled(): void {
private _updateTerminalAutoApproveEnabled(sendDuringReconnect = false): void {
// Deliberately on the manual, workspace-aware path rather than declaring
// `agentHost` on its schema: the setting is `restricted` and settable per
// workspace, and its companion rule set (`terminalAutoApproveRules`) is
// workspace-aware too. Resolving only the global value here would let a
// workspace that turned auto-approval off still have it applied.
const enabled = this._configurationService.getValue<boolean>(TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID) !== false;
this._dispatchRootConfig({ [AgentHostTerminalAutoApproveEnabledConfigKey]: enabled });
this._dispatchRootConfig({ [AgentHostTerminalAutoApproveEnabledConfigKey]: enabled }, sendDuringReconnect);
}

private _updateTerminalAutoApproveRules(): void {
this._dispatchRootConfig({ [AgentHostTerminalAutoApproveRulesConfigKey]: getAgentHostTerminalAutoApproveRulesConfig(this._configurationService) });
private _updateTerminalAutoApproveRules(sendDuringReconnect = false): void {
this._dispatchRootConfig({ [AgentHostTerminalAutoApproveRulesConfigKey]: getAgentHostTerminalAutoApproveRulesConfig(this._configurationService) }, sendDuringReconnect);
}

/**
Expand Down
32 changes: 32 additions & 0 deletions src/vs/platform/agentHost/common/agentHostManagedSettings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { IConfigurationService } from '../../configuration/common/configuration.js';
import { GLOBAL_AUTO_APPROVE_SETTING_ID, IManagedPermissions, MANAGED_PERMISSION_TERMINAL_ASK_RULE, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID } from './agentHostSchema.js';

export interface IManagedPermissionsSettingMapping {
readonly settingId: string;
readonly transform: (value: unknown) => IManagedPermissions | undefined;
}

export function createManagedPermissionsSettingMapping<T>(settingId: string, transform: (value: T | undefined) => IManagedPermissions | undefined): IManagedPermissionsSettingMapping {
return { settingId, transform: value => transform(value as T | undefined) };
}

export const managedPermissionsSettingMappings: readonly IManagedPermissionsSettingMapping[] = [
createManagedPermissionsSettingMapping<boolean>(GLOBAL_AUTO_APPROVE_SETTING_ID, value => value === false ? { disableBypassPermissionsMode: 'disable' } : undefined),
createManagedPermissionsSettingMapping<boolean>(TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, value => value === false ? { ask: [MANAGED_PERMISSION_TERMINAL_ASK_RULE] } : undefined),
];

export function resolveManagedPermissions(configurationService: IConfigurationService): IManagedPermissions | undefined {
const permissions: IManagedPermissions = {};
for (const entry of managedPermissionsSettingMappings) {
const contribution = entry.transform(configurationService.getValue(entry.settingId));
if (contribution) {
Object.assign(permissions, contribution);
}
}
return Object.keys(permissions).length > 0 ? permissions : undefined;
}
62 changes: 62 additions & 0 deletions src/vs/platform/agentHost/common/agentHostSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,52 @@ const permissionsProperty = schemaProperty<IPermissionsValue>({
sessionMutable: true,
});

/** Managed runtime restrictions synthesized from effective VS Code settings. */
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';

/**
* Treat the empty object used as the merge-safe root-config clear sentinel as
* no managed permissions.
*/
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<IManagedPermissions>({
type: 'object',
title: localize('agentHost.config.managedPermissions.title', "Managed Permissions"),
description: localize('agentHost.config.managedPermissions.description', "Permission restrictions derived from effective VS Code settings and forwarded to the runtime as `managedSettings.permissions` at session startup."),
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.
Expand Down Expand Up @@ -433,6 +479,21 @@ export const TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID = 'chat.tools.terminal.ena
*/
export const AgentHostGlobalAutoApproveEnabledConfigKey = 'globalAutoApproveEnabled';

/**
* The VS Code setting ID for global auto approve. Defined here so renderer-side
* agent-host clients can forward it without importing from `workbench/contrib/chat`.
*/
export const GLOBAL_AUTO_APPROVE_SETTING_ID = 'chat.tools.global.autoApprove';

/**
* Root config key forwarded from the renderer holding the {@link IManagedPermissions}
* object. Synthesized by VS Code from the effective 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 mapped setting applies.
*/
export const AgentHostManagedPermissionsConfigKey = 'managedPermissions';

/**
* Root config key forwarded from the renderer when VS Code's `chat.autoReply`
* setting changes. When `true`, the agent host auto-answers `ask_user`
Expand Down Expand Up @@ -714,6 +775,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<boolean>({
type: 'boolean',
title: localize('agentHost.config.autoReplyEnabled.title', "Auto Reply"),
Expand Down
3 changes: 3 additions & 0 deletions src/vs/platform/agentHost/common/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2125,6 +2125,9 @@ export interface IAgentService {
*/
dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number, clientContext?: IAgentHostClientTelemetryContext): void;

/** Remove the 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.
Expand Down
Loading
Loading