Skip to content
Merged
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
4 changes: 3 additions & 1 deletion src/vs/platform/agentHost/browser/agentHostProtocolClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1999,7 +1999,9 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect
}

private _authenticationKey(params: AuthenticateParams): string {
return `${params.resource}\0${JSON.stringify(params.scopes ?? [])}`;
const key = `${params.resource}\0${JSON.stringify(params.scopes ?? [])}`;
// Targeted MCP credentials are independent of each other and of the provider-level credential.
return params.target ? `${key}\0${JSON.stringify([params.target.kind, params.target.session, params.target.customizationId])}` : key;
}

/**
Expand Down
4 changes: 3 additions & 1 deletion src/vs/platform/agentHost/common/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import type { CodexModelProvider, IAgentTurnTelemetryCorrelation, IAgentHostClie
import type { AgentPermissionDecisionSource } from './meta/agentPermissionResponseMeta.js';
import type { IAgentChatUsage } from './meta/agentUsageMeta.js';
import type { IMcpServerConfiguration } from '../../mcp/common/mcpPlatformTypes.js';
import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from './state/protocol/commands.js';
import type { AuthenticateTarget, ResolveSessionConfigResult, SessionConfigCompletionsResult } from './state/protocol/commands.js';
import type { CanvasState } from './state/protocol/channels-canvas/state.js';
import { subagentChatUri, type ChatUriFormat } from './agentHostChatMinting.js';
import { ProtectedResourceMetadata, type BackgroundWork, type Changeset, type ChatInteractivity, type ChatOrigin, type ConfigSchema, type MessageAttachment, type ModelSelection, type AgentSelection, type SessionActiveClient, type ToolCallPendingConfirmationState, type ToolDefinition, ChangesSummary } from './state/protocol/state.js';
Expand Down Expand Up @@ -531,6 +531,8 @@ export interface AuthenticateParams {
readonly token: string;
/** The access token's remaining lifetime in seconds, when known. */
readonly expiresIn?: number;
/** The single consumer this token is for; when absent, tokens match by resource and scopes. */
readonly target?: AuthenticateTarget;
/** Optional client metadata. Hosts must remain usable when it is absent. */
readonly _meta?: Record<string, unknown>;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ValidatorBase, vBoolean, vEnum, vObj, vOptionalProp, vString, type Vali
import type { IJSONSchema } from '../../../base/common/jsonSchema.js';
import type { IDevContainerAgentHostConnectResult } from './devContainerAgentHost.js';
import type { AgentHostDebugLogsArtifactKind, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult } from './agentService.js';
import type { McpServerAuthenticateTarget } from './state/sessionProtocol.js';
import type { InitializeResult } from './state/protocol/common/commands.js';
import type { McpAuthRequirement } from './state/protocol/channels-session/state.js';
import { AgentHostArtifactRemovalCapabilityMetaKey } from './meta/agentHostArtifactRemovalMeta.js';
Expand Down Expand Up @@ -284,6 +285,8 @@ export interface IAgentHostWorkspaceTrustRequest {
* `authenticate` command before responding.
*/
export interface IAgentHostMcpAuthenticationRequest {
/** Absent from hosts that predate targeted MCP authentication. */
readonly target?: McpServerAuthenticateTarget;
readonly serverName: string;
readonly auth: McpAuthRequirement;
}
Expand Down
52 changes: 52 additions & 0 deletions src/vs/platform/agentHost/common/state/protocol/common/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1252,6 +1252,41 @@ export interface ResourceMkdirResult {

// ─── authenticate ────────────────────────────────────────────────────────────

/**
* Identifies the consumer of an authentication token.
*
* @category Commands
* @nonexhaustive
*/
export const enum AuthenticateTargetKind {
McpServer = 'mcpServer',
}

/**
* An MCP server identified by host-published session and customization state.
*
* @category Commands
*/
export interface McpServerAuthenticateTarget {
kind: AuthenticateTargetKind.McpServer;
/** Session URI whose `SessionState.customizations` contains the MCP server. */
session: URI;
/**
* The server's session-unique `McpServerCustomization.id`, echoed from the
* customization carrying `McpServerAuthRequiredState` or from
* `ToolCallAuthRequiredState.contributor.customizationId`.
*/
customizationId: string;
}

/**
* Consumer of an authentication token, discriminated by `kind`.
* Extensible to support additional consumers, such as host-owned authentication.
*
* @category Commands
*/
export type AuthenticateTarget = McpServerAuthenticateTarget;

/**
* Pushes a Bearer token for a protected resource. The `resource` field MUST
* match a protected-resource identifier the client has discovered from the
Expand Down Expand Up @@ -1324,6 +1359,23 @@ export interface AuthenticateParams extends BaseParams {
* token.
*/
scopes?: string[];
/**
* Optional consumer of the token. Clients SHOULD include this when answering
* a specific MCP server's `McpServerAuthRequiredState` or
* `ToolCallAuthRequiredState` challenge, and omit it for provider-level
* resources (`AgentInfo.protectedResources` or `AuthRequired` -32007 data).
*
* When present, the host MUST apply the token only to that MCP server in
* that session, never to sibling servers, other sessions, or agent-level
* resources, even if they share the resource and scopes. `resource` MUST
* still equal that server's advertised `McpAuthRequirement.resource.resource`.
* The host MUST reject an unknown session or customization, or a
* customization that is not an MCP server, with `InvalidParams` (-32602).
*
* Without a target, resource/scope matching is unchanged; the token may
* satisfy every matching pending challenge. Older hosts ignore this field.
*/
target?: AuthenticateTarget;
}

/**
Expand Down
4 changes: 3 additions & 1 deletion src/vs/platform/agentHost/common/state/sessionProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export type {

// Command params and results
export type {
AuthenticateTarget,
CreateSessionParams,
DirectoryEntry,
DispatchActionParams,
Expand All @@ -54,6 +55,7 @@ export type {
InitializeResult,
ListSessionsParams,
ListSessionsResult,
McpServerAuthenticateTarget,
ReconnectParams,
ReconnectReplayResult,
ReconnectResult,
Expand Down Expand Up @@ -84,7 +86,7 @@ export type {
CreateResourceWatchResult,
} from './protocol/channels-resource-watch/commands.js';

export { ContentEncoding, ReconnectResultType, ResourceType, ResourceWriteMode } from './protocol/commands.js';
export { AuthenticateTargetKind, ContentEncoding, ReconnectResultType, ResourceType, ResourceWriteMode } from './protocol/commands.js';
export { ResourceChangeType } from './protocol/channels-resource-watch/state.js';
export type { ResourceChange, ResourceWatchState } from './protocol/channels-resource-watch/state.js';

Expand Down
31 changes: 19 additions & 12 deletions src/vs/platform/agentHost/node/agentHostAuthenticationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { DeferredPromise } from '../../../base/common/async.js';
import { getExpirationTime, getRemainingTimeInSeconds, isExpired } from '../../../base/common/date.js';
import { Emitter, Event } from '../../../base/common/event.js';
import { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js';
import { getComparisonKey } from '../../../base/common/resources.js';
import { URI } from '../../../base/common/uri.js';
import { createDecorator } from '../../instantiation/common/instantiation.js';
import type { ILogService } from '../../log/common/log.js';
import type { AuthenticateParams, AuthenticateResult, IAgent, IAgentHostAuthTokenRequest } from '../common/agent.js';
Expand Down Expand Up @@ -50,6 +52,7 @@ export interface IAgentHostAuthenticationController {
}

interface IStoredAuthToken {
readonly target?: AuthenticateParams['target'];
readonly resource: string;
readonly scopes: readonly string[];
readonly token: string;
Expand All @@ -58,6 +61,7 @@ interface IStoredAuthToken {
}

interface IAuthenticationRequest {
readonly target?: AuthenticateParams['target'];
readonly resource: string;
readonly completed: DeferredPromise<void>;
}
Expand Down Expand Up @@ -104,7 +108,7 @@ export class AgentHostAuthenticationService extends Disposable implements IAgent
...providerList.flatMap(provider => provider.getProtectedResources().map(resource => resource.resource)),
...(scope.principal ? [scope.principal.apiOrigin] : []),
];
if (resources.includes(params.resource)) {
if (!params.target && resources.includes(params.resource)) {
await scope.authenticate(params, resources);
return { authenticated: true };
}
Expand All @@ -123,9 +127,9 @@ export class AgentHostAuthenticationService extends Disposable implements IAgent
return { authenticated };
}
const scopes = normalizeAuthenticationScopes(params.scopes);
const key = this._key(params.resource, scopes);
const key = this._key(params.resource, scopes, params.target);
const previousRequest = this._authenticationRequests.get(key);
const request: IAuthenticationRequest = { resource: params.resource, completed: new DeferredPromise<void>() };
const request: IAuthenticationRequest = { resource: params.resource, target: params.target, completed: new DeferredPromise<void>() };
this._authenticationRequests.set(key, request);
// Wake replayers only after the replacement request is visible.
previousRequest?.completed.complete();
Expand All @@ -151,7 +155,7 @@ export class AgentHostAuthenticationService extends Disposable implements IAgent
// server bind failure) MUST NOT prevent another provider from
// accepting the same token.
const matching = providerList.filter(
p => p.getProtectedResources().some(r => r.resource === params.resource),
p => !params.target && p.getProtectedResources().some(r => r.resource === params.resource),
);
const settled = await Promise.allSettled(
matching.map(p => p.authenticate(params.resource, params.token, params.expiresIn, params._meta)),
Expand Down Expand Up @@ -201,10 +205,10 @@ export class AgentHostAuthenticationService extends Disposable implements IAgent
this._revocations.set(key, (this._revocations.get(key) ?? 0) + 1);
} else if (authenticated) {
const account = readAuthenticationAccount(params);
this._tokens.set(key, { resource: params.resource, scopes, token: params.token, expiresAt, account });
this._tokens.set(key, { resource: params.resource, scopes, token: params.token, expiresAt, account, ...(params.target ? { target: params.target } : {}) });
}
const token = this._tokens.get(key)?.token;
if (previousToken !== token || authenticationAccountId(previous?.account) !== authenticationAccountId(this._tokens.get(key)?.account)) {
if (!params.target && (previousToken !== token || authenticationAccountId(previous?.account) !== authenticationAccountId(this._tokens.get(key)?.account))) {
this._onDidChangeAuthToken.fire({ resource: params.resource, scopes, token });
}
return { authenticated };
Expand All @@ -213,7 +217,7 @@ export class AgentHostAuthenticationService extends Disposable implements IAgent
async replay(provider: IAgent): Promise<void> {
while (true) {
const pending = [...this._authenticationRequests.values()].filter(request =>
provider.handleAuthenticationToken || provider.getProtectedResources().some(resource => resource.resource === request.resource));
provider.handleAuthenticationToken || (!request.target && provider.getProtectedResources().some(resource => resource.resource === request.resource)));
if (pending.length === 0) {
break;
}
Expand All @@ -232,9 +236,10 @@ export class AgentHostAuthenticationService extends Disposable implements IAgent
const expiresIn = getRemainingTimeInSeconds(stored.expiresAt, now);
const params: AuthenticateParams = {
resource: stored.resource, scopes: stored.scopes, token: stored.token, expiresIn,
...(stored.target ? { target: stored.target } : {}),
...(stored.account ? { _meta: authenticationAccountMeta(stored.account) } : {}),
};
if (protectedResources.has(stored.resource)) {
if (!stored.target && protectedResources.has(stored.resource)) {
try {
await provider.authenticate(stored.resource, stored.token, expiresIn);
} catch (error) {
Expand Down Expand Up @@ -407,7 +412,7 @@ export class AgentHostAuthenticationService extends Disposable implements IAgent
}
const request = { resource: resource.resource, scopes: resource.scopes_supported };
const stored = this._getAuthToken(request, false)
?? [...this._tokens.values()].find(candidate => candidate.resource === resource.resource && !isExpired(candidate.expiresAt));
?? [...this._tokens.values()].find(candidate => !candidate.target && candidate.resource === resource.resource && !isExpired(candidate.expiresAt));
if (!stored) {
if (resource.required !== false) {
throw new ProtocolError(AhpErrorCodes.AuthRequired, 'Local session continuation requires authentication', { resources: [resource] });
Expand Down Expand Up @@ -453,7 +458,7 @@ export class AgentHostAuthenticationService extends Disposable implements IAgent
const requested = new Set(scopes);
let best: IStoredAuthToken | undefined;
for (const candidate of this._tokens.values()) {
if (candidate.resource !== request.resource || candidate.scopes.length === 0 || (!includeExpired && isExpired(candidate.expiresAt))) {
if (candidate.target || candidate.resource !== request.resource || candidate.scopes.length === 0 || (!includeExpired && isExpired(candidate.expiresAt))) {
continue;
}
if (!this._containsAll(candidate.scopes, requested)) {
Expand Down Expand Up @@ -482,8 +487,10 @@ export class AgentHostAuthenticationService extends Disposable implements IAgent
return true;
}

private _key(resource: string, scopes: readonly string[]): string {
return `${resource}\x00${scopes.join('\x00')}`;
private _key(resource: string, scopes: readonly string[], target?: AuthenticateParams['target']): string {
return target
? JSON.stringify([resource, scopes, target.kind, getComparisonKey(URI.parse(target.session)), target.customizationId])
: `${resource}\x00${scopes.join('\x00')}`;
}

}
3 changes: 2 additions & 1 deletion src/vs/platform/agentHost/node/agentHostAutomationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { IAgentHostProviderService } from './agentHostProviderService.js';
import { getModelTelemetryContext } from './agentHostTurnTelemetryContext.js';
import { IAgentHostClientConnectionService } from './agentHostClientConnectionService.js';
import { IAgentHostSystemWakeService } from './agentHostSystemWakeService.js';
import { AuthenticateTargetKind } from '../common/state/sessionProtocol.js';
import { getMcpServerCustomizations } from './shared/mcpCustomizationController.js';
import { McpServerStatus, type McpAuthRequirement } from '../common/state/protocol/channels-session/state.js';
import type { IAgentHostMcpAuthenticationRequest } from '../common/agentHostExtensionProtocol.js';
Expand Down Expand Up @@ -994,7 +995,7 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost
}
attempted.add(key);
challenges.set(server.name, attempted);
void this._requestMcpAuthentication(session, { serverName: server.name, auth });
void this._requestMcpAuthentication(session, { serverName: server.name, auth, target: { kind: AuthenticateTargetKind.McpServer, session, customizationId: server.id } });
}
if (challenges.size > 0) {
this._mcpAuthenticationChallenges.set(session, challenges);
Expand Down
Loading
Loading