Path: blob/main/src/vs/platform/agentHost/common/remoteAgentHostService.ts
13394 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 { Event } from '../../../base/common/event.js';6import { IDisposable } from '../../../base/common/lifecycle.js';7import { connectionTokenQueryName } from '../../../base/common/network.js';8import { createDecorator } from '../../instantiation/common/instantiation.js';9import type { IAgentConnection } from './agentService.js';10import { TUNNEL_ADDRESS_PREFIX } from './tunnelAgentHost.js';1112/** Connection status for a remote agent host. */13export const enum RemoteAgentHostConnectionStatus {14Connected = 'connected',15Connecting = 'connecting',16Disconnected = 'disconnected',17}1819/** Configuration key for the list of remote agent host addresses. */20export const RemoteAgentHostsSettingId = 'chat.remoteAgentHosts';2122/** Configuration key to enable remote agent host connections. */23export const RemoteAgentHostsEnabledSettingId = 'chat.remoteAgentHostsEnabled';2425/**26* Configuration key that controls whether online dev tunnels and27* configured SSH remote agent hosts are auto-connected at startup.28*/29export const RemoteAgentHostAutoConnectSettingId = 'chat.remoteAgentHostsAutoConnect';3031export const enum RemoteAgentHostEntryType {32WebSocket = 'websocket',33SSH = 'ssh',34Tunnel = 'tunnel',35}3637export interface IRemoteAgentHostWebSocketConnection {38readonly type: RemoteAgentHostEntryType.WebSocket;39readonly address: string;40}4142export interface IRemoteAgentHostSSHConnection {43readonly type: RemoteAgentHostEntryType.SSH;44/**45* The WebSocket address used by the agent host protocol client to46* communicate with the remote agent host process. This is typically a47* forwarded local port (e.g. `localhost:4321`) established by the SSH48* tunnel — it is NOT the SSH hostname itself.49*/50readonly address: string;51/**52* SSH config host alias (e.g. `myserver`). When set, the SSH tunnel is53* automatically re-established on startup using the user's SSH config.54* This takes precedence over {@link hostName} when constructing the55* VS Code Remote SSH authority.56*/57readonly sshConfigHost?: string;58/**59* The actual SSH hostname or IP address of the remote machine60* (e.g. `myserver.example.com`). This is the host that the SSH61* client connects to, and is used to construct the VS Code Remote62* SSH authority when {@link sshConfigHost} is not available.63*/64readonly hostName: string;65/** SSH username for the remote machine. */66readonly user?: string;67/** SSH port on the remote machine (default 22). */68readonly port?: number;69}7071export interface IRemoteAgentHostTunnelConnection {72readonly type: RemoteAgentHostEntryType.Tunnel;73/** Dev tunnel ID. */74readonly tunnelId: string;75/** Dev tunnel cluster region. */76readonly clusterId: string;77/**78* User-defined display name for this tunnel (derived from tunnel tags).79* Used as the tunnel name in the VS Code Remote Tunnels authority80* (e.g. `tunnel+<label>`). Falls back to {@link tunnelId} if not set.81*/82readonly label?: string;83/** Auth provider used to connect to this tunnel. */84readonly authProvider?: 'github' | 'microsoft';85}8687export type RemoteAgentHostConnection = IRemoteAgentHostWebSocketConnection | IRemoteAgentHostSSHConnection | IRemoteAgentHostTunnelConnection;8889/** An entry in the {@link RemoteAgentHostsSettingId} setting. */90export interface IRemoteAgentHostEntry {91readonly name: string;92readonly connectionToken?: string;93readonly connection: RemoteAgentHostConnection;94}9596export function getEntryAddress(entry: IRemoteAgentHostEntry): string {97switch (entry.connection.type) {98case RemoteAgentHostEntryType.WebSocket:99case RemoteAgentHostEntryType.SSH:100return entry.connection.address;101case RemoteAgentHostEntryType.Tunnel:102return `${TUNNEL_ADDRESS_PREFIX}${entry.connection.tunnelId}`;103}104}105106export const enum RemoteAgentHostInputValidationError {107Empty = 'empty',108Invalid = 'invalid',109}110111export interface IParsedRemoteAgentHostInput {112readonly address: string;113readonly connectionToken?: string;114readonly suggestedName: string;115}116117export type RemoteAgentHostInputParseResult =118| { readonly parsed: IParsedRemoteAgentHostInput; readonly error?: undefined }119| { readonly parsed?: undefined; readonly error: RemoteAgentHostInputValidationError };120121export const IRemoteAgentHostService = createDecorator<IRemoteAgentHostService>('remoteAgentHostService');122123/**124* Manages connections to one or more remote agent host processes over125* WebSocket. Each connection is identified by its address string and126* exposed as an {@link IAgentConnection}, the same interface used for127* the local agent host.128*/129export interface IRemoteAgentHostService {130readonly _serviceBrand: undefined;131132/** Fires when a remote connection is established or lost. */133readonly onDidChangeConnections: Event<void>;134135/** Currently connected remote addresses with metadata. */136readonly connections: readonly IRemoteAgentHostConnectionInfo[];137138/** All configured remote agent host entries from settings, regardless of connection status. */139readonly configuredEntries: readonly IRemoteAgentHostEntry[];140141/**142* Get a per-connection {@link IAgentConnection} for subscribing to143* state, dispatching actions, creating sessions, etc.144*145* Returns `undefined` if no active connection exists for the address.146*/147getConnection(address: string): IAgentConnection | undefined;148149/**150* Adds or updates a configured remote host and resolves once a connection151* to that host is available.152*/153addRemoteAgentHost(entry: IRemoteAgentHostEntry): Promise<IRemoteAgentHostConnectionInfo>;154155/**156* Removes a configured remote host entry by address.157* Disconnects any active connection and removes the entry from settings.158*/159removeRemoteAgentHost(address: string): Promise<void>;160161/**162* Forcefully reconnect to a configured remote host.163* Tears down any existing connection and starts a fresh connect attempt164* with reset backoff.165*/166reconnect(address: string): void;167168/**169* Register a pre-connected agent connection.170* Used by the SSH and tunnel services to inject relay-backed connections171* without going through the WebSocket connect flow.172*173* The optional `transportDisposable` represents the underlying transport174* (e.g. an SSH tunnel relay or tunnel-relay session) and is owned by this175* service for the lifetime of the entry. It will be disposed when:176* - the entry is removed via {@link removeRemoteAgentHost}177* - the entry is reconciled away (config-driven removal)178* - this service itself is disposed179* Callers should put any teardown that needs to happen on entry removal180* (e.g. closing the shared-process tunnel, dropping renderer-side handles)181* into this disposable, so a single removal path tears down the whole stack.182*/183addManagedConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection, transportDisposable?: IDisposable): Promise<IRemoteAgentHostConnectionInfo>;184185/**186* Look up the {@link IRemoteAgentHostEntry} for a given address.187* Checks both configured entries from settings and dynamically188* registered entries (e.g. tunnel connections).189*/190getEntryByAddress(address: string): IRemoteAgentHostEntry | undefined;191}192193/** Metadata about a single remote connection. */194export interface IRemoteAgentHostConnectionInfo {195readonly address: string;196readonly name: string;197readonly clientId: string;198readonly defaultDirectory?: string;199readonly status: RemoteAgentHostConnectionStatus;200}201202export class NullRemoteAgentHostService implements IRemoteAgentHostService {203declare readonly _serviceBrand: undefined;204readonly onDidChangeConnections = Event.None;205readonly connections: readonly IRemoteAgentHostConnectionInfo[] = [];206readonly configuredEntries: readonly IRemoteAgentHostEntry[] = [];207getConnection(): IAgentConnection | undefined { return undefined; }208async addRemoteAgentHost(): Promise<IRemoteAgentHostConnectionInfo> {209throw new Error('Remote agent host connections are not supported in this environment.');210}211async removeRemoteAgentHost(_address: string): Promise<void> { }212reconnect(_address: string): void { }213async addManagedConnection(): Promise<IRemoteAgentHostConnectionInfo> {214throw new Error('Remote agent host connections are not supported in this environment.');215}216getEntryByAddress(): IRemoteAgentHostEntry | undefined { return undefined; }217}218219export function parseRemoteAgentHostInput(input: string): RemoteAgentHostInputParseResult {220const trimmedInput = input.trim();221if (!trimmedInput) {222return { error: RemoteAgentHostInputValidationError.Empty };223}224225const candidate = extractRemoteAgentHostCandidate(trimmedInput);226if (!candidate) {227return { error: RemoteAgentHostInputValidationError.Invalid };228}229230const hasExplicitScheme = /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(candidate);231try {232const url = new URL(hasExplicitScheme ? candidate : `ws://${candidate}`);233const normalizedProtocol = normalizeRemoteAgentHostProtocol(url.protocol);234if (!normalizedProtocol || !url.host) {235return { error: RemoteAgentHostInputValidationError.Invalid };236}237238const connectionToken = url.searchParams.get(connectionTokenQueryName) ?? undefined;239url.searchParams.delete(connectionTokenQueryName);240241// Only preserve wss: in the address - the transport defaults to ws:242const address = formatRemoteAgentHostAddress(url, normalizedProtocol === 'wss:' ? normalizedProtocol : undefined);243if (!address) {244return { error: RemoteAgentHostInputValidationError.Invalid };245}246247return {248parsed: {249address,250connectionToken,251suggestedName: url.host,252},253};254} catch {255return { error: RemoteAgentHostInputValidationError.Invalid };256}257}258259function extractRemoteAgentHostCandidate(input: string): string | undefined {260const urlMatch = input.match(/(?<url>(?:https?|wss?):\/\/\S+)/i);261const candidate = urlMatch?.groups?.url ?? input;262const trimmedCandidate = candidate.trim().replace(/[),.;\]]+$/, '');263return trimmedCandidate || undefined;264}265266function normalizeRemoteAgentHostProtocol(protocol: string): 'ws:' | 'wss:' | undefined {267switch (protocol.toLowerCase()) {268case 'ws:':269case 'http:':270return 'ws:';271case 'wss:':272case 'https:':273return 'wss:';274default:275return undefined;276}277}278279function formatRemoteAgentHostAddress(url: URL, protocol: 'ws:' | 'wss:' | undefined): string | undefined {280if (!url.host) {281return undefined;282}283284const path = url.pathname !== '/' ? url.pathname : '';285const query = url.search;286const base = protocol ? `${protocol}//${url.host}` : url.host;287return `${base}${path}${query}`;288}289290/** Raw shape of entries persisted in the {@link RemoteAgentHostsSettingId} setting. */291export interface IRawRemoteAgentHostEntry {292readonly address: string;293readonly name: string;294readonly connectionToken?: string;295readonly sshConfigHost?: string;296readonly sshHostName?: string;297readonly sshUser?: string;298readonly sshPort?: number;299}300301export function rawEntryToEntry(raw: IRawRemoteAgentHostEntry): IRemoteAgentHostEntry | undefined {302if (raw.sshConfigHost || raw.sshHostName || raw.sshUser || raw.sshPort) {303return {304name: raw.name,305connectionToken: raw.connectionToken,306connection: {307type: RemoteAgentHostEntryType.SSH,308address: raw.address,309sshConfigHost: raw.sshConfigHost,310hostName: raw.sshHostName ?? raw.address,311user: raw.sshUser,312port: raw.sshPort,313},314};315}316return {317name: raw.name,318connectionToken: raw.connectionToken,319connection: {320type: RemoteAgentHostEntryType.WebSocket,321address: raw.address,322},323};324}325326export function entryToRawEntry(entry: IRemoteAgentHostEntry): IRawRemoteAgentHostEntry | undefined {327switch (entry.connection.type) {328case RemoteAgentHostEntryType.SSH:329return {330address: entry.connection.address,331name: entry.name,332connectionToken: entry.connectionToken,333sshConfigHost: entry.connection.sshConfigHost,334sshHostName: entry.connection.hostName,335sshUser: entry.connection.user,336sshPort: entry.connection.port,337};338case RemoteAgentHostEntryType.WebSocket:339return {340address: entry.connection.address,341name: entry.name,342connectionToken: entry.connectionToken,343};344case RemoteAgentHostEntryType.Tunnel:345return undefined;346}347}348349350