Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
49 changes: 47 additions & 2 deletions src/vs/platform/agentHost/common/remoteAgentHostService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,31 @@ export interface IRemoteAgentHostWebSocketConnection {

export interface IRemoteAgentHostSSHConnection {
readonly type: RemoteAgentHostEntryType.SSH;
/**
* The WebSocket address used by the agent host protocol client to
* communicate with the remote agent host process. This is typically a
* forwarded local port (e.g. `localhost:4321`) established by the SSH
* tunnel — it is NOT the SSH hostname itself.
Comment thread
connor4312 marked this conversation as resolved.
*/
readonly address: string;
/** SSH config host alias — if set, the tunnel is re-established on startup. */
/**
* SSH config host alias (e.g. `myserver`). When set, the SSH tunnel is
* automatically re-established on startup using the user's SSH config.
* This takes precedence over {@link hostName} when constructing the
* VS Code Remote SSH authority.
*/
readonly sshConfigHost?: string;
/**
* The actual SSH hostname or IP address of the remote machine
* (e.g. `myserver.example.com`). This is the host that the SSH
* client connects to, and is used to construct the VS Code Remote
* SSH authority when {@link sshConfigHost} is not available.
*/
readonly hostName: string;
/** SSH username for the remote machine. */
readonly user?: string;
/** SSH port on the remote machine (default 22). */
readonly port?: number;
}

export interface IRemoteAgentHostTunnelConnection {
Expand All @@ -46,6 +68,12 @@ export interface IRemoteAgentHostTunnelConnection {
readonly tunnelId: string;
/** Dev tunnel cluster region. */
readonly clusterId: string;
/**
* User-defined display name for this tunnel (derived from tunnel tags).
* Used as the tunnel name in the VS Code Remote Tunnels authority
* (e.g. `tunnel+<label>`). Falls back to {@link tunnelId} if not set.
*/
readonly label?: string;
/** Auth provider used to connect to this tunnel. */
readonly authProvider?: 'github' | 'microsoft';
}
Expand Down Expand Up @@ -137,6 +165,13 @@ export interface IRemoteAgentHostService {
* without going through the WebSocket connect flow.
*/
addSSHConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection): Promise<IRemoteAgentHostConnectionInfo>;

/**
* Look up the {@link IRemoteAgentHostEntry} for a given address.
* Checks both configured entries from settings and dynamically
* registered entries (e.g. tunnel connections).
*/
getEntryByAddress(address: string): IRemoteAgentHostEntry | undefined;
}

/** Metadata about a single remote connection. */
Expand All @@ -162,6 +197,7 @@ export class NullRemoteAgentHostService implements IRemoteAgentHostService {
async addSSHConnection(): Promise<IRemoteAgentHostConnectionInfo> {
throw new Error('Remote agent host connections are not supported in this environment.');
}
getEntryByAddress(): IRemoteAgentHostEntry | undefined { return undefined; }
}

export function parseRemoteAgentHostInput(input: string): RemoteAgentHostInputParseResult {
Expand Down Expand Up @@ -241,17 +277,23 @@ export interface IRawRemoteAgentHostEntry {
readonly name: string;
readonly connectionToken?: string;
readonly sshConfigHost?: string;
readonly sshHostName?: string;
readonly sshUser?: string;
readonly sshPort?: number;
}

export function rawEntryToEntry(raw: IRawRemoteAgentHostEntry): IRemoteAgentHostEntry | undefined {
if (raw.sshConfigHost) {
if (raw.sshConfigHost || raw.sshHostName || raw.sshUser || raw.sshPort) {
return {
name: raw.name,
connectionToken: raw.connectionToken,
connection: {
type: RemoteAgentHostEntryType.SSH,
address: raw.address,
sshConfigHost: raw.sshConfigHost,
hostName: raw.sshHostName ?? raw.address,
user: raw.sshUser,
port: raw.sshPort,
},
};
}
Expand All @@ -273,6 +315,9 @@ export function entryToRawEntry(entry: IRemoteAgentHostEntry): IRawRemoteAgentHo
name: entry.name,
connectionToken: entry.connectionToken,
sshConfigHost: entry.connection.sshConfigHost,
sshHostName: entry.connection.hostName,
sshUser: entry.connection.user,
sshPort: entry.connection.port,
};
case RemoteAgentHostEntryType.WebSocket:
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo
private readonly _entries = new Map<string, IConnectionEntry>();
private readonly _names = new Map<string, string>();
private readonly _tokens = new Map<string, string | undefined>();
/**
* Stores the original {@link IRemoteAgentHostEntry} for connections
* registered via {@link addSSHConnection}. This is needed because
* tunnel entries are not persisted to settings and therefore don't
* appear in {@link configuredEntries}.
*/
private readonly _registeredEntries = new Map<string, IRemoteAgentHostEntry>();
private readonly _pendingConnectionWaits = new Map<string, DeferredPromise<IRemoteAgentHostConnectionInfo>>();
/** Pending reconnect timeouts, keyed by normalized address. */
private readonly _reconnectTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
Expand Down Expand Up @@ -110,6 +117,20 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo
return entry?.connected ? entry.client : undefined;
}

getEntryByAddress(address: string): IRemoteAgentHostEntry | undefined {
const normalized = normalizeRemoteAgentHostAddress(address);
// Check dynamically registered entries first (e.g. tunnel connections
// that are not persisted to settings).
const registered = this._registeredEntries.get(normalized);
if (registered) {
return registered;
}
// Fall back to configured entries from settings.
return this.configuredEntries.find(
e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) === normalized
);
}

reconnect(address: string): void {
const normalized = normalizeRemoteAgentHostAddress(address);

Expand Down Expand Up @@ -204,6 +225,7 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo
const connEntry: IConnectionEntry = { store, client: protocolClient, connected: true, status: RemoteAgentHostConnectionStatus.Connected };
this._entries.set(address, connEntry);
this._names.set(address, entry.name);
this._registeredEntries.set(address, entry);
if (entry.connectionToken) {
this._tokens.set(address, entry.connectionToken);
}
Expand Down Expand Up @@ -245,6 +267,7 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo
// (the config change listener will reconcile, but this is instant).
this._names.delete(normalized);
this._tokens.delete(normalized);
this._registeredEntries.delete(normalized);
this._cancelReconnect(normalized);
this._reconnectAttempts.delete(normalized);
this._removeConnection(normalized);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA
type: RemoteAgentHostEntryType.SSH,
address: result.address,
sshConfigHost: result.sshConfigHost,
hostName: result.config.host,
user: result.config.username || undefined,
port: result.config.port,
},
}, protocolClient);
} catch (err) {
Expand Down Expand Up @@ -150,6 +153,9 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA
type: RemoteAgentHostEntryType.SSH,
address: result.address,
sshConfigHost: result.sshConfigHost,
hostName: result.config.host,
user: result.config.username || undefined,
port: result.config.port,
},
}, protocolClient);

