Skip to content

Commit 8de3d6f

Browse files
author
Girish Konda
committed
agentHost: support SSH proxy configuration
1 parent b6d68fb commit 8de3d6f

8 files changed

Lines changed: 1986 additions & 12 deletions

File tree

‎src/vs/platform/agentHost/common/sshConfigParsing.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ function parseSSHPathList(value: string): string[] {
5252
return paths;
5353
}
5454

55+
function parseSSHProxy(value: string | undefined): string | undefined {
56+
return value && value.toLowerCase() !== 'none' ? value : undefined;
57+
}
58+
5559
/**
5660
* Parse `ssh -G` output into a resolved config object.
5761
*/
@@ -86,6 +90,10 @@ export function parseSSHGOutput(stdout: string): ISSHResolvedConfig {
8690
identityFile: identityFiles,
8791
identityAgent: map.get('identityagent') || undefined,
8892
forwardAgent: map.get('forwardagent') === 'yes',
93+
proxyJump: parseSSHProxy(map.get('proxyjump')),
94+
proxyCommand: parseSSHProxy(map.get('proxycommand')),
95+
proxyUseFdpass: map.get('proxyusefdpass') === 'yes',
96+
hostKeyAlias: map.get('hostkeyalias') || undefined,
8997
userKnownHostsFiles: parseSSHPathList(map.get('userknownhostsfile') ?? ''),
9098
globalKnownHostsFiles: parseSSHPathList(map.get('globalknownhostsfile') ?? ''),
9199
strictHostKeyChecking: strictHostKeyChecking && isSSHStrictHostKeyChecking(strictHostKeyChecking)

‎src/vs/platform/agentHost/common/sshRemoteAgentHost.ts‎

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@ export interface ISSHAgentHostConfig {
5757
readonly name: string;
5858
/** SSH config host alias (e.g. "robfast2") for reconnection on restart. */
5959
readonly sshConfigHost?: string;
60+
/** Resolved ProxyJump value for this connect attempt. Never persisted. */
61+
readonly proxyJump?: string;
62+
/** Resolved ProxyCommand value for this connect attempt. Never persisted or logged. */
63+
readonly proxyCommand?: string;
64+
/** Whether the resolved SSH config requests ProxyUseFdpass. */
65+
readonly proxyUseFdpass?: boolean;
66+
/** Resolved HostKeyAlias used by OpenSSH's `%k` proxy token. */
67+
readonly hostKeyAlias?: string;
6068
/** Dev override: custom command to start the remote agent host instead of the default CLI. */
6169
readonly remoteAgentHostCommand?: string;
6270
/** When true, enables OpenSSH agent forwarding (auth-agent@openssh.com) for this connection. Requires {@link authMethod} to be Agent. */
@@ -114,11 +122,10 @@ export function computeSSHConnectionKey(config: { sshConfigHost?: string; userna
114122
}
115123

116124
/**
117-
* A sanitized view of the SSH config that omits secret material
118-
* (password, private key path). Exposed on active connections so
119-
* consumers can inspect connection metadata without accessing credentials.
125+
* A sanitized view of the SSH config that omits credentials and the raw
126+
* ProxyCommand. Exposed on active connections for non-sensitive metadata.
120127
*/
121-
export type ISSHAgentHostConfigSanitized = Omit<ISSHAgentHostConfig, 'password' | 'privateKeyPath'>;
128+
export type ISSHAgentHostConfigSanitized = Omit<ISSHAgentHostConfig, 'password' | 'privateKeyPath' | 'proxyCommand'>;
122129

123130
export interface ISSHAgentHostConnection extends IDisposable {
124131
/** The SSH config used to establish this connection (secrets stripped). */
@@ -276,6 +283,14 @@ export interface ISSHResolvedConfig {
276283
readonly identityFile: string[];
277284
readonly identityAgent: string | undefined;
278285
readonly forwardAgent: boolean;
286+
/** Effective ProxyJump value. */
287+
readonly proxyJump?: string;
288+
/** Effective ProxyCommand value. Sensitive; never persist or log it. */
289+
readonly proxyCommand?: string;
290+
/** Effective ProxyUseFdpass value. */
291+
readonly proxyUseFdpass: boolean;
292+
/** Effective HostKeyAlias value, including the explicit literal `none`. */
293+
readonly hostKeyAlias?: string;
279294
/**
280295
* `UserKnownHostsFile` paths, in priority order. `ssh -G` emits these as a
281296
* single space-separated list, so this is already split. Typically
@@ -322,6 +337,21 @@ export interface ISSHKeyboardInteractiveRequest {
322337
readonly prompts: readonly ISSHKeyboardInteractivePrompt[];
323338
}
324339

340+
export type SSHNativeAskpassPromptKind = 'confirm' | 'password' | 'passphrase' | 'secret';
341+
342+
/**
343+
* Prompt raised by a native OpenSSH ProxyCommand/ProxyJump helper through
344+
* `SSH_ASKPASS`. The raw prompt is transient and must never be persisted or
345+
* logged because implementations may include sensitive command context.
346+
*/
347+
export interface ISSHNativeAskpassRequest {
348+
readonly requestId: string;
349+
readonly connectionKey: string;
350+
readonly displayHost: string;
351+
readonly prompt: string;
352+
readonly kind: SSHNativeAskpassPromptKind;
353+
}
354+
325355
/**
326356
* One live remote agent host endpoint the user could connect to, as
327357
* surfaced by `code agent endpoints` on the remote machine. Deliberately
@@ -504,6 +534,18 @@ export interface ISSHRemoteAgentHostMainService {
504534
*/
505535
respondKeyboardInteractive(requestId: string, responses: readonly string[] | undefined): Promise<void>;
506536

537+
/** Fires when a native OpenSSH proxy helper needs confirmation or a secret. */
538+
readonly onDidRequestNativeAskpass: Event<ISSHNativeAskpassRequest>;
539+
540+
/** Dismisses renderer UI when the owning native proxy helper no longer needs a prompt. */
541+
readonly onDidCancelNativeAskpass: Event<string /* requestId */>;
542+
543+
/**
544+
* Answer a native OpenSSH proxy-helper prompt. Pass `undefined` to cancel
545+
* the owning connection attempt.
546+
*/
547+
respondNativeAskpass(requestId: string, response: string | undefined): Promise<void>;
548+
507549
/**
508550
* Fires when connect() discovers at least one live `editor`-owned
509551
* endpoint on the remote and needs the renderer to choose which

‎src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
type ISSHHostKeyVerificationRequest,
5050
type ISSHHostKeysAnnouncement,
5151
type ISSHKeyboardInteractiveRequest,
52+
type ISSHNativeAskpassRequest,
5253
type ISSHRemoteAgentHostMainService,
5354
type ISSHResolvedConfig,
5455
type ISSHConnectProgress,
@@ -502,6 +503,10 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA
502503
this._handleKeyboardInteractiveRequest(request);
503504
}));
504505

506+
this._register(this._mainService.onDidRequestNativeAskpass(request => {
507+
this._handleNativeAskpassRequest(request);
508+
}));
509+
505510
// Bridge endpoint-selection requests (multiple live remote agent
506511
// hosts found on the remote) to the stored per-host location
507512
// preference, prompting with the shared preference modal only when
@@ -651,6 +656,55 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA
651656
}
652657
}
653658

659+
private async _handleNativeAskpassRequest(request: ISSHNativeAskpassRequest): Promise<void> {
660+
this._logService.info(`[SSHRemoteAgentHost] Native OpenSSH helper requested ${request.kind} input for ${request.displayHost}`);
661+
const cts = new CancellationTokenSource();
662+
const cancelListener = this._mainService.onDidCancelNativeAskpass(requestId => {
663+
if (requestId === request.requestId) {
664+
cts.cancel();
665+
}
666+
});
667+
668+
try {
669+
let response: string | undefined;
670+
if (request.kind === 'confirm') {
671+
const { confirmed } = await this._dialogService.confirm({
672+
type: 'warning',
673+
message: localize('sshNativeAskpassConfirmMessage', "OpenSSH requires confirmation to connect through '{0}'.", request.displayHost),
674+
detail: request.prompt,
675+
primaryButton: localize('sshNativeAskpassConfirm', "&&Continue"),
676+
cancelButton: localize('sshNativeAskpassDecline', "Cancel"),
677+
custom: { icon: Codicon.shield },
678+
token: cts.token,
679+
});
680+
if (cts.token.isCancellationRequested) {
681+
return;
682+
}
683+
response = confirmed ? 'yes' : 'no';
684+
} else {
685+
const cleanedPrompt = request.prompt.replace(/[\s:]+$/, '');
686+
response = await this._quickInputService.input({
687+
title: request.displayHost,
688+
prompt: cleanedPrompt || localize('sshNativeAskpassDefaultPrompt', "Authentication required for {0}", request.displayHost),
689+
password: true,
690+
ignoreFocusLost: true,
691+
}, cts.token);
692+
if (cts.token.isCancellationRequested) {
693+
return;
694+
}
695+
}
696+
await this._mainService.respondNativeAskpass(request.requestId, response);
697+
} catch (err) {
698+
this._logService.error('[SSHRemoteAgentHost] Failed handling native OpenSSH helper prompt', err);
699+
try {
700+
await this._mainService.respondNativeAskpass(request.requestId, undefined);
701+
} catch { /* swallow */ }
702+
} finally {
703+
cancelListener.dispose();
704+
cts.dispose();
705+
}
706+
}
707+
654708
/**
655709
* Decide whether to trust a server's host key, and tell the shared process.
656710
*

0 commit comments

Comments
 (0)