diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 9907d4e4da8..997cd916c16 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../base/common/event.js'; +import { IAuthorizationProtectedResourceMetadata } from '../../../base/common/oauth.js'; import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { IActionEnvelope, INotification, ISessionAction } from './state/sessionActions.js'; @@ -39,10 +40,55 @@ export interface IAgentDescriptor { readonly provider: AgentProvider; readonly displayName: string; readonly description: string; - /** Whether the renderer should push a GitHub auth token for this agent. */ + /** + * Whether the renderer should push a GitHub auth token for this agent. + * @deprecated Use {@link IResourceMetadata.resources} from {@link IAgentService.getResourceMetadata} instead. + */ readonly requiresAuth: boolean; } +// ---- Auth types (RFC 9728 / RFC 6750 inspired) ----------------------------- + +/** + * Describes the agent host as an OAuth 2.0 protected resource. + * Uses {@link IAuthorizationProtectedResourceMetadata} from RFC 9728 + * to describe auth requirements, enabling clients to resolve tokens + * using the standard VS Code authentication service. + * + * Returned from the server via {@link IAgentService.getResourceMetadata}. + */ +export interface IResourceMetadata { + /** + * Protected resources the agent host requires authentication for. + * Each entry uses the standard RFC 9728 shape so clients can resolve + * tokens via {@link IAuthenticationService.getOrActivateProviderIdForServer}. + */ + readonly resources: readonly IAuthorizationProtectedResourceMetadata[]; +} + +/** + * Parameters for the `authenticate` command. + * Analogous to sending `Authorization: Bearer ` (RFC 6750 section 2.1). + */ +export interface IAuthenticateParams { + /** + * The `resource` identifier from the server's + * {@link IAuthorizationProtectedResourceMetadata} that this token targets. + */ + readonly resource: string; + + /** The bearer token value (RFC 6750). */ + readonly token: string; +} + +/** + * Result of the `authenticate` command. + */ +export interface IAuthenticateResult { + /** Whether the token was accepted. */ + readonly authenticated: boolean; +} + export interface IAgentCreateSessionConfig { readonly provider?: AgentProvider; readonly model?: string; @@ -300,9 +346,21 @@ export interface IAgent { /** List persisted sessions from this provider. */ listSessions(): Promise; - /** Set the authentication token for this provider. */ + /** + * Set the authentication token for this provider. + * @deprecated Use {@link authenticate} instead. + */ setAuthToken(token: string): Promise; + /** Declare protected resources this agent requires auth for (RFC 9728). */ + getProtectedResources(): IAuthorizationProtectedResourceMetadata[]; + + /** + * Authenticate for a specific resource. Returns true if accepted. + * The `resource` matches {@link IAuthorizationProtectedResourceMetadata.resource}. + */ + authenticate(resource: string, token: string): Promise; + /** Gracefully shut down all sessions. */ shutdown(): Promise; @@ -328,9 +386,24 @@ export interface IAgentService { /** Discover available agent backends from the agent host. */ listAgents(): Promise; - /** Set the GitHub auth token used by the Copilot SDK. */ + /** + * Set the GitHub auth token used by the Copilot SDK. + * @deprecated Use {@link authenticate} instead. + */ setAuthToken(token: string): Promise; + /** + * Retrieve the resource metadata describing auth requirements. + * Modeled on RFC 9728 (OAuth 2.0 Protected Resource Metadata). + */ + getResourceMetadata(): Promise; + + /** + * Authenticate with the server using a specific auth scheme. + * Analogous to sending `Authorization: Bearer ` (RFC 6750). + */ + authenticate(params: IAuthenticateParams): Promise; + /** * Refresh the model list from all providers, publishing updated * agents (with models) to root state via `root/agentsChanged`. diff --git a/src/vs/platform/agentHost/common/state/sessionProtocol.ts b/src/vs/platform/agentHost/common/state/sessionProtocol.ts index e49688b0f44..deca30524f4 100644 --- a/src/vs/platform/agentHost/common/state/sessionProtocol.ts +++ b/src/vs/platform/agentHost/common/state/sessionProtocol.ts @@ -80,6 +80,7 @@ export const AHP_SESSION_ALREADY_EXISTS = -32003 as const; export const AHP_TURN_IN_PROGRESS = -32004 as const; export const AHP_UNSUPPORTED_PROTOCOL_VERSION = -32005 as const; export const AHP_CONTENT_NOT_FOUND = -32006 as const; +export const AHP_AUTH_REQUIRED = -32007 as const; // ---- Type guards ----------------------------------------------------------- @@ -101,9 +102,10 @@ export function isJsonRpcResponse(msg: IProtocolMessage): msg is IAhpSuccessResp /** * Error with a JSON-RPC error code for protocol-level failures. + * Optionally carries a `data` payload for structured error details. */ export class ProtocolError extends Error { - constructor(readonly code: number, message: string) { + constructor(readonly code: number, message: string, readonly data?: unknown) { super(message); } } diff --git a/src/vs/platform/agentHost/electron-browser/agentHostService.ts b/src/vs/platform/agentHost/electron-browser/agentHostService.ts index d0667c84b85..358b5f91eea 100644 --- a/src/vs/platform/agentHost/electron-browser/agentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/agentHostService.ts @@ -13,7 +13,7 @@ import { acquirePort } from '../../../base/parts/ipc/electron-browser/ipc.mp.js' import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentDescriptor, IAgentHostService, IAgentService, IAgentSessionMetadata } from '../common/agentService.js'; +import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentDescriptor, IAgentHostService, IAgentService, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; import type { IBrowseDirectoryResult, IStateSnapshot } from '../common/state/sessionProtocol.js'; import { revive } from '../../../base/common/marshalling.js'; @@ -86,6 +86,12 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { setAuthToken(token: string): Promise { return this._proxy.setAuthToken(token); } + getResourceMetadata(): Promise { + return this._proxy.getResourceMetadata(); + } + authenticate(params: IAuthenticateParams): Promise { + return this._proxy.authenticate(params); + } listAgents(): Promise { return this._proxy.listAgents(); } diff --git a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts index c05b080a7a4..2a151d2a211 100644 --- a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts @@ -14,7 +14,7 @@ import { hasKey } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentDescriptor, IAgentSessionMetadata } from '../common/agentService.js'; +import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentDescriptor, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; import type { IClientNotificationMap, ICommandMap } from '../common/state/protocol/messages.js'; import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; @@ -134,6 +134,20 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC this._sendExtensionNotification('setAuthToken', { token }); } + /** + * Retrieve the server's resource metadata describing auth requirements. + */ + async getResourceMetadata(): Promise { + return await this._sendExtensionRequest('getResourceMetadata') as IResourceMetadata; + } + + /** + * Authenticate with the remote agent host using a specific scheme. + */ + async authenticate(params: IAuthenticateParams): Promise { + return await this._sendExtensionRequest('authenticate', params) as IAuthenticateResult; + } + /** * Refresh the model list from all providers on the remote host. */ diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 498155d598b..e81ae6a408b 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -150,6 +150,12 @@ async function startWebSocketServer(agentService: AgentService, logService: ILog handleSetAuthToken(token) { agentService.setAuthToken(token); }, + handleGetResourceMetadata() { + return agentService.getResourceMetadataSync(); + }, + async handleAuthenticate(params) { + return agentService.authenticate(params); + }, handleBrowseDirectory(uri) { return agentService.browseDirectory(URI.parse(uri)); }, diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 8c1fce8fdd3..86991c636e4 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -9,7 +9,7 @@ import { observableValue } from '../../../base/common/observable.js'; import { URI } from '../../../base/common/uri.js'; import { ILogService } from '../../log/common/log.js'; import { IFileService } from '../../files/common/files.js'; -import { AgentProvider, IAgentCreateSessionConfig, IAgent, IAgentService, IAgentSessionMetadata, AgentSession, IAgentDescriptor } from '../common/agentService.js'; +import { AgentProvider, IAgentCreateSessionConfig, IAgent, IAgentService, IAgentSessionMetadata, AgentSession, IAgentDescriptor, IResourceMetadata, IAuthenticateParams, IAuthenticateResult } from '../common/agentService.js'; import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; import type { IBrowseDirectoryResult, IStateSnapshot } from '../common/state/sessionProtocol.js'; import { SessionStatus, type ISessionSummary } from '../common/state/sessionState.js'; @@ -98,6 +98,30 @@ export class AgentService extends Disposable implements IAgentService { await Promise.all(promises); } + async getResourceMetadata(): Promise { + const resources = [...this._providers.values()].flatMap(p => p.getProtectedResources()); + return { resources }; + } + + getResourceMetadataSync(): IResourceMetadata { + const resources = [...this._providers.values()].flatMap(p => p.getProtectedResources()); + return { resources }; + } + + async authenticate(params: IAuthenticateParams): Promise { + this._logService.trace(`[AgentService] authenticate called: resource=${params.resource}`); + for (const provider of this._providers.values()) { + const resources = provider.getProtectedResources(); + if (resources.some(r => r.resource === params.resource)) { + const accepted = await provider.authenticate(params.resource, params.token); + if (accepted) { + return { authenticated: true }; + } + } + } + return { authenticated: false }; + } + // ---- session management ------------------------------------------------- async listSessions(): Promise { diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 73adeba5a01..be6216f1f2b 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -9,7 +9,7 @@ import { URI } from '../../../base/common/uri.js'; import * as os from 'os'; import { IFileService } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; -import { IAgent, IAgentAttachment } from '../common/agentService.js'; +import { IAgent, IAgentAttachment, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; import type { ISessionAction } from '../common/state/sessionActions.js'; import { IBrowseDirectoryResult, ICreateSessionParams, AHP_PROVIDER_NOT_FOUND, JSON_RPC_INTERNAL_ERROR, ProtocolError, IDirectoryEntry } from '../common/state/sessionProtocol.js'; import { @@ -236,6 +236,24 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH } } + handleGetResourceMetadata(): IResourceMetadata { + const resources = this._options.agents.get().flatMap(a => a.getProtectedResources()); + return { resources }; + } + + async handleAuthenticate(params: IAuthenticateParams): Promise { + for (const agent of this._options.agents.get()) { + const resources = agent.getProtectedResources(); + if (resources.some(r => r.resource === params.resource)) { + const accepted = await agent.authenticate(params.resource, params.token); + if (accepted) { + return { authenticated: true }; + } + } + } + return { authenticated: false }; + } + async handleBrowseDirectory(uri: ProtocolURI): Promise { let stat; try { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 6f8ef0cd5b9..43207af6df5 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -4,18 +4,19 @@ *--------------------------------------------------------------------------------------------*/ import { CopilotClient, CopilotSession, type SessionEvent, type SessionEventPayload } from '@github/copilot-sdk'; +import { rgPath } from '@vscode/ripgrep'; import { DeferredPromise } from '../../../../base/common/async.js'; import { Emitter } from '../../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; import { FileAccess } from '../../../../base/common/network.js'; +import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; import { delimiter, dirname } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; -import { rgPath } from '@vscode/ripgrep'; import { generateUuid } from '../../../../base/common/uuid.js'; import { ILogService } from '../../../log/common/log.js'; -import { IAgentCreateSessionConfig, IAgentModelInfo, IAgentProgressEvent, IAgentMessageEvent, IAgent, IAgentSessionMetadata, IAgentToolStartEvent, IAgentToolCompleteEvent, AgentSession, IAgentDescriptor, IAgentAttachment } from '../../common/agentService.js'; -import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isHiddenTool } from './copilotToolDisplay.js'; +import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentSessionMetadata, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; +import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isHiddenTool } from './copilotToolDisplay.js'; function tryStringify(value: unknown): string | undefined { try { @@ -62,6 +63,15 @@ export class CopilotAgent extends Disposable implements IAgent { }; } + getProtectedResources(): IAuthorizationProtectedResourceMetadata[] { + return [{ + resource: 'https://api.github.com', + resource_name: 'GitHub Copilot', + authorization_servers: ['https://github.com/login/oauth'], + scopes_supported: ['read:user', 'user:email'], + }]; + } + async setAuthToken(token: string): Promise { const tokenChanged = this._githubToken !== token; this._githubToken = token; @@ -75,6 +85,14 @@ export class CopilotAgent extends Disposable implements IAgent { } } + async authenticate(resource: string, token: string): Promise { + if (resource !== 'https://api.github.com') { + return false; + } + await this.setAuthToken(token); + return true; + } + // ---- client lifecycle --------------------------------------------------- private async _ensureClient(): Promise { diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index a2fb3c2d748..f8f747ff0a4 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -5,6 +5,7 @@ import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { ILogService } from '../../log/common/log.js'; +import type { IAgentDescriptor, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; import type { ICommandMap } from '../common/state/protocol/messages.js'; import { IActionEnvelope, INotification, isSessionAction, type ISessionAction } from '../common/state/sessionActions.js'; import { isActionKnownToVersion, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; @@ -37,8 +38,8 @@ function jsonRpcSuccess(id: number, result: unknown): IJsonRpcResponse { } /** Build a JSON-RPC error response suitable for transport.send(). */ -function jsonRpcError(id: number, code: number, message: string): IJsonRpcResponse { - return { jsonrpc: '2.0', id, error: { code, message } }; +function jsonRpcError(id: number, code: number, message: string, data?: unknown): IJsonRpcResponse { + return { jsonrpc: '2.0', id, error: { code, message, ...(data !== undefined ? { data } : {}) } }; } /** @@ -336,23 +337,59 @@ export class ProtocolServerHandler extends Disposable { private _handleRequest(client: IConnectedClient, method: string, params: unknown, id: number): void { const handler = this._requestHandlers.hasOwnProperty(method) ? this._requestHandlers[method as RequestMethod] : undefined; - if (!handler) { - client.transport.send(jsonRpcError(id, JSON_RPC_INTERNAL_ERROR, `Unknown method: ${method}`)); + if (handler) { + (handler as (client: IConnectedClient, params: unknown) => Promise)(client, params).then(result => { + this._logService.trace(`[ProtocolServer] Request '${method}' id=${id} succeeded`); + client.transport.send(jsonRpcSuccess(id, result ?? null)); + }).catch(err => { + this._logService.error(`[ProtocolServer] Request '${method}' failed`, err); + const code = err instanceof ProtocolError ? err.code : JSON_RPC_INTERNAL_ERROR; + const data = err instanceof ProtocolError ? err.data : undefined; + const message = err instanceof ProtocolError + ? err.message + : err instanceof Error && err.stack + ? err.stack + : String(err?.message ?? err); + client.transport.send(jsonRpcError(id, code, message, data)); + }); return; } - (handler as (client: IConnectedClient, params: unknown) => Promise)(client, params).then(result => { - this._logService.trace(`[ProtocolServer] Request '${method}' id=${id} succeeded`); - client.transport.send(jsonRpcSuccess(id, result ?? null)); - }).catch(err => { - this._logService.error(`[ProtocolServer] Request '${method}' failed`, err); - const code = err instanceof ProtocolError ? err.code : JSON_RPC_INTERNAL_ERROR; - const message = err instanceof ProtocolError - ? err.message - : err instanceof Error && err.stack - ? err.stack - : String(err?.message ?? err); - client.transport.send(jsonRpcError(id, code, message)); - }); + + // VS Code extension methods (not in the typed protocol maps yet) + const extensionResult = this._handleExtensionRequest(method, params); + if (extensionResult) { + extensionResult.then(result => { + client.transport.send(jsonRpcSuccess(id, result ?? null)); + }).catch(err => { + this._logService.error(`[ProtocolServer] Extension request '${method}' failed`, err); + client.transport.send(jsonRpcError(id, JSON_RPC_INTERNAL_ERROR, String(err?.message ?? err))); + }); + return; + } + + client.transport.send(jsonRpcError(id, JSON_RPC_INTERNAL_ERROR, `Unknown method: ${method}`)); + } + + /** + * Handle VS Code extension methods that are not yet part of the typed + * protocol. Returns a Promise if the method was recognized, undefined + * otherwise. + */ + private _handleExtensionRequest(method: string, params: unknown): Promise | undefined { + switch (method) { + case 'getResourceMetadata': + return Promise.resolve(this._sideEffectHandler.handleGetResourceMetadata()); + case 'authenticate': + return this._sideEffectHandler.handleAuthenticate(params as IAuthenticateParams); + case 'refreshModels': + return this._sideEffectHandler.handleRefreshModels?.() ?? Promise.resolve(null); + case 'listAgents': + return Promise.resolve(this._sideEffectHandler.handleListAgents?.() ?? []); + case 'shutdown': + return this._sideEffectHandler.handleShutdown?.() ?? Promise.resolve(null); + default: + return undefined; + } } // ---- Broadcasting ------------------------------------------------------- @@ -408,7 +445,15 @@ export interface IProtocolSideEffectHandler { handleDisposeSession(session: URI): void; handleListSessions(): Promise; handleSetAuthToken(token: string): void; + handleGetResourceMetadata(): IResourceMetadata; + handleAuthenticate(params: IAuthenticateParams): Promise; handleBrowseDirectory(uri: URI): Promise; /** Returns the server's default browsing directory, if available. */ getDefaultDirectory?(): URI; + /** Refresh models from all providers (VS Code extension method). */ + handleRefreshModels?(): Promise; + /** List agent descriptors (VS Code extension method). */ + handleListAgents?(): IAgentDescriptor[]; + /** Shut down all providers (VS Code extension method). */ + handleShutdown?(): Promise; } diff --git a/src/vs/platform/agentHost/test/auth-rework.md b/src/vs/platform/agentHost/test/auth-rework.md new file mode 100644 index 00000000000..eb1f67709bc --- /dev/null +++ b/src/vs/platform/agentHost/test/auth-rework.md @@ -0,0 +1,454 @@ +# Auth Rework: Standards-Based Authentication for the Agent Host Protocol + +## Problem + +The current authentication mechanism is imperative and VS Code-specific: + +1. The renderer discovers agents via `listAgents()` and checks `IAgentDescriptor.requiresAuth`. +2. It obtains a GitHub OAuth token from VS Code's built-in authentication service. +3. It pushes the token via `setAuthToken(token)` — a fire-and-forget JSON-RPC notification. +4. The agent host fans the token out to all registered `IAgent` providers. + +This couples the agent host to VS Code internals. An external client (CLI tool, web app, another editor) connecting over WebSocket has no way to know _what_ authentication is required, _where_ to get a token, or _what scopes_ are needed. The client must have out-of-band knowledge that "this server needs a GitHub OAuth token." + +## Design Goals + +- **Self-describing**: The server declares its auth requirements so arbitrary clients can discover them without prior knowledge of the server's internals. +- **Standards-aligned**: Use the semantics and vocabulary of RFC 6750 (Bearer Token Usage) and RFC 9728 (OAuth 2.0 Protected Resource Metadata) adapted for JSON-RPC. +- **Challenge-on-failure**: When auth is missing or invalid, the server responds with a structured challenge (like `WWW-Authenticate`) that tells the client exactly what to do. +- **Transport-agnostic**: Works over WebSocket JSON-RPC and MessagePort IPC alike. +- **Multi-provider**: Supports multiple independent auth requirements (e.g. GitHub + a future enterprise IdP) each with their own scopes and authorization servers. +- **Non-breaking migration**: Can coexist with `setAuthToken` during a transition period. + +## Relevant Standards + +### RFC 6750 — Bearer Token Usage + +Defines how bearer tokens are transmitted (`Authorization: Bearer `) and how servers challenge clients when auth is missing or invalid: + +``` +WWW-Authenticate: Bearer realm="example", + error="invalid_token", + error_description="The access token expired" +``` + +Key error codes: `invalid_request`, `invalid_token`, `insufficient_scope`. + +### RFC 9728 — OAuth 2.0 Protected Resource Metadata + +Defines a metadata document that a protected resource publishes to describe itself: + +```json +{ + "resource": "https://resource.example.com", + "authorization_servers": ["https://as.example.com"], + "scopes_supported": ["profile", "email"], + "bearer_methods_supported": ["header"] +} +``` + +Clients discover this metadata either via a well-known URL or via the `resource_metadata` parameter in a `WWW-Authenticate` challenge. This tells the client _where_ to get a token and _what scopes_ to request. + +## Proposed Design + +### Overview + +The authentication flow has three phases, mirroring the HTTP flow from RFC 9728 §5: + +``` +┌─────────┐ ┌──────────────┐ ┌─────────────────┐ +│ Client │ │ Agent Host │ │ Authorization │ +│ │ │ (Server) │ │ Server │ +└────┬─────┘ └──────┬───────┘ └────────┬────────┘ + │ │ │ + │ 1. initialize │ │ + │ ───────────────────────────────────> │ │ + │ │ │ + │ 2. initialize result │ │ + │ { auth: [{ scheme, resource, │ │ + │ authorization_servers, │ │ + │ scopes_supported }] } │ │ + │ <─────────────────────────────────── │ │ + │ │ │ + │ 3. Obtain token from AS │ │ + │ ─────────────────────────────────────────────────────────────────> │ + │ │ │ + │ 4. Token │ │ + │ <───────────────────────────────────────────────────────────────── │ + │ │ │ + │ 5. authenticate { scheme, token } │ │ + │ ───────────────────────────────────> │ │ + │ │ │ + │ 6. { authenticated: true } │ │ + │ <─────────────────────────────────── │ │ + │ │ │ + │ 7. createSession / other commands │ │ + │ ───────────────────────────────────> │ │ +``` + +### Phase 1: Discovery (in `initialize` response) + +The `initialize` result is extended with a `resourceMetadata` field, modeled on RFC 9728 §2: + +```typescript +interface IInitializeResult { + protocolVersion: number; + serverSeq: number; + snapshots: ISnapshot[]; + defaultDirectory?: URI; + + /** RFC 9728-style resource metadata describing auth requirements. */ + resourceMetadata?: IResourceMetadata; +} + +/** + * Describes the agent host as an OAuth 2.0 protected resource. + * Modeled on RFC 9728 (OAuth 2.0 Protected Resource Metadata). + */ +interface IResourceMetadata { + /** + * Identifier for this resource (the agent host). + * Analogous to RFC 9728 `resource`. + */ + resource: string; + + /** + * Independent auth requirements. Each entry describes one + * authentication scheme the server accepts. A client must + * satisfy at least one to use authenticated features. + */ + authSchemes: IAuthScheme[]; +} + +/** + * A single authentication scheme the server accepts. + */ +interface IAuthScheme { + /** + * The auth scheme name. Initially only "bearer" (RFC 6750). + * Future schemes (e.g. "dpop", "device_code") can be added. + */ + scheme: 'bearer'; + + /** + * An opaque identifier for this auth requirement, used to + * correlate `authenticate` calls and challenges. Allows the + * server to require multiple independent tokens (e.g. one + * per agent provider). + * + * Example: "github" for GitHub Copilot auth. + */ + id: string; + + /** + * Human-readable label for display in auth UIs. + * Analogous to RFC 9728 `resource_name`. + */ + label: string; + + /** + * Authorization server issuer identifiers (RFC 8414). + * Tells the client where to obtain tokens. + * Analogous to RFC 9728 `authorization_servers`. + * + * Example: ["https://github.com/login/oauth"] + */ + authorizationServers: string[]; + + /** + * OAuth scopes the server needs. + * Analogous to RFC 9728 `scopes_supported`. + * + * Example: ["read:user", "user:email", "repo", "workflow"] + */ + scopesSupported?: string[]; + + /** + * Whether this auth requirement is mandatory for any + * functionality, or only for specific agents/features. + */ + required?: boolean; +} +``` + +**Why in `initialize`?** RFC 9728 publishes metadata at a well-known URL. In our JSON-RPC world, the `initialize` handshake _is_ the well-known endpoint — it's the first thing every client calls, and it's already where we exchange capabilities. This avoids an extra round-trip and keeps the discovery atomic. + +### Phase 2: Token Delivery (`authenticate` command) + +Replace the fire-and-forget `setAuthToken` notification with a proper JSON-RPC **request** so the client gets confirmation: + +```typescript +/** + * Client → Server request to authenticate. + * Analogous to sending `Authorization: Bearer ` (RFC 6750 §2.1). + */ +interface IAuthenticateParams { + /** + * The auth scheme identifier from the server's resourceMetadata. + * Correlates to IAuthScheme.id. + */ + schemeId: string; + + /** The scheme type (initially always "bearer"). */ + scheme: 'bearer'; + + /** The bearer token value (RFC 6750). */ + token: string; +} + +interface IAuthenticateResult { + /** Whether the token was accepted. */ + authenticated: boolean; +} +``` + +This is a **request** (not a notification) so: +- The client knows immediately if the token was accepted or rejected. +- The server can validate the token before returning success. +- Errors use structured challenges (see Phase 3). + +The client can call `authenticate` multiple times (e.g. when a token is refreshed), and can authenticate for multiple scheme IDs independently. + +### Phase 3: Challenges on Failure + +When a command fails because authentication is missing or invalid, the server returns a JSON-RPC error with structured challenge data in the `data` field, modeled on RFC 6750 §3: + +```typescript +/** + * JSON-RPC error data for authentication failures. + * Modeled on RFC 6750 WWW-Authenticate challenge parameters. + */ +interface IAuthChallenge { + /** The scheme ID that needs (re-)authentication. */ + schemeId: string; + + /** RFC 6750 §3.1 error code. */ + error: 'invalid_request' | 'invalid_token' | 'insufficient_scope'; + + /** Human-readable error description (RFC 6750 §3 error_description). */ + errorDescription?: string; + + /** Required scopes, if the error is insufficient_scope (RFC 6750 §3 scope). */ + scope?: string; +} +``` + +This is returned as the `data` payload of a JSON-RPC error response: + +```json +{ + "jsonrpc": "2.0", + "id": 5, + "error": { + "code": -32010, + "message": "Authentication required", + "data": { + "challenges": [ + { + "schemeId": "github", + "error": "invalid_token", + "errorDescription": "The access token expired" + } + ] + } + } +} +``` + +A dedicated error code (e.g. `-32010 AHP_AUTH_REQUIRED`) signals this is an auth error so clients can handle it programmatically without parsing the message string. + +### Phase 4: Auth State Notifications + +The server pushes auth state changes via notifications so clients know when auth expires or the required scopes change: + +```typescript +/** + * Server → Client notification when auth state changes. + */ +interface IAuthStateNotification { + type: 'notify/authRequired'; + + /** The scheme ID whose auth state changed. */ + schemeId: string; + + /** The new state. */ + state: 'authenticated' | 'expired' | 'revoked' | 'required'; + + /** Optional challenge with details (e.g. new scopes needed). */ + challenge?: IAuthChallenge; +} +``` + +This replaces the implicit "push a token whenever you see an account change" model with an explicit server-driven signal. + +## Concrete Example: GitHub Copilot Auth + +### Server-side (CopilotAgent) + +When the Copilot agent registers, it publishes an auth scheme: + +```typescript +// In CopilotAgent.getAuthSchemes(): +[{ + scheme: 'bearer', + id: 'github', + label: 'GitHub', + authorizationServers: ['https://github.com/login/oauth'], + scopesSupported: ['read:user', 'user:email'], + required: true, +}] +``` + +The agent host aggregates auth schemes from all agents into `IInitializeResult.resourceMetadata`. + +### Client-side (VS Code renderer) + +```typescript +// After initialize: +const metadata = initResult.resourceMetadata; +if (metadata) { + for (const scheme of metadata.authSchemes) { + if (scheme.scheme === 'bearer' && scheme.authorizationServers.some( + as => as.includes('github.com') + )) { + // We know how to handle GitHub auth + const token = await this._getGitHubToken(scheme.scopesSupported); + await agentHostService.authenticate({ + schemeId: scheme.id, + scheme: 'bearer', + token, + }); + } + } +} +``` + +### Client-side (generic external client) + +A CLI tool connecting over WebSocket: + +```typescript +const ws = new WebSocket('ws://localhost:3000'); +const initResult = await rpc.request('initialize', { protocolVersion: 1, clientId: 'cli-1' }); + +for (const scheme of initResult.resourceMetadata?.authSchemes ?? []) { + if (scheme.scheme === 'bearer') { + console.log(`Auth required: ${scheme.label}`); + console.log(`Get a token from: ${scheme.authorizationServers[0]}`); + console.log(`Scopes: ${scheme.scopesSupported?.join(', ')}`); + + // Client can use any OAuth library to get the token + const token = await doOAuthFlow(scheme.authorizationServers[0], scheme.scopesSupported); + await rpc.request('authenticate', { schemeId: scheme.id, scheme: 'bearer', token }); + } +} +``` + +## Protocol Changes Summary + +### New JSON-RPC request: `authenticate` + +| Direction | Type | Params | Result | +|---|---|---|---| +| Client → Server | Request | `IAuthenticateParams` | `IAuthenticateResult` | + +### New JSON-RPC error code + +| Code | Name | When | +|---|---|---| +| `-32010` | `AHP_AUTH_REQUIRED` | A command failed because auth is missing or invalid | + +### Extended: `initialize` result + +| Field | Type | Description | +|---|---|---| +| `resourceMetadata` | `IResourceMetadata` | Optional. Auth and resource information. | + +### New notification + +| Type | Direction | When | +|---|---|---| +| `notify/authRequired` | Server → Client | Auth state changed (expired, revoked, new requirements) | + +### Deprecated + +| Item | Replacement | Migration | +|---|---|---| +| `setAuthToken` notification | `authenticate` request | Keep accepting `setAuthToken` for one version, log deprecation | +| `IAgentDescriptor.requiresAuth` | `IResourceMetadata.authSchemes` | Derive from `authSchemes` during transition | + +## Interface Changes in `agentService.ts` + +### `IAgentService` + +```diff + interface IAgentService { +- setAuthToken(token: string): Promise; ++ authenticate(params: IAuthenticateParams): Promise; + } +``` + +### `IAgent` + +```diff + interface IAgent { +- setAuthToken(token: string): Promise; ++ /** Declare auth schemes this agent requires. */ ++ getAuthSchemes(): IAuthScheme[]; ++ /** Authenticate with a specific scheme. Returns true if accepted. */ ++ authenticate(schemeId: string, token: string): Promise; + } +``` + +### `IAgentDescriptor` + +```diff + interface IAgentDescriptor { + readonly provider: AgentProvider; + readonly displayName: string; + readonly description: string; +- readonly requiresAuth: boolean; + } +``` + +`requiresAuth` is removed — clients discover auth requirements from `IResourceMetadata` instead of per-agent descriptors. + +## Design Decisions + +### Why not `WWW-Authenticate` headers literally? + +We're not using HTTP. Embedding RFC 6750's string-encoded header format in JSON-RPC would be awkward. Instead, we use JSON-native equivalents with the same semantics: `IAuthChallenge` mirrors the `WWW-Authenticate` parameters, and `IResourceMetadata` mirrors RFC 9728's metadata document. + +### Why in `initialize` and not a separate `getResourceMetadata` command? + +Fewer round-trips. Every client calls `initialize` first — embedding auth requirements there means the client knows what auth is needed from the very first response. A separate command would add latency and complexity for zero benefit, since the metadata is small and always needed. + +### Why `schemeId` and not just the `scheme` name? + +A server might need multiple bearer tokens from different authorization servers (e.g. GitHub + an enterprise IdP). The `schemeId` lets the client and server correlate tokens to specific requirements. It also makes `authenticate` calls idempotent and unambiguous. + +### Why a request instead of a notification for `authenticate`? + +The current `setAuthToken` is fire-and-forget — the client has no idea if the token was accepted, expired, or for the wrong provider. Making `authenticate` a request with a response lets the client react immediately (retry with different scopes, prompt the user, etc.). + +### What about Device Code / OAuth flows that the server drives? + +This proposal covers the "client already has a token" case (RFC 6750 bearer). For server-driven flows (device code, authorization code with redirect), the `authorizationServers` metadata tells the client which AS to talk to. The actual OAuth flow is client-side — the server just declares requirements. + +A future extension could add an `IAuthScheme` with `scheme: 'device_code'` that includes a device authorization endpoint, letting the server guide the client through a device flow. This is out of scope for the initial implementation. + +## Migration Plan + +1. **Phase A**: Add `resourceMetadata` to `IInitializeResult` and the `authenticate` command. Keep `setAuthToken` working as-is. +2. **Phase B**: Update VS Code renderer to use `authenticate` instead of `setAuthToken`. External clients can start using the new flow. +3. **Phase C**: Remove `setAuthToken`, `requiresAuth`, and the old imperative push model. Bump protocol version. + +## Open Questions + +1. **Token validation**: Should the server validate tokens eagerly on `authenticate` (e.g. call a GitHub API endpoint), or defer validation to when a command actually needs it? Eager validation gives better error messages; deferred is simpler and avoids extra network calls. + +2. **Per-agent vs. global auth**: The current design has one `resourceMetadata` for the whole server. Should auth schemes be per-agent-provider instead? Per-agent gives finer control (e.g. "Copilot needs GitHub, MockAgent needs nothing") but complicates the protocol. The current proposal uses global metadata with `schemeId` correlation, which the server can internally route to the right agent. + +3. **Token refresh**: Should the server expose token expiry information so clients can proactively refresh, or rely on `notify/authRequired` to signal when a refresh is needed? Proactive refresh avoids interruptions but requires the server to parse tokens (which it shouldn't have to for opaque tokens). + +4. **Multiple tokens**: Can a client authenticate multiple scheme IDs simultaneously? (Proposed: yes.) Can multiple clients each send their own token? (Proposed: yes, last-writer-wins per schemeId, which matches current behavior.) diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 6ddf3ac28c3..c7aabaf8e79 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -5,6 +5,7 @@ import { Emitter } from '../../../../base/common/event.js'; import { URI } from '../../../../base/common/uri.js'; +import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; import { AgentSession, type AgentProvider, type IAgent, type IAgentAttachment, type IAgentCreateSessionConfig, type IAgentDescriptor, type IAgentMessageEvent, type IAgentModelInfo, type IAgentProgressEvent, type IAgentSessionMetadata, type IAgentToolCompleteEvent, type IAgentToolStartEvent } from '../../common/agentService.js'; /** @@ -24,6 +25,7 @@ export class MockAgent implements IAgent { readonly abortSessionCalls: URI[] = []; readonly respondToPermissionCalls: { requestId: string; approved: boolean }[] = []; readonly changeModelCalls: { session: URI; model: string }[] = []; + readonly authenticateCalls: { resource: string; token: string }[] = []; constructor(readonly id: AgentProvider = 'mock') { } @@ -31,6 +33,13 @@ export class MockAgent implements IAgent { return { provider: this.id, displayName: `Agent ${this.id}`, description: `Test ${this.id} agent`, requiresAuth: this.id === 'copilot' }; } + getProtectedResources(): IAuthorizationProtectedResourceMetadata[] { + if (this.id === 'copilot') { + return [{ resource: 'https://api.github.com', authorization_servers: ['https://github.com/login/oauth'] }]; + } + return []; + } + async listModels(): Promise { return [{ provider: this.id, id: `${this.id}-model`, name: `${this.id} Model`, maxContextWindow: 128000, supportsVision: false, supportsReasoningEffort: false }]; } @@ -75,6 +84,11 @@ export class MockAgent implements IAgent { this.setAuthTokenCalls.push(token); } + async authenticate(resource: string, token: string): Promise { + this.authenticateCalls.push({ resource, token }); + return true; + } + async shutdown(): Promise { } fireProgress(event: IAgentProgressEvent): void { @@ -104,6 +118,10 @@ export class ScriptedMockAgent implements IAgent { return { provider: 'mock', displayName: 'Mock Agent', description: 'Scripted test agent', requiresAuth: false }; } + getProtectedResources(): IAuthorizationProtectedResourceMetadata[] { + return []; + } + async listModels(): Promise { return [{ provider: 'mock', id: 'mock-model', name: 'Mock Model', maxContextWindow: 128000, supportsVision: false, supportsReasoningEffort: false }]; } @@ -225,6 +243,10 @@ export class ScriptedMockAgent implements IAgent { async setAuthToken(_token: string): Promise { } + async authenticate(_resource: string, _token: string): Promise { + return true; + } + async shutdown(): Promise { } dispose(): void { diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 3c6a3b83fed..74591f923ba 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -75,6 +75,8 @@ class MockSideEffectHandler implements IProtocolSideEffectHandler { handleDisposeSession(_session: string): void { } async handleListSessions(): Promise { return []; } handleSetAuthToken(_token: string): void { } + handleGetResourceMetadata() { return { resources: [] }; } + async handleAuthenticate(_params: { resource: string; token: string }) { return { authenticated: true }; } async handleBrowseDirectory(uri: string): Promise<{ entries: { name: string; type: 'file' | 'directory' }[] }> { this.browsedUris.push(URI.parse(uri)); const error = this.browseErrors.get(uri); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index e4eca99f515..a7cd17bbeb2 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -179,8 +179,8 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc this._logService.error(`[RemoteAgentHost] Failed to subscribe to root state for ${address}`, err); }); - // Push auth token to this new connection - this._pushAuthToken(connection); + // Authenticate with this new connection + this._authenticateWithConnection(connection); } private _handleRootStateChange(address: string, connection: IAgentConnection, rootState: IRootState): void { @@ -282,6 +282,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc extensionId: 'vscode.remote-agent-host', extensionDisplayName: 'Remote Agent Host', resolveWorkingDirectory, + resolveAuthentication: () => this._resolveAuthenticationInteractively(connection), })); agentStore.add(this._chatSessionsService.registerChatSessionContentProvider(sessionType, sessionHandler)); @@ -302,26 +303,93 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc for (const address of this._connections.keys()) { const connection = this._remoteAgentHostService.getConnection(address); if (connection) { - this._pushAuthToken(connection); + this._authenticateWithConnection(connection); } } } - private async _pushAuthToken(connection: IAgentConnection): Promise { + /** + * Discover auth requirements from the connection's resource metadata + * and authenticate using matching tokens resolved via the standard + * VS Code authentication service (same flow as MCP auth). + */ + private async _authenticateWithConnection(connection: IAgentConnection): Promise { try { - const account = await this._defaultAccountService.getDefaultAccount(); - if (!account) { - return; + const metadata = await connection.getResourceMetadata(); + for (const resource of metadata.resources) { + const token = await this._resolveTokenForResource(resource.authorization_servers ?? [], resource.scopes_supported ?? []); + if (token) { + this._logService.info(`[RemoteAgentHost] Authenticating for resource: ${resource.resource}`); + await connection.authenticate({ resource: resource.resource, token }); + } else { + this._logService.info(`[RemoteAgentHost] No token resolved for resource: ${resource.resource}`); + } + } + } catch (err) { + this._logService.error('[RemoteAgentHost] Failed to authenticate with connection', err); + } + } + + /** + * Resolve a bearer token for a set of authorization servers using the + * standard VS Code authentication service provider resolution. + */ + private async _resolveTokenForResource(authorizationServers: readonly string[], scopes: readonly string[]): Promise { + for (const server of authorizationServers) { + const serverUri = URI.parse(server); + const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri); + if (!providerId) { + this._logService.trace(`[RemoteAgentHost] No auth provider found for server: ${server}`); + continue; } - const sessions = await this._authenticationService.getSessions(account.authenticationProvider.id); - const session = sessions.find(s => s.id === account.sessionId); - if (session) { - await connection.setAuthToken(session.accessToken); + const sessions = await this._authenticationService.getSessions(providerId, [...scopes], { authorizationServer: serverUri }, true); + if (sessions.length > 0) { + return sessions[0].accessToken; + } + + // Fall back to any session from the provider + const anySessions = await this._authenticationService.getSessions(providerId); + if (anySessions.length > 0) { + return anySessions[0].accessToken; } - } catch { - // best-effort } + return undefined; + } + + /** + * Interactively prompt the user to authenticate when the server requires it. + * Returns true if authentication succeeded. + */ + private async _resolveAuthenticationInteractively(connection: IAgentConnection): Promise { + try { + const metadata = await connection.getResourceMetadata(); + for (const resource of metadata.resources) { + for (const server of resource.authorization_servers ?? []) { + const serverUri = URI.parse(server); + const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri); + if (!providerId) { + continue; + } + + const scopes = [...(resource.scopes_supported ?? [])]; + const session = await this._authenticationService.createSession(providerId, scopes, { + activateImmediate: true, + authorizationServer: serverUri, + }); + + await connection.authenticate({ + resource: resource.resource, + token: session.accessToken, + }); + this._logService.info(`[RemoteAgentHost] Interactive authentication succeeded for ${resource.resource}`); + return true; + } + } + } catch (err) { + this._logService.error('[RemoteAgentHost] Interactive authentication failed', err); + } + return false; } private _traceIpc(address: string, method: string, data?: unknown): void { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index 28130358707..e860b721fbc 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -219,6 +219,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr fullName: agent.displayName, description: agent.description, connection: this._agentHostService, + resolveAuthentication: () => this._resolveAuthenticationInteractively(), })); store.add(this._chatSessionsService.registerChatSessionContentProvider(sessionType, sessionHandler)); @@ -233,27 +234,104 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr store.add(this._languageModelsService.registerLanguageModelProvider(vendor, modelProvider)); // Push auth token and refresh models from server - this._pushAuthToken().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }); + this._authenticateWithServer().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }); store.add(this._defaultAccountService.onDidChangeDefaultAccount(() => - this._pushAuthToken().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }))); + this._authenticateWithServer().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }))); store.add(this._authenticationService.onDidChangeSessions(() => - this._pushAuthToken().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }))); + this._authenticateWithServer().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }))); } - private async _pushAuthToken(): Promise { + /** + * Discover auth requirements from the server's resource metadata + * and authenticate using matching tokens resolved via the standard + * VS Code authentication service (same flow as MCP auth). + */ + private async _authenticateWithServer(): Promise { try { - const account = await this._defaultAccountService.getDefaultAccount(); - if (!account) { - return; + const metadata = await this._agentHostService.getResourceMetadata(); + this._logService.trace(`[AgentHost] Resource metadata: ${metadata.resources.length} resource(s)`); + for (const resource of metadata.resources) { + const token = await this._resolveTokenForResource(resource.authorization_servers ?? [], resource.scopes_supported ?? []); + if (token) { + this._logService.info(`[AgentHost] Authenticating for resource: ${resource.resource}`); + await this._agentHostService.authenticate({ resource: resource.resource, token }); + } else { + this._logService.info(`[AgentHost] No token resolved for resource: ${resource.resource}`); + } } - - const sessions = await this._authenticationService.getSessions(account.authenticationProvider.id); - const session = sessions.find(s => s.id === account.sessionId); - if (session) { - await this._agentHostService.setAuthToken(session.accessToken); - } - } catch { - // best-effort + } catch (err) { + this._logService.error('[AgentHost] Failed to authenticate with server', err); } } + + /** + * Resolve a bearer token for a set of authorization servers using the + * standard VS Code authentication service provider resolution. + */ + private async _resolveTokenForResource(authorizationServers: readonly string[], scopes: readonly string[]): Promise { + for (const server of authorizationServers) { + const serverUri = URI.parse(server); + const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri); + if (!providerId) { + this._logService.trace(`[AgentHost] No auth provider found for server: ${server}`); + continue; + } + this._logService.trace(`[AgentHost] Resolved auth provider '${providerId}' for server: ${server}`); + + // Try with the declared scopes first, then fall back to empty scopes + // (the provider may have sessions with broader scopes). + const sessions = await this._authenticationService.getSessions(providerId, [...scopes], { authorizationServer: serverUri }, true); + if (sessions.length > 0) { + return sessions[0].accessToken; + } + + // Fall back to any session from the provider + const anySessions = await this._authenticationService.getSessions(providerId); + if (anySessions.length > 0) { + this._logService.trace(`[AgentHost] Using session with broader scopes from provider '${providerId}'`); + return anySessions[0].accessToken; + } + + this._logService.trace(`[AgentHost] No sessions found for provider '${providerId}'`); + } + return undefined; + } + + /** + * Interactively prompt the user to authenticate when the server requires it. + * Fetches resource metadata, resolves the auth provider, creates a session + * (which triggers the login UI), and pushes the token to the server. + * Returns true if authentication succeeded. + */ + private async _resolveAuthenticationInteractively(): Promise { + try { + const metadata = await this._agentHostService.getResourceMetadata(); + for (const resource of metadata.resources) { + for (const server of resource.authorization_servers ?? []) { + const serverUri = URI.parse(server); + const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri); + if (!providerId) { + continue; + } + + // createSession will show the login UI if no session exists + const scopes = [...(resource.scopes_supported ?? [])]; + const session = await this._authenticationService.createSession(providerId, scopes, { + activateImmediate: true, + authorizationServer: serverUri, + }); + + await this._agentHostService.authenticate({ + resource: resource.resource, + token: session.accessToken, + }); + this._logService.info(`[AgentHost] Interactive authentication succeeded for ${resource.resource}`); + return true; + } + } + } catch (err) { + this._logService.error('[AgentHost] Interactive authentication failed', err); + } + return false; + } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index a1ddeacc81b..d2edaccb893 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -96,6 +96,12 @@ export interface IAgentHostSessionHandlerConfig { * If not provided, falls back to the first workspace folder. */ readonly resolveWorkingDirectory?: (resourceKey: string) => string | undefined; + /** + * Optional callback invoked when the server rejects an operation because + * authentication is required. Should trigger interactive authentication + * and return true if the user authenticated successfully. + */ + readonly resolveAuthentication?: () => Promise; } export class AgentHostSessionHandler extends Disposable implements IChatSessionContentProvider { @@ -442,11 +448,33 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC ?? this._workspaceContextService.getWorkspace().folders[0]?.uri.fsPath; this._logService.trace(`[AgentHost] Creating new session, model=${rawModelId ?? '(default)'}, provider=${this._config.provider}`); - const session = await this._config.connection.createSession({ - model: rawModelId, - provider: this._config.provider, - workingDirectory, - }); + + let session: URI; + try { + session = await this._config.connection.createSession({ + model: rawModelId, + provider: this._config.provider, + workingDirectory, + }); + } catch (err) { + // If authentication is required, try to resolve it and retry once + if (this._isAuthRequiredError(err) && this._config.resolveAuthentication) { + this._logService.info('[AgentHost] Authentication required, prompting user...'); + const authenticated = await this._config.resolveAuthentication(); + if (authenticated) { + session = await this._config.connection.createSession({ + model: rawModelId, + provider: this._config.provider, + workingDirectory, + }); + } else { + throw new Error('Authentication is required to start a session. Please sign in and try again.'); + } + } else { + throw err; + } + } + this._logService.trace(`[AgentHost] Created session: ${session.toString()}`); // Subscribe to the new session's state @@ -460,6 +488,17 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return session; } + /** + * Check if an error is an "authentication required" error. + * Works across both ProxyChannel (message-only) and WebSocket (structured) paths. + */ + private _isAuthRequiredError(err: unknown): boolean { + if (err instanceof Error && err.message.includes('Authentication required')) { + return true; + } + return false; + } + /** * Extracts the raw model id from a language-model service identifier. * E.g. "agent-host-copilot:claude-sonnet-4-20250514" → "claude-sonnet-4-20250514".