Expand Down
104 changes: 96 additions & 8 deletions src/vs/sessions/contrib/chat/browser/chat.contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextke
import { CopilotCLISessionType } from '../../../services/sessions/common/session.js';
import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js';
import { SessionsChatAccessibilityHelp } from './sessionsChatAccessibilityHelp.js';
import { AGENT_HOST_SCHEME, fromAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js';
import { IRemoteAgentHostService, IRemoteAgentHostSSHConnection, RemoteAgentHostEntryType } from '../../../../platform/agentHost/common/remoteAgentHostService.js';
import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';
import { encodeHex, VSBuffer } from '../../../../base/common/buffer.js';

export class OpenSessionWorktreeInVSCodeAction extends Action2 {
static readonly ID = 'chat.openSessionWorktreeInVSCode';
Expand All @@ -71,6 +75,8 @@ export class OpenSessionWorktreeInVSCodeAction extends Action2 {
const openerService = accessor.get(IOpenerService);
const productService = accessor.get(IProductService);
const sessionsManagementService = accessor.get(ISessionsManagementService);
const sessionsProvidersService = accessor.get(ISessionsProvidersService);
const remoteAgentHostService = accessor.get(IRemoteAgentHostService);

const activeSession = sessionsManagementService.activeSession.get();
if (!activeSession) {
Expand All @@ -79,12 +85,19 @@ export class OpenSessionWorktreeInVSCodeAction extends Action2 {

const workspace = activeSession.workspace.get();
const repo = workspace?.repositories[0];
const folderUri = activeSession.sessionType === CopilotCLISessionType.id ? repo?.workingDirectory ?? repo?.uri : undefined;
const rawFolderUri = activeSession.sessionType === CopilotCLISessionType.id ? repo?.workingDirectory ?? repo?.uri : undefined;

if (!folderUri) {
if (!rawFolderUri) {
return;
}

// Unwrap agent-host URIs to get the original file path on the remote
const folderUri = rawFolderUri.scheme === AGENT_HOST_SCHEME ? fromAgentHostUri(rawFolderUri) : rawFolderUri;

// Resolve VS Code remote authority from the session's provider
const remoteAuthority = resolveRemoteAuthority(
activeSession.providerId, sessionsProvidersService, remoteAgentHostService);

const scheme = productService.quality === 'stable'
? 'vscode'
: productService.quality === 'exploration'
Expand All @@ -97,16 +110,91 @@ export class OpenSessionWorktreeInVSCodeAction extends Action2 {
params.set('windowId', '_blank');
params.set('session', activeSession.resource.toString());

await openerService.open(URI.from({
scheme,
authority: Schemas.file,
path: folderUri.path,
query: params.toString(),
}), { openExternal: true });
if (remoteAuthority) {
// Open as remote: vscode://vscode-remote/<remoteAuthority><path>
// The main process converts this to vscode-remote://<remoteAuthority><path>
await openerService.open(URI.from({
scheme,
authority: Schemas.vscodeRemote,
path: `/${remoteAuthority}${folderUri.path}`,
query: params.toString(),
}), { openExternal: true });
} else {
// Open as local file
await openerService.open(URI.from({
scheme,
authority: Schemas.file,
path: folderUri.path,
query: params.toString(),
}), { openExternal: true });
}
}
}
registerAction2(OpenSessionWorktreeInVSCodeAction);

/**
* Resolves the VS Code remote authority for the given session provider,
* e.g. `ssh-remote+myhost` or `tunnel+myTunnel`.
*
* Returns `undefined` for local or WebSocket-only providers where no
* VS Code remote extension can handle the connection.
*/
export function resolveRemoteAuthority(
providerId: string,
sessionsProvidersService: ISessionsProvidersService,
remoteAgentHostService: IRemoteAgentHostService,
): string | undefined {
const provider = sessionsProvidersService.getProvider(providerId);
if (!provider?.remoteAddress) {
return undefined;
}

const entry = remoteAgentHostService.getEntryByAddress(provider.remoteAddress);
if (!entry) {
return undefined;
}

switch (entry.connection.type) {
case RemoteAgentHostEntryType.SSH:
if (entry.connection.sshConfigHost) {
return `ssh-remote+${entry.connection.sshConfigHost}`;
}
return `ssh-remote+${sshAuthorityString(entry.connection)}`;
case RemoteAgentHostEntryType.Tunnel:
return `tunnel+${entry.connection.label ?? `${entry.connection.tunnelId}.${entry.connection.clusterId}`}`;
default:
return undefined;
}
}

/**
* Encodes an SSH connection into the authority string format expected by
* the Remote SSH extension.
*
* Simple hostnames (lowercase alphanumeric) are used verbatim.
* Complex hosts (with user, port, uppercase, or special characters)
* are encoded as a hex-encoded JSON object `{"hostName":...,"user":...,"port":...}`.
*/
export function sshAuthorityString(connection: IRemoteAgentHostSSHConnection): string {
const hostName = connection.hostName;
const needsEncoding = connection.user || connection.port
|| /[A-Z/\\+]/.test(hostName) || !/^[a-zA-Z0-9.:\-]+$/.test(hostName);
if (!needsEncoding) {
return hostName;
}

const obj: Record<string, string | number> = { hostName };
if (connection.user) {
obj.user = connection.user;
}
if (connection.port) {
obj.port = connection.port;
}

const json = JSON.stringify(obj);
return encodeHex(VSBuffer.fromString(json));
}

class NewChatInSessionsWindowAction extends Action2 {

constructor() {
Expand Down
Loading
Loading