Path: blob/main/src/vs/sessions/contrib/remoteAgentHost/browser/webTunnelAgentHostService.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Emitter, Event } from '../../../../base/common/event.js';6import { Disposable } from '../../../../base/common/lifecycle.js';7import { RemoteAgentHostProtocolClient } from '../../../../platform/agentHost/browser/remoteAgentHostProtocolClient.js';8import { RemoteAgentHostEntryType, IRemoteAgentHostService, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js';9import type { IProtocolTransport } from '../../../../platform/agentHost/common/state/sessionTransport.js';10import type { ProtocolMessage, AhpServerNotification, JsonRpcResponse } from '../../../../platform/agentHost/common/state/sessionProtocol.js';11import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from '../../../../platform/agentHost/common/transportConstants.js';12import {13ITunnelAgentHostService,14TUNNEL_ADDRESS_PREFIX,15TUNNEL_MIN_PROTOCOL_VERSION,16TunnelTags,17type ICachedTunnel,18type ITunnelInfo,19} from '../../../../platform/agentHost/common/tunnelAgentHost.js';20import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';21import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';22import { ILogService } from '../../../../platform/log/common/log.js';23import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';24import type { IDiscoveredTunnel, ITunnelConnection, ITunnelDiscoveryProvider } from '../../../../workbench/browser/web.api.js';25import { IBrowserWorkbenchEnvironmentService } from '../../../../workbench/services/environment/browser/environmentService.js';26import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js';2728const LOG_PREFIX = '[WebTunnelAgentHost]';2930/** Storage key for recently used tunnel cache. */31const CACHED_TUNNELS_KEY = 'tunnelAgentHost.recentTunnels';3233/**34* Web (browser) implementation of {@link ITunnelAgentHostService}.35*36* Delegates to the embedder's {@link ITunnelDiscoveryProvider} (provided via37* `IWorkbenchConstructionOptions.tunnelDiscoveryProvider`) for:38* - **Discovery**: listing available agent host tunnels39* - **Relay address**: obtaining the WebSocket proxy URL for connecting40*41* This decouples VS Code core from any specific embedder (vscode.dev,42* github.dev, etc.). The embedder handles the actual Dev Tunnels API43* calls and relay proxying.44*/45export class WebTunnelAgentHostService extends Disposable implements ITunnelAgentHostService {46declare readonly _serviceBrand: undefined;4748private readonly _onDidChangeTunnels = this._register(new Emitter<void>());49readonly onDidChangeTunnels: Event<void> = this._onDidChangeTunnels.event;5051private readonly _discoveryProvider: ITunnelDiscoveryProvider | undefined;5253constructor(54@IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService,55@IBrowserWorkbenchEnvironmentService environmentService: IBrowserWorkbenchEnvironmentService,56@ILogService private readonly _logService: ILogService,57@IInstantiationService private readonly _instantiationService: IInstantiationService,58@IConfigurationService private readonly _configurationService: IConfigurationService,59@IAuthenticationService private readonly _authenticationService: IAuthenticationService,60@IStorageService private readonly _storageService: IStorageService,61) {62super();63this._discoveryProvider = environmentService.options?.tunnelDiscoveryProvider;64if (!this._discoveryProvider) {65this._logService.debug(`${LOG_PREFIX} No tunnelDiscoveryProvider — tunnel discovery disabled`);66}67}6869// Discovery7071async listTunnels(options?: { silent?: boolean }): Promise<ITunnelInfo[]> {72if (!this._discoveryProvider) {73return [];74}7576if (!this._configurationService.getValue<boolean>(RemoteAgentHostsEnabledSettingId)) {77return [];78}7980try {81// The embedder acquires tokens internally via its own auth flow82const discovered = await this._discoveryProvider.listTunnels();83const results: ITunnelInfo[] = [];8485for (const tunnel of discovered) {86const info = this._toTunnelInfo(tunnel);87if (info && info.protocolVersion >= TUNNEL_MIN_PROTOCOL_VERSION) {88results.push(info);89}90}9192this._logService.info(`${LOG_PREFIX} Found ${results.length} tunnel(s) with agent host support`);93return results;94} catch (err) {95this._logService.error(`${LOG_PREFIX} Failed to list tunnels`, err);96return [];97}98}99100private _toTunnelInfo(tunnel: IDiscoveredTunnel): ITunnelInfo | undefined {101if (!tunnel.tunnelId || !tunnel.clusterId) {102return undefined;103}104105const tags = new TunnelTags(tunnel.tags);106107return {108tunnelId: tunnel.tunnelId,109clusterId: tunnel.clusterId,110name: tags.name || tunnel.name || tunnel.tunnelId,111tags: tunnel.tags as string[],112protocolVersion: tags.protocolVersion,113hostConnectionCount: tunnel.hostConnectionCount,114};115}116117// Connection (via embedder)118119async connect(tunnel: ITunnelInfo, authProvider?: 'github' | 'microsoft'): Promise<void> {120if (!this._discoveryProvider) {121throw new Error('No tunnelDiscoveryProvider available');122}123124const { tunnelId, clusterId } = tunnel;125this._logService.info(`${LOG_PREFIX} Connecting to tunnel '${tunnel.name}' (${tunnelId})`);126127// The embedder handles the full connection including auth128const connection = await this._discoveryProvider.connect(tunnelId, clusterId);129130// Derive connection token from tunnel ID (same convention as CLI and desktop)131const connectionToken = await deriveConnectionToken(tunnelId);132133const transport = new TunnelConnectionTransport(connection, this._logService);134const address = `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`;135const protocolClient = this._instantiationService.createInstance(136RemoteAgentHostProtocolClient, address, transport,137);138139try {140await protocolClient.connect();141this._logService.info(`${LOG_PREFIX} Protocol handshake completed with ${address}`);142143await this._remoteAgentHostService.addManagedConnection({144name: tunnel.name,145connectionToken,146connection: {147type: RemoteAgentHostEntryType.Tunnel,148tunnelId,149clusterId,150label: tunnel.name,151authProvider,152},153}, protocolClient);154155this._onDidChangeTunnels.fire();156} catch (err) {157protocolClient.dispose();158this._logService.error(`${LOG_PREFIX} Connection setup failed`, err);159throw err;160}161}162163async disconnect(address: string): Promise<void> {164await this._remoteAgentHostService.removeRemoteAgentHost(address);165this._onDidChangeTunnels.fire();166}167168// Auth169170async getAuthProvider(options?: { silent?: boolean }): Promise<'github' | 'microsoft' | undefined> {171for (const provider of ['github', 'microsoft'] as const) {172const sessions = await this._authenticationService.getSessions(provider, undefined, {}, true);173if (sessions.length > 0) {174return provider;175}176}177return undefined;178}179180// Tunnel cache181182getCachedTunnels(): ICachedTunnel[] {183const raw = this._storageService.get(CACHED_TUNNELS_KEY, StorageScope.APPLICATION);184if (!raw) {185return [];186}187try {188return JSON.parse(raw);189} catch {190return [];191}192}193194cacheTunnel(tunnel: ITunnelInfo, authProvider?: 'github' | 'microsoft'): void {195const cached = this.getCachedTunnels();196const filtered = cached.filter(t => t.tunnelId !== tunnel.tunnelId);197filtered.unshift({198tunnelId: tunnel.tunnelId,199clusterId: tunnel.clusterId,200name: tunnel.name,201authProvider,202});203this._storeCachedTunnels(filtered.slice(0, 20));204this._onDidChangeTunnels.fire();205}206207removeCachedTunnel(tunnelId: string): void {208const cached = this.getCachedTunnels();209this._storeCachedTunnels(cached.filter(t => t.tunnelId !== tunnelId));210this._onDidChangeTunnels.fire();211}212213private _storeCachedTunnels(tunnels: ICachedTunnel[]): void {214if (tunnels.length === 0) {215this._storageService.remove(CACHED_TUNNELS_KEY, StorageScope.APPLICATION);216} else {217this._storageService.store(CACHED_TUNNELS_KEY, JSON.stringify(tunnels), StorageScope.APPLICATION, StorageTarget.USER);218}219}220}221222/**223* Adapts an {@link ITunnelConnection} (embedder-provided) into an224* {@link IProtocolTransport} for {@link RemoteAgentHostProtocolClient}.225*226* The connection is already established by the time this adapter is created,227* so there is no `connect()` method — the protocol client skips that step.228*/229class TunnelConnectionTransport extends Disposable implements IProtocolTransport {230private readonly _onMessage = this._register(new Emitter<ProtocolMessage>());231readonly onMessage = this._onMessage.event;232233private readonly _onClose = this._register(new Emitter<void>());234readonly onClose = this._onClose.event;235236private _malformedFrames = 0;237238constructor(239private readonly _connection: ITunnelConnection,240private readonly _logService: ILogService,241) {242super();243this._register(_connection.onMessage((data: string) => {244let message: ProtocolMessage;245try {246message = JSON.parse(data) as ProtocolMessage;247} catch (err) {248this._malformedFrames++;249if (this._malformedFrames <= MALFORMED_FRAMES_LOG_CAP) {250const preview = data.length > 80 ? data.slice(0, 80) + '…' : data;251this._logService.warn(252`[TunnelConnectionTransport] Malformed frame #${this._malformedFrames} (len=${data.length}): ${preview}`,253err instanceof Error ? err.message : String(err)254);255}256if (this._malformedFrames > MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD) {257this._logService.warn(258'[TunnelConnectionTransport] Malformed frame threshold exceeded; forcing tunnel close.'259);260this._connection.close();261}262return;263}264this._onMessage.fire(message);265}));266this._register(_connection.onClose(() => {267this._onClose.fire();268}));269}270271send(message: ProtocolMessage | AhpServerNotification | JsonRpcResponse): void {272this._connection.send(JSON.stringify(message));273}274275override dispose(): void {276this._connection.close();277super.dispose();278}279}280281/**282* Derive a connection token from a tunnel ID using the same convention283* as the VS Code CLI and the desktop shared-process service.284*/285async function deriveConnectionToken(tunnelId: string): Promise<string> {286const encoder = new TextEncoder();287const data = encoder.encode(tunnelId);288const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', data);289const hashArray = new Uint8Array(hashBuffer);290291// Base64url encode (matches Node's createHash('sha256').digest('base64url'))292let result = btoa(String.fromCharCode(...hashArray))293.replace(/\+/g, '-')294.replace(/\//g, '_')295.replace(/=+$/, '');296297if (result.startsWith('-')) {298result = 'a' + result;299}300return result;301}302303304