mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-02 00:09:30 +01:00
Add SSH remote agent host bootstrap (#304882)
* Add SSH remote agent host bootstrap Adds a new ISSHRemoteAgentHostService that automates connecting to a remote machine via SSH, installing the VS Code CLI, starting 'code agent-host', and forwarding the agent host port back through the SSH tunnel. - New service interface and types in common/sshRemoteAgentHost.ts - Full implementation using ssh2 in electron-browser/ with dynamic imports to respect layering rules - Multi-step quick input flow for SSH connection details integrated into the remote agent host picker - 'Connect via SSH' command registered in contributions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review feedback - Strip password/privateKeyPath from ISSHAgentHostConnection.config so secrets are not exposed to consumers after connect - Redact connection tokens (?tkn=...) in all log output and error messages to prevent credential leakage - Parse user@host:port format in SSH host input with proper validation for port range and missing components - Guard onDidClose with a closed flag to prevent double-fire when dispose and SSH close/error events overlap Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * SSH working through main process Co-authored-by: Copilot <copilot@github.com> * Resolve ssh configs Co-authored-by: Copilot <copilot@github.com> * progress * Granular connect progress, test fix Co-authored-by: Copilot <copilot@github.com> * Test, refactor Co-authored-by: Copilot <copilot@github.com> * Resolve comments Co-authored-by: Copilot <copilot@github.com> * Get rid of cpu-features * Move to shared process Co-authored-by: Copilot <copilot@github.com> * fixes Co-authored-by: Copilot <copilot@github.com> * add ssh2 to remote/package.json * Cleanup and fixes Co-authored-by: Copilot <copilot@github.com> * fix Co-authored-by: Copilot <copilot@github.com> * fix Co-authored-by: Copilot <copilot@github.com> * resolve comments Co-authored-by: Copilot <copilot@github.com> * comments Co-authored-by: Copilot <copilot@github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Raymond Zhao <7199958+rzhao271@users.noreply.github.com> Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
79
package-lock.json
generated
79
package-lock.json
generated
@@ -53,6 +53,7 @@
|
||||
"node-pty": "^1.2.0-beta.12",
|
||||
"open": "^10.1.2",
|
||||
"playwright-core": "1.59.0-alpha-2026-02-20",
|
||||
"ssh2": "^1.16.0",
|
||||
"tas-client": "0.3.1",
|
||||
"undici": "^7.24.0",
|
||||
"v8-inspect-profiler": "^0.1.1",
|
||||
@@ -78,6 +79,7 @@
|
||||
"@types/node": "^22.18.10",
|
||||
"@types/sinon": "^10.0.2",
|
||||
"@types/sinon-test": "^2.4.2",
|
||||
"@types/ssh2": "^1.15.4",
|
||||
"@types/trusted-types": "^2.0.7",
|
||||
"@types/vscode-notebook-renderer": "^1.72.0",
|
||||
"@types/wicg-file-system-access": "^2023.10.7",
|
||||
@@ -2774,6 +2776,33 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ssh2": {
|
||||
"version": "1.15.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz",
|
||||
"integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^18.11.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ssh2/node_modules/@types/node": {
|
||||
"version": "18.19.130",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
|
||||
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ssh2/node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/svgo": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/svgo/-/svgo-1.3.6.tgz",
|
||||
@@ -5126,6 +5155,15 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
||||
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": "~2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/assign-symbols": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz",
|
||||
@@ -5423,6 +5461,15 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bcrypt-pbkdf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tweetnacl": "^0.14.3"
|
||||
}
|
||||
},
|
||||
"node_modules/before-after-hook": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz",
|
||||
@@ -14016,10 +14063,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.14.2",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz",
|
||||
"integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==",
|
||||
"dev": true,
|
||||
"version": "2.26.2",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz",
|
||||
"integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/nanomatch": {
|
||||
@@ -16694,7 +16741,6 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sax": {
|
||||
@@ -17564,6 +17610,23 @@
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="
|
||||
},
|
||||
"node_modules/ssh2": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
|
||||
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"asn1": "^0.2.6",
|
||||
"bcrypt-pbkdf": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"cpu-features": "~0.0.10",
|
||||
"nan": "^2.23.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stable": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz",
|
||||
@@ -18651,6 +18714,12 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "0.14.5",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/typanion": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/typanion/-/typanion-3.14.0.tgz",
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
"node-pty": "^1.2.0-beta.12",
|
||||
"open": "^10.1.2",
|
||||
"playwright-core": "1.59.0-alpha-2026-02-20",
|
||||
"ssh2": "^1.16.0",
|
||||
"tas-client": "0.3.1",
|
||||
"undici": "^7.24.0",
|
||||
"v8-inspect-profiler": "^0.1.1",
|
||||
@@ -150,6 +151,7 @@
|
||||
"@types/node": "^22.18.10",
|
||||
"@types/sinon": "^10.0.2",
|
||||
"@types/sinon-test": "^2.4.2",
|
||||
"@types/ssh2": "^1.15.4",
|
||||
"@types/trusted-types": "^2.0.7",
|
||||
"@types/vscode-notebook-renderer": "^1.72.0",
|
||||
"@types/wicg-file-system-access": "^2023.10.7",
|
||||
@@ -239,7 +241,10 @@
|
||||
"kerberos@2.1.1": {
|
||||
"node-addon-api": "7.1.0"
|
||||
},
|
||||
"serialize-javascript": "^7.0.3"
|
||||
"serialize-javascript": "^7.0.3",
|
||||
"ssh2": {
|
||||
"cpu-features": "0.0.0"
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
55
remote/package-lock.json
generated
55
remote/package-lock.json
generated
@@ -42,6 +42,7 @@
|
||||
"kerberos": "2.1.1",
|
||||
"minimist": "^1.2.8",
|
||||
"node-pty": "^1.2.0-beta.12",
|
||||
"ssh2": "^1.16.0",
|
||||
"tas-client": "0.3.1",
|
||||
"vscode-oniguruma": "1.7.0",
|
||||
"vscode-regexpp": "^3.1.0",
|
||||
@@ -828,6 +829,15 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
||||
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": "~2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -847,6 +857,15 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/bcrypt-pbkdf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tweetnacl": "^0.14.3"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
@@ -1225,6 +1244,13 @@
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.26.2",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz",
|
||||
"integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz",
|
||||
@@ -1382,6 +1408,12 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.5.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
|
||||
@@ -1491,6 +1523,23 @@
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="
|
||||
},
|
||||
"node_modules/ssh2": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
|
||||
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"asn1": "^0.2.6",
|
||||
"bcrypt-pbkdf": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"cpu-features": "~0.0.10",
|
||||
"nan": "^2.23.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
@@ -1560,6 +1609,12 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "0.14.5",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.24.4",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz",
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"vscode-regexpp": "^3.1.0",
|
||||
"vscode-textmate": "^9.3.2",
|
||||
"ws": "^8.19.0",
|
||||
"ssh2": "^1.16.0",
|
||||
"yauzl": "^3.0.0",
|
||||
"yazl": "^2.4.3"
|
||||
},
|
||||
@@ -49,6 +50,9 @@
|
||||
"node-gyp-build": "4.8.1",
|
||||
"kerberos@2.1.1": {
|
||||
"node-addon-api": "7.1.0"
|
||||
},
|
||||
"ssh2": {
|
||||
"cpu-features": "0.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1195,7 +1195,6 @@ export class CodeApplication extends Disposable {
|
||||
services.set(INativeMcpDiscoveryHelperService, new SyncDescriptor(NativeMcpDiscoveryHelperService));
|
||||
services.set(IMcpGatewayService, new SyncDescriptor(McpGatewayService));
|
||||
|
||||
|
||||
// Dev Only: CSS service (for ESM)
|
||||
services.set(ICSSDevelopmentService, new SyncDescriptor(CSSDevelopmentService, undefined, true));
|
||||
|
||||
|
||||
@@ -87,6 +87,8 @@ import { InspectProfilingService as V8InspectProfilingService } from '../../../p
|
||||
import { IV8InspectProfilingService } from '../../../platform/profiling/common/profiling.js';
|
||||
import { IExtensionsScannerService } from '../../../platform/extensionManagement/common/extensionsScannerService.js';
|
||||
import { ExtensionsScannerService } from '../../../platform/extensionManagement/node/extensionsScannerService.js';
|
||||
import { ISSHRemoteAgentHostMainService, SSH_REMOTE_AGENT_HOST_CHANNEL } from '../../../platform/agentHost/common/sshRemoteAgentHost.js';
|
||||
import { SSHRemoteAgentHostMainService } from '../../../platform/agentHost/node/sshRemoteAgentHostService.js';
|
||||
import { IUserDataProfilesService } from '../../../platform/userDataProfile/common/userDataProfile.js';
|
||||
import { IExtensionsProfileScannerService } from '../../../platform/extensionManagement/common/extensionsProfileScannerService.js';
|
||||
import { PolicyChannelClient } from '../../../platform/policy/common/policyIpc.js';
|
||||
@@ -402,6 +404,9 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter {
|
||||
// Web Content Extractor
|
||||
services.set(ISharedWebContentExtractorService, new SyncDescriptor(SharedWebContentExtractorService));
|
||||
|
||||
// SSH Remote Agent Host
|
||||
services.set(ISSHRemoteAgentHostMainService, new SyncDescriptor(SSHRemoteAgentHostMainService, undefined, true));
|
||||
|
||||
return new InstantiationService(services);
|
||||
}
|
||||
|
||||
@@ -472,6 +477,10 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter {
|
||||
// Playwright
|
||||
const playwrightChannel = this._register(new PlaywrightChannel(this.server, accessor.get(IMainProcessService), accessor.get(ILogService)));
|
||||
this.server.registerChannel('playwright', playwrightChannel);
|
||||
|
||||
// SSH Remote Agent Host
|
||||
const sshRemoteAgentHostChannel = ProxyChannel.fromService(accessor.get(ISSHRemoteAgentHostMainService), this._store);
|
||||
this.server.registerChannel(SSH_REMOTE_AGENT_HOST_CHANNEL, sshRemoteAgentHostChannel);
|
||||
}
|
||||
|
||||
private registerErrorHandler(logService: ILogService): void {
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface IRemoteAgentHostEntry {
|
||||
readonly address: string;
|
||||
readonly name: string;
|
||||
readonly connectionToken?: string;
|
||||
/** SSH config host alias — if set, the tunnel is re-established on startup. */
|
||||
readonly sshConfigHost?: string;
|
||||
}
|
||||
|
||||
export const enum RemoteAgentHostInputValidationError {
|
||||
@@ -89,6 +91,13 @@ export interface IRemoteAgentHostService {
|
||||
* with reset backoff.
|
||||
*/
|
||||
reconnect(address: string): void;
|
||||
|
||||
/**
|
||||
* Register a pre-connected SSH agent connection.
|
||||
* Used by the SSH service to inject relay-backed connections
|
||||
* without going through the WebSocket connect flow.
|
||||
*/
|
||||
addSSHConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection): Promise<IRemoteAgentHostConnectionInfo>;
|
||||
}
|
||||
|
||||
/** Metadata about a single remote connection. */
|
||||
@@ -111,6 +120,9 @@ export class NullRemoteAgentHostService implements IRemoteAgentHostService {
|
||||
}
|
||||
async removeRemoteAgentHost(_address: string): Promise<void> { }
|
||||
reconnect(_address: string): void { }
|
||||
async addSSHConnection(): Promise<IRemoteAgentHostConnectionInfo> {
|
||||
throw new Error('Remote agent host connections are not supported in this environment.');
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRemoteAgentHostInput(input: string): RemoteAgentHostInputParseResult {
|
||||
|
||||
64
src/vs/platform/agentHost/common/sshConfigParsing.ts
Normal file
64
src/vs/platform/agentHost/common/sshConfigParsing.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import type { ISSHResolvedConfig } from './sshRemoteAgentHost.js';
|
||||
|
||||
/** Strip inline comments from an SSH config value. */
|
||||
export function stripSSHComment(s: string): string {
|
||||
const idx = s.indexOf(' #');
|
||||
return idx !== -1 ? s.substring(0, idx).trim() : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Host aliases from SSH config content (without following Includes).
|
||||
*/
|
||||
export function parseSSHConfigHostEntries(content: string): string[] {
|
||||
const hosts: string[] = [];
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
const hostMatch = trimmed.match(/^Host\s+(.+)$/i);
|
||||
if (hostMatch) {
|
||||
const hostValue = stripSSHComment(hostMatch[1]);
|
||||
for (const h of hostValue.split(/\s+/)) {
|
||||
if (!h.includes('*') && !h.includes('?') && !h.startsWith('!')) {
|
||||
hosts.push(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return hosts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `ssh -G` output into a resolved config object.
|
||||
*/
|
||||
export function parseSSHGOutput(stdout: string): ISSHResolvedConfig {
|
||||
const map = new Map<string, string>();
|
||||
const identityFiles: string[] = [];
|
||||
for (const line of stdout.split('\n')) {
|
||||
const spaceIdx = line.indexOf(' ');
|
||||
if (spaceIdx === -1) {
|
||||
continue;
|
||||
}
|
||||
const key = line.substring(0, spaceIdx).toLowerCase();
|
||||
const value = line.substring(spaceIdx + 1).trim();
|
||||
if (key === 'identityfile') {
|
||||
identityFiles.push(value);
|
||||
} else {
|
||||
map.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hostname: map.get('hostname') ?? '',
|
||||
user: map.get('user') || undefined,
|
||||
port: parseInt(map.get('port') ?? '22', 10),
|
||||
identityFile: identityFiles,
|
||||
forwardAgent: map.get('forwardagent') === 'yes',
|
||||
};
|
||||
}
|
||||
212
src/vs/platform/agentHost/common/sshRemoteAgentHost.ts
Normal file
212
src/vs/platform/agentHost/common/sshRemoteAgentHost.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Event } from '../../../base/common/event.js';
|
||||
import { IDisposable } from '../../../base/common/lifecycle.js';
|
||||
import { createDecorator } from '../../instantiation/common/instantiation.js';
|
||||
|
||||
export const ISSHRemoteAgentHostService = createDecorator<ISSHRemoteAgentHostService>('sshRemoteAgentHostService');
|
||||
|
||||
/**
|
||||
* IPC channel name for the main-process SSH service.
|
||||
*/
|
||||
export const SSH_REMOTE_AGENT_HOST_CHANNEL = 'sshRemoteAgentHost';
|
||||
|
||||
export const enum SSHAuthMethod {
|
||||
/** Use the local SSH agent for key-based auth. */
|
||||
Agent = 'agent',
|
||||
/** Authenticate with an explicit private key file. */
|
||||
KeyFile = 'keyFile',
|
||||
/** Authenticate with a password. */
|
||||
Password = 'password',
|
||||
}
|
||||
|
||||
export interface ISSHAgentHostConfig {
|
||||
/** Remote hostname or IP. */
|
||||
readonly host: string;
|
||||
/** SSH port (default 22). */
|
||||
readonly port?: number;
|
||||
/** Username on the remote machine. */
|
||||
readonly username: string;
|
||||
/** Authentication method. */
|
||||
readonly authMethod: SSHAuthMethod;
|
||||
/** Path to the private key file (when {@link authMethod} is KeyFile). */
|
||||
readonly privateKeyPath?: string;
|
||||
/** Password string (when {@link authMethod} is Password). */
|
||||
readonly password?: string;
|
||||
/** Display name for this connection. */
|
||||
readonly name: string;
|
||||
/** SSH config host alias (e.g. "robfast2") for reconnection on restart. */
|
||||
readonly sshConfigHost?: string;
|
||||
/** Dev override: custom command to start the remote agent host instead of the default CLI. */
|
||||
readonly remoteAgentHostCommand?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A sanitized view of the SSH config that omits secret material
|
||||
* (password, private key path). Exposed on active connections so
|
||||
* consumers can inspect connection metadata without accessing credentials.
|
||||
*/
|
||||
export type ISSHAgentHostConfigSanitized = Omit<ISSHAgentHostConfig, 'password' | 'privateKeyPath'>;
|
||||
|
||||
export interface ISSHAgentHostConnection extends IDisposable {
|
||||
/** The SSH config used to establish this connection (secrets stripped). */
|
||||
readonly config: ISSHAgentHostConfigSanitized;
|
||||
/** The connection address (e.g. `ssh:myhost` or `user@host:22`) registered with IRemoteAgentHostService. */
|
||||
readonly localAddress: string;
|
||||
/** The display name. */
|
||||
readonly name: string;
|
||||
/** Fires when this SSH connection is closed or lost. */
|
||||
readonly onDidClose: Event<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages SSH connections that bootstrap a remote agent host process.
|
||||
*
|
||||
* Each connection SSHs into a remote machine, ensures the VS Code CLI
|
||||
* is installed, starts `code agent-host`, and creates a WebSocket relay
|
||||
* over the SSH channel. Messages are forwarded between the renderer and
|
||||
* the remote agent host via IPC through the shared process.
|
||||
*/
|
||||
export interface ISSHRemoteAgentHostService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/** Fires when the set of active SSH connections changes. */
|
||||
readonly onDidChangeConnections: Event<void>;
|
||||
|
||||
/** Progress messages during connect. */
|
||||
readonly onDidReportConnectProgress: Event<ISSHConnectProgress>;
|
||||
|
||||
/** Currently active SSH-bootstrapped connections. */
|
||||
readonly connections: readonly ISSHAgentHostConnection[];
|
||||
|
||||
/**
|
||||
* Bootstrap a remote agent host over SSH.
|
||||
*
|
||||
* 1. Opens an SSH connection to the remote host
|
||||
* 2. Downloads and installs the VS Code CLI if needed
|
||||
* 3. Starts `code agent-host`
|
||||
* 4. Creates a WebSocket relay over the SSH channel
|
||||
* 5. Registers the connection with {@link IRemoteAgentHostService}
|
||||
*
|
||||
* Resolves with the connection handle once the agent host is reachable.
|
||||
*/
|
||||
connect(config: ISSHAgentHostConfig): Promise<ISSHAgentHostConnection>;
|
||||
|
||||
/**
|
||||
* Disconnect an SSH-bootstrapped connection by host address.
|
||||
* Tears down the SSH tunnel, stops the remote agent host, and
|
||||
* removes the entry from {@link IRemoteAgentHostService}.
|
||||
*/
|
||||
disconnect(host: string): Promise<void>;
|
||||
|
||||
/** List SSH config host aliases (excluding wildcards). */
|
||||
listSSHConfigHosts(): Promise<string[]>;
|
||||
|
||||
/** Resolve full SSH config for a host via `ssh -G`. */
|
||||
resolveSSHConfig(host: string): Promise<ISSHResolvedConfig>;
|
||||
|
||||
/**
|
||||
* Re-establish an SSH tunnel on startup for a previously connected host.
|
||||
* Returns the new local forwarded address and registers it.
|
||||
*/
|
||||
reconnect(sshConfigHost: string, name: string): Promise<ISSHAgentHostConnection>;
|
||||
}
|
||||
/**
|
||||
* Serializable result from a successful SSH connect operation.
|
||||
* Returned over IPC from the main process.
|
||||
*/
|
||||
export interface ISSHConnectResult {
|
||||
/** Unique identifier for this connection's relay channel. */
|
||||
readonly connectionId: string;
|
||||
/** Display-friendly address (e.g. "ssh:robfast2"). */
|
||||
readonly address: string;
|
||||
readonly name: string;
|
||||
readonly connectionToken: string | undefined;
|
||||
readonly config: ISSHAgentHostConfigSanitized;
|
||||
/** SSH config host alias for reconnection on restart. */
|
||||
readonly sshConfigHost?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved SSH configuration for a host, obtained from `ssh -G`.
|
||||
*/
|
||||
export interface ISSHResolvedConfig {
|
||||
readonly hostname: string;
|
||||
readonly user: string | undefined;
|
||||
readonly port: number;
|
||||
readonly identityFile: string[];
|
||||
readonly forwardAgent: boolean;
|
||||
}
|
||||
|
||||
export interface ISSHConnectProgress {
|
||||
readonly connectionKey: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A message relayed from a remote agent host through the SSH tunnel.
|
||||
* The shared process acts as a WebSocket proxy, forwarding JSON messages
|
||||
* bidirectionally between the SSH channel and the renderer via IPC.
|
||||
*/
|
||||
export interface ISSHRelayMessage {
|
||||
readonly connectionId: string;
|
||||
readonly data: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main-process service that performs the actual SSH work.
|
||||
* The renderer calls this over IPC and handles registration
|
||||
* with {@link IRemoteAgentHostService} locally.
|
||||
*/
|
||||
export const ISSHRemoteAgentHostMainService = createDecorator<ISSHRemoteAgentHostMainService>('sshRemoteAgentHostMainService');
|
||||
|
||||
export interface ISSHRemoteAgentHostMainService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/** Fires when the set of active SSH connections changes. */
|
||||
readonly onDidChangeConnections: Event<void>;
|
||||
|
||||
/** Fires when a connection is closed from the shared process side. */
|
||||
readonly onDidCloseConnection: Event<string /* connectionId */>;
|
||||
|
||||
/** Progress messages during connect (e.g. "Installing CLI..."). */
|
||||
readonly onDidReportConnectProgress: Event<ISSHConnectProgress>;
|
||||
|
||||
/** Fires when a message is received from a remote agent host via the SSH relay. */
|
||||
readonly onDidRelayMessage: Event<ISSHRelayMessage>;
|
||||
|
||||
/** Fires when a relay connection to a remote agent host closes. */
|
||||
readonly onDidRelayClose: Event<string /* connectionId */>;
|
||||
|
||||
/**
|
||||
* Bootstrap a remote agent host over SSH. Returns serializable
|
||||
* connection info for the renderer to register.
|
||||
*/
|
||||
connect(config: ISSHAgentHostConfig): Promise<ISSHConnectResult>;
|
||||
|
||||
/**
|
||||
* Send a message to a remote agent host through the SSH relay.
|
||||
*/
|
||||
relaySend(connectionId: string, message: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Disconnect an SSH-bootstrapped connection by host address.
|
||||
*/
|
||||
disconnect(host: string): Promise<void>;
|
||||
|
||||
/** List SSH config host aliases (excluding wildcards). */
|
||||
listSSHConfigHosts(): Promise<string[]>;
|
||||
|
||||
/** Resolve full SSH config for a host via `ssh -G`. */
|
||||
resolveSSHConfig(host: string): Promise<ISSHResolvedConfig>;
|
||||
|
||||
/**
|
||||
* Re-establish an SSH tunnel for a previously connected host.
|
||||
* Resolves the SSH config alias, connects, and returns fresh
|
||||
* connection info with a new local forwarded port.
|
||||
*/
|
||||
reconnect(sshConfigHost: string, name: string, remoteAgentHostCommand?: string): Promise<ISSHConnectResult>;
|
||||
}
|
||||
@@ -36,6 +36,20 @@ export interface IProtocolTransport extends IDisposable {
|
||||
send(message: IProtocolMessage | IAhpServerNotification | IJsonRpcResponse | IJsonRpcRequest): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A client-side transport that requires an explicit connection step
|
||||
* before messages can be exchanged.
|
||||
*/
|
||||
export interface IClientTransport extends IProtocolTransport {
|
||||
/** Establish the underlying connection (e.g. open a WebSocket). */
|
||||
connect(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Type guard for transports that require an explicit connection step. */
|
||||
export function isClientTransport(transport: IProtocolTransport): transport is IClientTransport {
|
||||
return typeof (transport as IClientTransport).connect === 'function';
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side transport that accepts multiple client connections.
|
||||
* Each connected client gets its own {@link IProtocolTransport}.
|
||||
|
||||
@@ -21,9 +21,9 @@ import type { IClientNotificationMap, ICommandMap } from '../common/state/protoc
|
||||
import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js';
|
||||
import { PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js';
|
||||
import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, type IJsonRpcResponse, type IProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js';
|
||||
import { isClientTransport, type IProtocolTransport } from '../common/state/sessionTransport.js';
|
||||
import { ContentEncoding } from '../common/state/protocol/commands.js';
|
||||
import type { ISessionSummary } from '../common/state/sessionState.js';
|
||||
import { WebSocketClientTransport } from './webSocketClientTransport.js';
|
||||
import { encodeBase64 } from '../../../base/common/buffer.js';
|
||||
|
||||
/**
|
||||
@@ -39,7 +39,8 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly _clientId = generateUuid();
|
||||
private readonly _transport: WebSocketClientTransport;
|
||||
private readonly _address: string;
|
||||
private readonly _transport: IProtocolTransport;
|
||||
private readonly _connectionAuthority: string;
|
||||
private _serverSeq = 0;
|
||||
private _nextClientSeq = 1;
|
||||
@@ -63,7 +64,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
|
||||
}
|
||||
|
||||
get address(): string {
|
||||
return this._transport['_address'];
|
||||
return this._address;
|
||||
}
|
||||
|
||||
get defaultDirectory(): string | undefined {
|
||||
@@ -72,13 +73,15 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
|
||||
|
||||
constructor(
|
||||
address: string,
|
||||
connectionToken: string | undefined,
|
||||
transport: IProtocolTransport,
|
||||
@ILogService private readonly _logService: ILogService,
|
||||
@IFileService private readonly _fileService: IFileService,
|
||||
) {
|
||||
super();
|
||||
this._address = address;
|
||||
this._connectionAuthority = agentHostAuthority(address);
|
||||
this._transport = this._register(new WebSocketClientTransport(address, connectionToken));
|
||||
this._transport = transport;
|
||||
this._register(this._transport);
|
||||
this._register(this._transport.onMessage(msg => this._handleMessage(msg)));
|
||||
this._register(this._transport.onClose(() => this._onDidClose.fire()));
|
||||
}
|
||||
@@ -87,7 +90,9 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
|
||||
* Connect to the remote agent host and perform the protocol handshake.
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
if (isClientTransport(this._transport)) {
|
||||
await this._transport.connect();
|
||||
}
|
||||
|
||||
const result = await this._sendRequest('initialize', {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type IRemoteAgentHostEntry,
|
||||
} from '../common/remoteAgentHostService.js';
|
||||
import { RemoteAgentHostProtocolClient } from './remoteAgentHostProtocolClient.js';
|
||||
import { WebSocketClientTransport } from './webSocketClientTransport.js';
|
||||
import { normalizeRemoteAgentHostAddress } from '../common/agentHostUri.js';
|
||||
|
||||
/** Tracks a single remote connection through its lifecycle. */
|
||||
@@ -100,6 +101,15 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo
|
||||
|
||||
reconnect(address: string): void {
|
||||
const normalized = normalizeRemoteAgentHostAddress(address);
|
||||
|
||||
// SSH entries are reconnected by the SSH service, not via WebSocket
|
||||
const configuredEntry = this._getConfiguredEntries().find(
|
||||
e => normalizeRemoteAgentHostAddress(e.address) === normalized
|
||||
);
|
||||
if (configuredEntry?.sshConfigHost) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = this._tokens.get(normalized);
|
||||
|
||||
// Cancel any pending reconnect
|
||||
@@ -149,6 +159,52 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo
|
||||
return connection;
|
||||
}
|
||||
|
||||
async addSSHConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection): Promise<IRemoteAgentHostConnectionInfo> {
|
||||
const address = entry.address;
|
||||
|
||||
// Dispose any existing entry for this address to avoid leaking
|
||||
// old protocol clients and relay transports on reconnect.
|
||||
const existingEntry = this._entries.get(address);
|
||||
if (existingEntry) {
|
||||
this._entries.delete(address);
|
||||
existingEntry.store.dispose();
|
||||
}
|
||||
|
||||
const store = new DisposableStore();
|
||||
|
||||
// Create a connection entry wrapping the pre-connected client
|
||||
const protocolClient = connection as RemoteAgentHostProtocolClient;
|
||||
store.add(protocolClient);
|
||||
const connEntry: IConnectionEntry = { store, client: protocolClient, connected: true, status: RemoteAgentHostConnectionStatus.Connected };
|
||||
this._entries.set(address, connEntry);
|
||||
this._names.set(address, entry.name);
|
||||
if (entry.connectionToken) {
|
||||
this._tokens.set(address, entry.connectionToken);
|
||||
}
|
||||
|
||||
store.add(protocolClient.onDidClose(() => {
|
||||
if (this._entries.get(address) === connEntry) {
|
||||
connEntry.connected = false;
|
||||
connEntry.status = RemoteAgentHostConnectionStatus.Disconnected;
|
||||
this._onDidChangeConnections.fire();
|
||||
}
|
||||
}));
|
||||
|
||||
// Persist SSH entries — await so that the config is written before
|
||||
// onDidChangeConnections fires, ensuring _reconcile creates the provider.
|
||||
await this._storeConfiguredEntries(this._upsertConfiguredEntry(entry));
|
||||
|
||||
this._onDidChangeConnections.fire();
|
||||
|
||||
return {
|
||||
address,
|
||||
name: entry.name,
|
||||
clientId: protocolClient.clientId,
|
||||
defaultDirectory: protocolClient.defaultDirectory,
|
||||
status: RemoteAgentHostConnectionStatus.Connected,
|
||||
};
|
||||
}
|
||||
|
||||
async removeRemoteAgentHost(address: string): Promise<void> {
|
||||
const normalized = normalizeRemoteAgentHostAddress(address);
|
||||
// This setting is only used in the sessions app (user scope), so we
|
||||
@@ -219,9 +275,9 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo
|
||||
}
|
||||
}
|
||||
|
||||
// Add new connections
|
||||
// Add new connections (skip SSH entries — those are handled by ISSHRemoteAgentHostService)
|
||||
for (const entry of entries) {
|
||||
if (!this._entries.has(entry.address)) {
|
||||
if (!this._entries.has(entry.address) && !entry.sshConfigHost) {
|
||||
this._connectTo(entry.address, entry.connectionToken);
|
||||
}
|
||||
}
|
||||
@@ -242,7 +298,8 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo
|
||||
}
|
||||
|
||||
const store = new DisposableStore();
|
||||
const client = store.add(this._instantiationService.createInstance(RemoteAgentHostProtocolClient, address, connectionToken));
|
||||
const transport = store.add(new WebSocketClientTransport(address, connectionToken));
|
||||
const client = store.add(this._instantiationService.createInstance(RemoteAgentHostProtocolClient, address, transport));
|
||||
const entry: IConnectionEntry = { store, client, connected: false, status: RemoteAgentHostConnectionStatus.Connecting };
|
||||
this._entries.set(address, entry);
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Emitter } from '../../../base/common/event.js';
|
||||
import { Disposable } from '../../../base/common/lifecycle.js';
|
||||
import type { IAhpServerNotification, IJsonRpcResponse, IProtocolMessage } from '../common/state/sessionProtocol.js';
|
||||
import type { IProtocolTransport } from '../common/state/sessionTransport.js';
|
||||
import type { ISSHRelayMessage, ISSHRemoteAgentHostMainService } from '../common/sshRemoteAgentHost.js';
|
||||
|
||||
/**
|
||||
* A protocol transport that relays messages through the shared process
|
||||
* SSH tunnel via IPC, instead of using a direct WebSocket connection.
|
||||
*
|
||||
* The shared process manages the actual WebSocket-over-SSH connection
|
||||
* and forwards messages bidirectionally through this IPC channel.
|
||||
*/
|
||||
export class SSHRelayTransport extends Disposable implements IProtocolTransport {
|
||||
|
||||
private readonly _onMessage = this._register(new Emitter<IProtocolMessage>());
|
||||
readonly onMessage = this._onMessage.event;
|
||||
|
||||
private readonly _onClose = this._register(new Emitter<void>());
|
||||
readonly onClose = this._onClose.event;
|
||||
|
||||
constructor(
|
||||
private readonly _connectionId: string,
|
||||
private readonly _sshService: ISSHRemoteAgentHostMainService,
|
||||
) {
|
||||
super();
|
||||
|
||||
// Listen for relay messages from the shared process
|
||||
this._register(this._sshService.onDidRelayMessage((msg: ISSHRelayMessage) => {
|
||||
if (msg.connectionId === this._connectionId) {
|
||||
try {
|
||||
const parsed = JSON.parse(msg.data) as IProtocolMessage;
|
||||
this._onMessage.fire(parsed);
|
||||
} catch {
|
||||
// Malformed message — drop
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Listen for relay close
|
||||
this._register(this._sshService.onDidRelayClose((closedId: string) => {
|
||||
if (closedId === this._connectionId) {
|
||||
this._onClose.fire();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
send(message: IProtocolMessage | IAhpServerNotification | IJsonRpcResponse): void {
|
||||
this._sshService.relaySend(this._connectionId, JSON.stringify(message)).catch(() => {
|
||||
// Send failed — connection probably closed
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js';
|
||||
import { ISSHRemoteAgentHostService } from '../common/sshRemoteAgentHost.js';
|
||||
import { SSHRemoteAgentHostService } from './sshRemoteAgentHostServiceImpl.js';
|
||||
|
||||
registerSingleton(ISSHRemoteAgentHostService, SSHRemoteAgentHostService, InstantiationType.Delayed);
|
||||
@@ -0,0 +1,215 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Emitter, Event } from '../../../base/common/event.js';
|
||||
import { Disposable, toDisposable } from '../../../base/common/lifecycle.js';
|
||||
import { ILogService } from '../../log/common/log.js';
|
||||
import { IConfigurationService } from '../../configuration/common/configuration.js';
|
||||
import { ISharedProcessService } from '../../ipc/electron-browser/services.js';
|
||||
import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js';
|
||||
import { IRemoteAgentHostService } from '../common/remoteAgentHostService.js';
|
||||
import { IInstantiationService } from '../../instantiation/common/instantiation.js';
|
||||
import { SSHRelayTransport } from './sshRelayTransport.js';
|
||||
import { RemoteAgentHostProtocolClient } from './remoteAgentHostProtocolClient.js';
|
||||
import {
|
||||
ISSHRemoteAgentHostService,
|
||||
SSH_REMOTE_AGENT_HOST_CHANNEL,
|
||||
type ISSHAgentHostConfig,
|
||||
type ISSHAgentHostConnection,
|
||||
type ISSHRemoteAgentHostMainService,
|
||||
type ISSHResolvedConfig,
|
||||
type ISSHConnectProgress,
|
||||
} from '../common/sshRemoteAgentHost.js';
|
||||
|
||||
/**
|
||||
* Renderer-side implementation of {@link ISSHRemoteAgentHostService} that
|
||||
* delegates the actual SSH work to the main process via IPC, then registers
|
||||
* the resulting connection with the renderer-local {@link IRemoteAgentHostService}.
|
||||
*/
|
||||
export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteAgentHostService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly _mainService: ISSHRemoteAgentHostMainService;
|
||||
|
||||
private readonly _onDidChangeConnections = this._register(new Emitter<void>());
|
||||
readonly onDidChangeConnections: Event<void> = this._onDidChangeConnections.event;
|
||||
|
||||
readonly onDidReportConnectProgress: Event<ISSHConnectProgress>;
|
||||
|
||||
private readonly _connections = new Map<string, SSHAgentHostConnectionHandle>();
|
||||
|
||||
constructor(
|
||||
@ISharedProcessService sharedProcessService: ISharedProcessService,
|
||||
@IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService,
|
||||
@ILogService private readonly _logService: ILogService,
|
||||
@IInstantiationService private readonly _instantiationService: IInstantiationService,
|
||||
@IConfigurationService private readonly _configurationService: IConfigurationService,
|
||||
) {
|
||||
super();
|
||||
|
||||
this._mainService = ProxyChannel.toService<ISSHRemoteAgentHostMainService>(
|
||||
sharedProcessService.getChannel(SSH_REMOTE_AGENT_HOST_CHANNEL),
|
||||
);
|
||||
|
||||
this.onDidReportConnectProgress = this._mainService.onDidReportConnectProgress;
|
||||
|
||||
// When shared process fires onDidCloseConnection, clean up the renderer-side handle.
|
||||
// Do NOT remove the configured entry — it stays in settings so startup reconnect
|
||||
// can re-establish the SSH tunnel on next launch.
|
||||
this._register(this._mainService.onDidCloseConnection(connectionId => {
|
||||
const handle = this._connections.get(connectionId);
|
||||
if (handle) {
|
||||
this._connections.delete(connectionId);
|
||||
handle.fireClose();
|
||||
handle.dispose();
|
||||
this._onDidChangeConnections.fire();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
get connections(): readonly ISSHAgentHostConnection[] {
|
||||
return [...this._connections.values()];
|
||||
}
|
||||
|
||||
async connect(config: ISSHAgentHostConfig): Promise<ISSHAgentHostConnection> {
|
||||
this._logService.info('[SSHRemoteAgentHost] Connecting to ' + config.host);
|
||||
const augmentedConfig = this._augmentConfig(config);
|
||||
const result = await this._mainService.connect(augmentedConfig);
|
||||
this._logService.trace('[SSHRemoteAgentHost] SSH tunnel established, connectionId=' + result.connectionId);
|
||||
|
||||
const existing = this._connections.get(result.connectionId);
|
||||
if (existing) {
|
||||
this._logService.trace('[SSHRemoteAgentHost] Returning existing connection handle');
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Create relay transport + protocol client, then register with RemoteAgentHostService
|
||||
try {
|
||||
const protocolClient = this._createRelayClient(result);
|
||||
await protocolClient.connect();
|
||||
this._logService.trace('[SSHRemoteAgentHost] Protocol handshake completed');
|
||||
|
||||
await this._remoteAgentHostService.addSSHConnection({
|
||||
address: result.address,
|
||||
name: result.name,
|
||||
connectionToken: result.connectionToken,
|
||||
sshConfigHost: result.sshConfigHost,
|
||||
}, protocolClient);
|
||||
} catch (err) {
|
||||
this._logService.error('[SSHRemoteAgentHost] Connection setup failed', err);
|
||||
this._mainService.disconnect(result.connectionId).catch(() => { /* best effort */ });
|
||||
throw err;
|
||||
}
|
||||
|
||||
const handle = new SSHAgentHostConnectionHandle(
|
||||
result.config,
|
||||
result.address,
|
||||
result.name,
|
||||
() => this._mainService.disconnect(result.connectionId),
|
||||
);
|
||||
|
||||
this._connections.set(result.connectionId, handle);
|
||||
this._onDidChangeConnections.fire();
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
async disconnect(host: string): Promise<void> {
|
||||
await this._mainService.disconnect(host);
|
||||
}
|
||||
|
||||
async listSSHConfigHosts(): Promise<string[]> {
|
||||
return this._mainService.listSSHConfigHosts();
|
||||
}
|
||||
|
||||
async resolveSSHConfig(host: string): Promise<ISSHResolvedConfig> {
|
||||
return this._mainService.resolveSSHConfig(host);
|
||||
}
|
||||
|
||||
async reconnect(sshConfigHost: string, name: string): Promise<ISSHAgentHostConnection> {
|
||||
const commandOverride = this._getRemoteAgentHostCommand();
|
||||
const result = await this._mainService.reconnect(sshConfigHost, name, commandOverride);
|
||||
|
||||
const existing = this._connections.get(result.connectionId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const protocolClient = this._createRelayClient(result);
|
||||
await protocolClient.connect();
|
||||
|
||||
await this._remoteAgentHostService.addSSHConnection({
|
||||
address: result.address,
|
||||
name: result.name,
|
||||
connectionToken: result.connectionToken,
|
||||
sshConfigHost: result.sshConfigHost,
|
||||
}, protocolClient);
|
||||
|
||||
const handle = new SSHAgentHostConnectionHandle(
|
||||
result.config,
|
||||
result.address,
|
||||
result.name,
|
||||
() => this._mainService.disconnect(result.connectionId),
|
||||
);
|
||||
|
||||
this._connections.set(result.connectionId, handle);
|
||||
this._onDidChangeConnections.fire();
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
private _createRelayClient(result: { connectionId: string; address: string }): RemoteAgentHostProtocolClient {
|
||||
const transport = new SSHRelayTransport(result.connectionId, this._mainService);
|
||||
return this._instantiationService.createInstance(
|
||||
RemoteAgentHostProtocolClient, result.address, transport,
|
||||
);
|
||||
}
|
||||
|
||||
private _augmentConfig(config: ISSHAgentHostConfig): ISSHAgentHostConfig {
|
||||
const commandOverride = this._getRemoteAgentHostCommand();
|
||||
if (commandOverride) {
|
||||
return { ...config, remoteAgentHostCommand: commandOverride };
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
private _getRemoteAgentHostCommand(): string | undefined {
|
||||
return this._configurationService.getValue<string>('chat.sshRemoteAgentHostCommand') || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight renderer-side handle that represents a connection
|
||||
* managed by the main process.
|
||||
*/
|
||||
class SSHAgentHostConnectionHandle extends Disposable implements ISSHAgentHostConnection {
|
||||
private readonly _onDidClose = this._register(new Emitter<void>());
|
||||
readonly onDidClose = this._onDidClose.event;
|
||||
|
||||
private _closedByMain = false;
|
||||
|
||||
constructor(
|
||||
readonly config: ISSHAgentHostConnection['config'],
|
||||
readonly localAddress: string,
|
||||
readonly name: string,
|
||||
disconnectFn: () => Promise<void>,
|
||||
) {
|
||||
super();
|
||||
|
||||
// When this handle is disposed, tear down the main-process tunnel
|
||||
// (skip if already closed from the main process side)
|
||||
this._register(toDisposable(() => {
|
||||
if (!this._closedByMain) {
|
||||
disconnectFn().catch(() => { /* best effort */ });
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/** Called by the service when the main process signals connection closure. */
|
||||
fireClose(): void {
|
||||
this._closedByMain = true;
|
||||
this._onDidClose.fire();
|
||||
}
|
||||
}
|
||||
@@ -10,16 +10,16 @@ import { Emitter } from '../../../base/common/event.js';
|
||||
import { Disposable } from '../../../base/common/lifecycle.js';
|
||||
import { connectionTokenQueryName } from '../../../base/common/network.js';
|
||||
import type { IAhpServerNotification, IJsonRpcResponse, IProtocolMessage } from '../common/state/sessionProtocol.js';
|
||||
import type { IProtocolTransport } from '../common/state/sessionTransport.js';
|
||||
import type { IClientTransport } from '../common/state/sessionTransport.js';
|
||||
|
||||
// ---- Client transport -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A WebSocket client transport that connects to a remote agent host server.
|
||||
* Uses the native browser WebSocket API (available in Electron renderer).
|
||||
* Implements {@link IProtocolTransport} with JSON serialization and URI revival.
|
||||
* Implements {@link IClientTransport} with JSON serialization and URI revival.
|
||||
*/
|
||||
export class WebSocketClientTransport extends Disposable implements IProtocolTransport {
|
||||
export class WebSocketClientTransport extends Disposable implements IClientTransport {
|
||||
|
||||
private readonly _onMessage = this._register(new Emitter<IProtocolMessage>());
|
||||
readonly onMessage = this._onMessage.event;
|
||||
|
||||
658
src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts
Normal file
658
src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts
Normal file
@@ -0,0 +1,658 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import type WebSocket from 'ws';
|
||||
import { createRequire } from 'node:module';
|
||||
import { promises as fsp } from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as cp from 'child_process';
|
||||
import { dirname, join, isAbsolute, basename } from '../../../base/common/path.js';
|
||||
import { Emitter, Event } from '../../../base/common/event.js';
|
||||
import { Disposable, toDisposable } from '../../../base/common/lifecycle.js';
|
||||
import { localize } from '../../../nls.js';
|
||||
import { ILogService } from '../../log/common/log.js';
|
||||
import {
|
||||
ISSHRemoteAgentHostMainService,
|
||||
SSHAuthMethod,
|
||||
type ISSHAgentHostConfig,
|
||||
type ISSHAgentHostConfigSanitized,
|
||||
type ISSHConnectProgress,
|
||||
type ISSHConnectResult,
|
||||
type ISSHRelayMessage,
|
||||
type ISSHResolvedConfig,
|
||||
} from '../common/sshRemoteAgentHost.js';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
|
||||
/** Minimal subset of ssh2.ClientChannel used by this module (duplex stream). */
|
||||
interface SSHChannel extends NodeJS.ReadWriteStream {
|
||||
on(event: 'data', listener: (data: Buffer) => void): this;
|
||||
on(event: 'close', listener: (code: number) => void): this;
|
||||
on(event: 'error', listener: (err: Error) => void): this;
|
||||
on(event: string, listener: (...args: unknown[]) => void): this;
|
||||
stderr: { on(event: 'data', listener: (data: Buffer) => void): void };
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/** Minimal subset of ssh2.Client used by this module. */
|
||||
interface SSHClient {
|
||||
on(event: 'ready', listener: () => void): SSHClient;
|
||||
on(event: 'error', listener: (err: Error) => void): SSHClient;
|
||||
on(event: 'close', listener: () => void): SSHClient;
|
||||
connect(config: Record<string, unknown>): void;
|
||||
exec(command: string, callback: (err: Error | undefined, stream: SSHChannel) => void): SSHClient;
|
||||
forwardOut(srcIP: string, srcPort: number, dstIP: string, dstPort: number, callback: (err: Error | undefined, channel: SSHChannel) => void): SSHClient;
|
||||
end(): void;
|
||||
}
|
||||
|
||||
const LOG_PREFIX = '[SSHRemoteAgentHost]';
|
||||
|
||||
/** Install location for the VS Code CLI on the remote machine. */
|
||||
const REMOTE_CLI_DIR = '~/.vscode-cli';
|
||||
const REMOTE_CLI_BIN = `${REMOTE_CLI_DIR}/code`;
|
||||
|
||||
/** Escape a string for use as a single shell argument (single-quote wrapping). */
|
||||
function shellEscape(s: string): string {
|
||||
// Wrap in single quotes; escape embedded single quotes as: '\''
|
||||
const escaped = s.replace(/'/g, '\'\\\'\'');
|
||||
return `'${escaped}'`;
|
||||
}
|
||||
|
||||
function resolveRemotePlatform(unameS: string, unameM: string): { os: string; arch: string } | undefined {
|
||||
const os = unameS.trim().toLowerCase();
|
||||
const machine = unameM.trim().toLowerCase();
|
||||
|
||||
let platformOs: string;
|
||||
if (os === 'linux') {
|
||||
platformOs = 'linux';
|
||||
} else if (os === 'darwin') {
|
||||
platformOs = 'darwin';
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let arch: string;
|
||||
if (machine === 'x86_64' || machine === 'amd64') {
|
||||
arch = 'x64';
|
||||
} else if (machine === 'aarch64' || machine === 'arm64') {
|
||||
arch = 'arm64';
|
||||
} else if (machine === 'armv7l') {
|
||||
arch = 'armhf';
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { os: platformOs, arch };
|
||||
}
|
||||
|
||||
function buildCLIDownloadUrl(os: string, arch: string, quality: string): string {
|
||||
return `https://update.code.visualstudio.com/latest/cli-${os}-${arch}/${quality}`;
|
||||
}
|
||||
|
||||
function sshExec(client: SSHClient, command: string, opts?: { ignoreExitCode?: boolean }): Promise<{ stdout: string; stderr: string; code: number }> {
|
||||
return new Promise<{ stdout: string; stderr: string; code: number }>((resolve, reject) => {
|
||||
client.exec(command, (err: Error | undefined, stream: SSHChannel) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
|
||||
const finish = (error: Error | undefined, code: number | undefined) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
if (code !== 0 && !opts?.ignoreExitCode) {
|
||||
reject(new Error(`SSH command failed (exit ${code}): ${command}\nstderr: ${stderr}`));
|
||||
} else {
|
||||
resolve({ stdout, stderr, code: code ?? 0 });
|
||||
}
|
||||
};
|
||||
|
||||
stream.on('data', (data: Buffer) => { stdout += data.toString(); });
|
||||
stream.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
|
||||
stream.on('error', (streamErr: Error) => finish(streamErr, undefined));
|
||||
stream.on('close', (code: number) => finish(undefined, code));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Redact connection tokens from log output. */
|
||||
function redactToken(text: string): string {
|
||||
return text.replace(/\?tkn=[^\s&]+/g, '?tkn=***');
|
||||
}
|
||||
|
||||
function startRemoteAgentHost(
|
||||
client: SSHClient,
|
||||
logService: ILogService,
|
||||
commandOverride?: string,
|
||||
): Promise<{ port: number; connectionToken: string | undefined; stream: SSHChannel }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const baseCmd = commandOverride ?? `${REMOTE_CLI_BIN} agent-host --port 0 --accept-server-license-terms`;
|
||||
// Wrap in a login shell so the agent host process inherits the
|
||||
// user's PATH and environment from ~/.bash_profile / ~/.bashrc
|
||||
// (ssh2 exec runs a non-interactive non-login shell by default).
|
||||
const cmd = `bash -l -c ${shellEscape(baseCmd)}`;
|
||||
logService.info(`${LOG_PREFIX} Starting remote agent host: ${cmd}`);
|
||||
|
||||
client.exec(cmd, (err: Error | undefined, stream: SSHChannel) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
let resolved = false;
|
||||
let outputBuf = '';
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(new Error(`${LOG_PREFIX} Timed out waiting for agent host to start.\noutput so far: ${redactToken(outputBuf)}`));
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
const checkForAddress = () => {
|
||||
if (!resolved) {
|
||||
const match = outputBuf.match(/ws:\/\/127\.0\.0\.1:(\d+)(?:\?tkn=([^\s&]+))?/);
|
||||
if (match) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
const port = parseInt(match[1], 10);
|
||||
const connectionToken = match[2] || undefined;
|
||||
logService.info(`${LOG_PREFIX} Remote agent host listening on port ${port}`);
|
||||
resolve({ port, connectionToken, stream });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
stream.stderr.on('data', (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
outputBuf += text;
|
||||
logService.trace(`${LOG_PREFIX} remote stderr: ${redactToken(text.trimEnd())}`);
|
||||
checkForAddress();
|
||||
});
|
||||
|
||||
stream.on('data', (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
outputBuf += text;
|
||||
logService.trace(`${LOG_PREFIX} remote stdout: ${redactToken(text.trimEnd())}`);
|
||||
checkForAddress();
|
||||
});
|
||||
|
||||
stream.on('close', (code: number) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`${LOG_PREFIX} Agent host process exited with code ${code} before becoming ready.\noutput: ${redactToken(outputBuf)}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a WebSocket connection to the remote agent host via an SSH forwarded channel.
|
||||
* Uses the `ws` library to speak WebSocket over the SSH channel.
|
||||
* Messages are relayed to the renderer via IPC events.
|
||||
*/
|
||||
function createWebSocketRelay(
|
||||
client: SSHClient,
|
||||
dstHost: string,
|
||||
dstPort: number,
|
||||
connectionToken: string | undefined,
|
||||
logService: ILogService,
|
||||
onMessage: (data: string) => void,
|
||||
onClose: () => void,
|
||||
): Promise<{ send: (data: string) => void; close: () => void }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.forwardOut('127.0.0.1', 0, dstHost, dstPort, (err: Error | undefined, channel: SSHChannel) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const WS = _require('ws') as typeof WebSocket;
|
||||
let url = `ws://${dstHost}:${dstPort}`;
|
||||
if (connectionToken) {
|
||||
url += `?tkn=${encodeURIComponent(connectionToken)}`;
|
||||
}
|
||||
|
||||
// The SSH channel is a duplex stream compatible with ws's createConnection,
|
||||
// but our minimal SSHChannel interface doesn't carry the full Node Duplex shape.
|
||||
const ws = new WS(url, { createConnection: (() => channel) as unknown as WebSocket.ClientOptions['createConnection'] });
|
||||
|
||||
ws.on('open', () => {
|
||||
logService.info(`${LOG_PREFIX} WebSocket relay connected to remote agent host`);
|
||||
resolve({
|
||||
send: (data: string) => {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
},
|
||||
close: () => ws.close(),
|
||||
});
|
||||
});
|
||||
|
||||
ws.on('message', (data: WebSocket.RawData) => {
|
||||
if (Array.isArray(data)) {
|
||||
onMessage(Buffer.concat(data).toString());
|
||||
} else if (data instanceof ArrayBuffer) {
|
||||
onMessage(Buffer.from(new Uint8Array(data)).toString());
|
||||
} else {
|
||||
onMessage(data.toString());
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', onClose);
|
||||
|
||||
ws.on('error', (wsErr: unknown) => {
|
||||
logService.warn(`${LOG_PREFIX} WebSocket relay error: ${wsErr instanceof Error ? wsErr.message : String(wsErr)}`);
|
||||
reject(wsErr);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeConfig(config: ISSHAgentHostConfig): ISSHAgentHostConfigSanitized {
|
||||
const { password: _p, privateKeyPath: _k, ...sanitized } = config;
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
class SSHConnection extends Disposable {
|
||||
private readonly _onDidClose = this._register(new Emitter<void>());
|
||||
readonly onDidClose = this._onDidClose.event;
|
||||
|
||||
readonly config: ISSHAgentHostConfigSanitized;
|
||||
private _closed = false;
|
||||
|
||||
constructor(
|
||||
fullConfig: ISSHAgentHostConfig,
|
||||
readonly connectionId: string,
|
||||
readonly address: string,
|
||||
readonly name: string,
|
||||
readonly connectionToken: string | undefined,
|
||||
sshClient: SSHClient,
|
||||
private readonly _relay: { send: (data: string) => void; close: () => void },
|
||||
remoteStream: SSHChannel,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.config = sanitizeConfig(fullConfig);
|
||||
|
||||
this._register(toDisposable(() => {
|
||||
if (this._closed) {
|
||||
return;
|
||||
}
|
||||
this._closed = true;
|
||||
this._relay.close();
|
||||
remoteStream.close();
|
||||
sshClient.end();
|
||||
this._onDidClose.fire();
|
||||
}));
|
||||
|
||||
sshClient.on('close', () => {
|
||||
this.dispose();
|
||||
});
|
||||
|
||||
sshClient.on('error', () => {
|
||||
this.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
relaySend(data: string): void {
|
||||
this._relay.send(data);
|
||||
}
|
||||
}
|
||||
|
||||
import { parseSSHConfigHostEntries, parseSSHGOutput, stripSSHComment } from '../common/sshConfigParsing.js';
|
||||
|
||||
export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRemoteAgentHostMainService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly _onDidChangeConnections = this._register(new Emitter<void>());
|
||||
readonly onDidChangeConnections: Event<void> = this._onDidChangeConnections.event;
|
||||
|
||||
private readonly _onDidCloseConnection = this._register(new Emitter<string>());
|
||||
readonly onDidCloseConnection: Event<string> = this._onDidCloseConnection.event;
|
||||
|
||||
private readonly _onDidReportConnectProgress = this._register(new Emitter<ISSHConnectProgress>());
|
||||
readonly onDidReportConnectProgress: Event<ISSHConnectProgress> = this._onDidReportConnectProgress.event;
|
||||
|
||||
private readonly _onDidRelayMessage = this._register(new Emitter<ISSHRelayMessage>());
|
||||
readonly onDidRelayMessage: Event<ISSHRelayMessage> = this._onDidRelayMessage.event;
|
||||
|
||||
private readonly _onDidRelayClose = this._register(new Emitter<string>());
|
||||
readonly onDidRelayClose: Event<string> = this._onDidRelayClose.event;
|
||||
|
||||
private readonly _connections = new Map<string, SSHConnection>();
|
||||
|
||||
constructor(
|
||||
@ILogService private readonly _logService: ILogService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async connect(config: ISSHAgentHostConfig): Promise<ISSHConnectResult> {
|
||||
const connectionKey = config.sshConfigHost
|
||||
? `ssh:${config.sshConfigHost}`
|
||||
: `${config.username}@${config.host}:${config.port ?? 22}`;
|
||||
|
||||
const existing = this._connections.get(connectionKey);
|
||||
if (existing) {
|
||||
return {
|
||||
connectionId: existing.connectionId,
|
||||
address: existing.address,
|
||||
name: existing.name,
|
||||
connectionToken: existing.connectionToken,
|
||||
config: existing.config,
|
||||
};
|
||||
}
|
||||
|
||||
this._logService.info(`${LOG_PREFIX} Connecting to ${connectionKey}...`);
|
||||
let sshClient: SSHClient | undefined;
|
||||
|
||||
try {
|
||||
const ssh2Module = _require('ssh2') as { Client: new () => unknown };
|
||||
|
||||
const reportProgress = (message: string) => {
|
||||
this._onDidReportConnectProgress.fire({ connectionKey, message });
|
||||
};
|
||||
|
||||
// 1. Establish SSH connection
|
||||
reportProgress(localize('sshProgressConnecting', "Establishing SSH connection..."));
|
||||
sshClient = await this._connectSSH(config, ssh2Module.Client);
|
||||
|
||||
if (config.remoteAgentHostCommand) {
|
||||
// Dev override: skip platform detection and CLI install,
|
||||
// use the provided command directly.
|
||||
this._logService.info(`${LOG_PREFIX} Using custom agent host command: ${config.remoteAgentHostCommand}`);
|
||||
} else {
|
||||
// 2. Detect remote platform
|
||||
const { stdout: unameS } = await sshExec(sshClient, 'uname -s');
|
||||
const { stdout: unameM } = await sshExec(sshClient, 'uname -m');
|
||||
const platform = resolveRemotePlatform(unameS, unameM);
|
||||
if (!platform) {
|
||||
throw new Error(`${LOG_PREFIX} Unsupported remote platform: ${unameS.trim()} ${unameM.trim()}`);
|
||||
}
|
||||
this._logService.info(`${LOG_PREFIX} Remote platform: ${platform.os}-${platform.arch}`);
|
||||
|
||||
// 3. Install CLI if needed
|
||||
reportProgress(localize('sshProgressInstallingCLI', "Checking remote CLI installation..."));
|
||||
await this._ensureCLIInstalled(sshClient, platform, reportProgress);
|
||||
}
|
||||
|
||||
// 4. Start agent-host and capture port/token
|
||||
reportProgress(localize('sshProgressStartingAgent', "Starting remote agent host..."));
|
||||
const { port: remotePort, connectionToken, stream: agentStream } = await startRemoteAgentHost(sshClient, this._logService, config.remoteAgentHostCommand);
|
||||
|
||||
// 5. Connect to remote agent host via WebSocket relay (no local TCP port)
|
||||
reportProgress(localize('sshProgressForwarding', "Connecting to remote agent host..."));
|
||||
const connectionId = connectionKey;
|
||||
const relay = await createWebSocketRelay(
|
||||
sshClient, '127.0.0.1', remotePort, connectionToken, this._logService,
|
||||
(data: string) => this._onDidRelayMessage.fire({ connectionId, data }),
|
||||
() => this._onDidRelayClose.fire(connectionId),
|
||||
);
|
||||
|
||||
// 6. Create connection object
|
||||
const address = connectionKey;
|
||||
const conn = new SSHConnection(
|
||||
config,
|
||||
connectionId,
|
||||
address,
|
||||
config.name,
|
||||
connectionToken,
|
||||
sshClient,
|
||||
relay,
|
||||
agentStream,
|
||||
);
|
||||
|
||||
conn.onDidClose(() => {
|
||||
this._connections.delete(connectionKey);
|
||||
this._onDidCloseConnection.fire(connectionId);
|
||||
this._onDidChangeConnections.fire();
|
||||
});
|
||||
|
||||
this._connections.set(connectionKey, conn);
|
||||
sshClient = undefined; // ownership transferred to SSHConnection
|
||||
|
||||
this._onDidChangeConnections.fire();
|
||||
|
||||
return {
|
||||
connectionId,
|
||||
address,
|
||||
name: config.name,
|
||||
connectionToken,
|
||||
config: conn.config,
|
||||
sshConfigHost: config.sshConfigHost,
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
sshClient?.end();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(host: string): Promise<void> {
|
||||
for (const [key, conn] of this._connections) {
|
||||
if (key === host || conn.connectionId === host) {
|
||||
conn.dispose();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async relaySend(connectionId: string, message: string): Promise<void> {
|
||||
for (const conn of this._connections.values()) {
|
||||
if (conn.connectionId === connectionId) {
|
||||
conn.relaySend(message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async reconnect(sshConfigHost: string, name: string, remoteAgentHostCommand?: string): Promise<ISSHConnectResult> {
|
||||
this._logService.info(`${LOG_PREFIX} Reconnecting via SSH config host: ${sshConfigHost}`);
|
||||
const resolved = await this.resolveSSHConfig(sshConfigHost);
|
||||
|
||||
let authMethod: SSHAuthMethod = SSHAuthMethod.Agent;
|
||||
let privateKeyPath: string | undefined;
|
||||
const defaultKeys = ['~/.ssh/id_rsa', '~/.ssh/id_ecdsa', '~/.ssh/id_ed25519', '~/.ssh/id_dsa', '~/.ssh/id_xmss'];
|
||||
if (resolved.identityFile.length > 0 && !defaultKeys.includes(resolved.identityFile[0])) {
|
||||
authMethod = SSHAuthMethod.KeyFile;
|
||||
privateKeyPath = resolved.identityFile[0];
|
||||
}
|
||||
|
||||
return this.connect({
|
||||
host: resolved.hostname,
|
||||
port: resolved.port !== 22 ? resolved.port : undefined,
|
||||
username: resolved.user ?? sshConfigHost,
|
||||
authMethod,
|
||||
privateKeyPath,
|
||||
name,
|
||||
sshConfigHost,
|
||||
remoteAgentHostCommand,
|
||||
});
|
||||
}
|
||||
|
||||
async listSSHConfigHosts(): Promise<string[]> {
|
||||
const configPath = join(os.homedir(), '.ssh', 'config');
|
||||
try {
|
||||
const content = await fsp.readFile(configPath, 'utf-8');
|
||||
return this._parseSSHConfigHosts(content, dirname(configPath));
|
||||
} catch {
|
||||
this._logService.info(`${LOG_PREFIX} Could not read SSH config at ${configPath}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async resolveSSHConfig(host: string): Promise<ISSHResolvedConfig> {
|
||||
return new Promise<ISSHResolvedConfig>((resolve, reject) => {
|
||||
cp.execFile('ssh', ['-G', host], { timeout: 5000 }, (err, stdout) => {
|
||||
if (err) {
|
||||
reject(new Error(`${LOG_PREFIX} ssh -G failed for ${host}: ${err.message}`));
|
||||
return;
|
||||
}
|
||||
const config = this._parseSSHGOutput(stdout);
|
||||
resolve(config);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async _parseSSHConfigHosts(content: string, configDir: string, visited?: Set<string>): Promise<string[]> {
|
||||
const seen = visited ?? new Set<string>();
|
||||
const hosts: string[] = [];
|
||||
|
||||
// Extract hosts from this file directly
|
||||
hosts.push(...parseSSHConfigHostEntries(content));
|
||||
|
||||
// Follow Include directives
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
const includeMatch = trimmed.match(/^Include\s+(.+)$/i);
|
||||
if (!includeMatch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawValue = stripSSHComment(includeMatch[1]);
|
||||
const patterns = rawValue.split(/\s+/).filter(Boolean);
|
||||
|
||||
for (const rawPattern of patterns) {
|
||||
const pattern = rawPattern.replace(/^~/, os.homedir());
|
||||
const resolvedPattern = isAbsolute(pattern) ? pattern : join(configDir, pattern);
|
||||
|
||||
if (seen.has(resolvedPattern)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(resolvedPattern);
|
||||
|
||||
try {
|
||||
const stat = await fsp.stat(resolvedPattern);
|
||||
if (stat.isDirectory()) {
|
||||
const files = await fsp.readdir(resolvedPattern);
|
||||
for (const file of files) {
|
||||
try {
|
||||
const sub = await fsp.readFile(join(resolvedPattern, file), 'utf-8');
|
||||
hosts.push(...await this._parseSSHConfigHosts(sub, resolvedPattern, seen));
|
||||
} catch { /* skip unreadable files */ }
|
||||
}
|
||||
} else {
|
||||
const sub = await fsp.readFile(resolvedPattern, 'utf-8');
|
||||
hosts.push(...await this._parseSSHConfigHosts(sub, dirname(resolvedPattern), seen));
|
||||
}
|
||||
} catch {
|
||||
const dir = dirname(resolvedPattern);
|
||||
const base = basename(resolvedPattern);
|
||||
if (base.includes('*')) {
|
||||
try {
|
||||
const files = await fsp.readdir(dir);
|
||||
for (const file of files) {
|
||||
const regex = new RegExp('^' + base.replace(/\*/g, '.*') + '$');
|
||||
if (regex.test(file)) {
|
||||
try {
|
||||
const sub = await fsp.readFile(join(dir, file), 'utf-8');
|
||||
hosts.push(...await this._parseSSHConfigHosts(sub, dir, seen));
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
} catch { /* skip unreadable dirs */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return hosts;
|
||||
}
|
||||
|
||||
private _parseSSHGOutput(stdout: string): ISSHResolvedConfig {
|
||||
return parseSSHGOutput(stdout);
|
||||
}
|
||||
|
||||
private async _connectSSH(
|
||||
config: ISSHAgentHostConfig,
|
||||
SSHClientCtor: new () => unknown,
|
||||
): Promise<SSHClient> {
|
||||
const connectConfig: Record<string, unknown> = {
|
||||
host: config.host,
|
||||
port: config.port ?? 22,
|
||||
username: config.username,
|
||||
readyTimeout: 30_000,
|
||||
keepaliveInterval: 15_000,
|
||||
};
|
||||
|
||||
switch (config.authMethod) {
|
||||
case SSHAuthMethod.Agent: {
|
||||
const agentSock = process.env['SSH_AUTH_SOCK'];
|
||||
this._logService.info(`${LOG_PREFIX} Using SSH agent: ${agentSock ?? '(not set)'}`);
|
||||
connectConfig.agent = agentSock;
|
||||
break;
|
||||
}
|
||||
case SSHAuthMethod.KeyFile:
|
||||
if (config.privateKeyPath) {
|
||||
const keyPath = config.privateKeyPath.replace(/^~/, os.homedir());
|
||||
connectConfig.privateKey = await fsp.readFile(keyPath);
|
||||
}
|
||||
break;
|
||||
case SSHAuthMethod.Password:
|
||||
connectConfig.password = config.password;
|
||||
break;
|
||||
}
|
||||
|
||||
return new Promise<SSHClient>((resolve, reject) => {
|
||||
const client = new SSHClientCtor() as SSHClient;
|
||||
|
||||
client.on('ready', () => {
|
||||
this._logService.info(`${LOG_PREFIX} SSH connection established to ${config.host}`);
|
||||
resolve(client);
|
||||
});
|
||||
|
||||
client.on('error', (err: Error) => {
|
||||
this._logService.error(`${LOG_PREFIX} SSH connection error: ${err.message}`);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
client.connect(connectConfig);
|
||||
});
|
||||
}
|
||||
|
||||
private async _ensureCLIInstalled(client: SSHClient, platform: { os: string; arch: string }, reportProgress: (message: string) => void): Promise<void> {
|
||||
const { code } = await sshExec(client, `${REMOTE_CLI_BIN} --version`, { ignoreExitCode: true });
|
||||
if (code === 0) {
|
||||
this._logService.info(`${LOG_PREFIX} VS Code CLI already installed on remote`);
|
||||
return;
|
||||
}
|
||||
|
||||
reportProgress(localize('sshProgressDownloadingCLI', "Installing VS Code CLI on remote..."));
|
||||
const quality = 'stable';
|
||||
const url = buildCLIDownloadUrl(platform.os, platform.arch, quality);
|
||||
|
||||
const installCmd = [
|
||||
`mkdir -p ${REMOTE_CLI_DIR}`,
|
||||
`curl -fsSL '${url}' | tar xz -C ${REMOTE_CLI_DIR}`,
|
||||
`chmod +x ${REMOTE_CLI_BIN}`,
|
||||
].join(' && ');
|
||||
|
||||
await sshExec(client, installCmd);
|
||||
this._logService.info(`${LOG_PREFIX} VS Code CLI installed successfully`);
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
for (const conn of this._connections.values()) {
|
||||
conn.dispose();
|
||||
}
|
||||
this._connections.clear();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
226
src/vs/platform/agentHost/test/common/sshConfigParsing.test.ts
Normal file
226
src/vs/platform/agentHost/test/common/sshConfigParsing.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import assert from 'assert';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
|
||||
import { parseSSHConfigHostEntries, parseSSHGOutput } from '../../common/sshConfigParsing.js';
|
||||
|
||||
suite('SSH Config Parsing', () => {
|
||||
|
||||
ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
suite('parseSSHConfigHostEntries', () => {
|
||||
|
||||
test('extracts simple host entries', () => {
|
||||
const config = [
|
||||
'Host myserver',
|
||||
' HostName 10.0.0.1',
|
||||
' User admin',
|
||||
].join('\n');
|
||||
|
||||
assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']);
|
||||
});
|
||||
|
||||
test('extracts multiple hosts from a single Host line', () => {
|
||||
const config = 'Host server1 server2 server3';
|
||||
assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['server1', 'server2', 'server3']);
|
||||
});
|
||||
|
||||
test('extracts hosts from multiple Host directives', () => {
|
||||
const config = [
|
||||
'Host work',
|
||||
' HostName work.example.com',
|
||||
'',
|
||||
'Host personal',
|
||||
' HostName home.example.com',
|
||||
].join('\n');
|
||||
|
||||
assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['work', 'personal']);
|
||||
});
|
||||
|
||||
test('skips wildcard hosts', () => {
|
||||
const config = [
|
||||
'Host *',
|
||||
' ForwardAgent yes',
|
||||
'',
|
||||
'Host myserver',
|
||||
' HostName 10.0.0.1',
|
||||
'',
|
||||
'Host *.example.com',
|
||||
' User admin',
|
||||
].join('\n');
|
||||
|
||||
assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']);
|
||||
});
|
||||
|
||||
test('skips negation patterns', () => {
|
||||
const config = 'Host !internal myserver';
|
||||
assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']);
|
||||
});
|
||||
|
||||
test('skips question mark wildcards', () => {
|
||||
const config = 'Host server? myserver';
|
||||
assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']);
|
||||
});
|
||||
|
||||
test('skips comment lines', () => {
|
||||
const config = [
|
||||
'# This is a comment',
|
||||
'Host myserver',
|
||||
' # Another comment',
|
||||
' HostName 10.0.0.1',
|
||||
].join('\n');
|
||||
|
||||
assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']);
|
||||
});
|
||||
|
||||
test('strips inline comments from Host values', () => {
|
||||
const config = 'Host myserver # my favorite server';
|
||||
assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']);
|
||||
});
|
||||
|
||||
test('handles empty content', () => {
|
||||
assert.deepStrictEqual(parseSSHConfigHostEntries(''), []);
|
||||
});
|
||||
|
||||
test('handles content with only comments and blanks', () => {
|
||||
const config = [
|
||||
'# comment',
|
||||
'',
|
||||
' # indented comment',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
assert.deepStrictEqual(parseSSHConfigHostEntries(config), []);
|
||||
});
|
||||
|
||||
test('is case-insensitive for Host keyword', () => {
|
||||
const config = [
|
||||
'host lower',
|
||||
'HOST upper',
|
||||
'Host mixed',
|
||||
].join('\n');
|
||||
|
||||
assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['lower', 'upper', 'mixed']);
|
||||
});
|
||||
|
||||
test('ignores non-Host directives', () => {
|
||||
const config = [
|
||||
'Host myserver',
|
||||
' HostName 10.0.0.1',
|
||||
' User admin',
|
||||
' Port 2222',
|
||||
' IdentityFile ~/.ssh/mykey',
|
||||
' ForwardAgent yes',
|
||||
].join('\n');
|
||||
|
||||
assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']);
|
||||
});
|
||||
});
|
||||
|
||||
suite('parseSSHGOutput', () => {
|
||||
|
||||
test('parses standard ssh -G output', () => {
|
||||
const output = [
|
||||
'hostname 10.0.0.1',
|
||||
'user admin',
|
||||
'port 22',
|
||||
'identityfile ~/.ssh/id_rsa',
|
||||
'identityfile ~/.ssh/id_ed25519',
|
||||
'forwardagent no',
|
||||
].join('\n');
|
||||
|
||||
assert.deepStrictEqual(parseSSHGOutput(output), {
|
||||
hostname: '10.0.0.1',
|
||||
user: 'admin',
|
||||
port: 22,
|
||||
identityFile: ['~/.ssh/id_rsa', '~/.ssh/id_ed25519'],
|
||||
forwardAgent: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('parses forwardagent yes', () => {
|
||||
const output = [
|
||||
'hostname example.com',
|
||||
'user root',
|
||||
'port 22',
|
||||
'forwardagent yes',
|
||||
].join('\n');
|
||||
|
||||
const result = parseSSHGOutput(output);
|
||||
assert.strictEqual(result.forwardAgent, true);
|
||||
});
|
||||
|
||||
test('parses non-standard port', () => {
|
||||
const output = [
|
||||
'hostname example.com',
|
||||
'user deploy',
|
||||
'port 2222',
|
||||
].join('\n');
|
||||
|
||||
const result = parseSSHGOutput(output);
|
||||
assert.strictEqual(result.port, 2222);
|
||||
});
|
||||
|
||||
test('handles missing user', () => {
|
||||
const output = [
|
||||
'hostname example.com',
|
||||
'port 22',
|
||||
].join('\n');
|
||||
|
||||
const result = parseSSHGOutput(output);
|
||||
assert.strictEqual(result.user, undefined);
|
||||
});
|
||||
|
||||
test('handles empty user', () => {
|
||||
const output = [
|
||||
'hostname example.com',
|
||||
'user ',
|
||||
'port 22',
|
||||
].join('\n');
|
||||
|
||||
const result = parseSSHGOutput(output);
|
||||
assert.strictEqual(result.user, undefined);
|
||||
});
|
||||
|
||||
test('defaults port to 22 when missing', () => {
|
||||
const output = 'hostname example.com\nuser root';
|
||||
const result = parseSSHGOutput(output);
|
||||
assert.strictEqual(result.port, 22);
|
||||
});
|
||||
|
||||
test('collects multiple identity files', () => {
|
||||
const output = [
|
||||
'hostname example.com',
|
||||
'port 22',
|
||||
'identityfile ~/.ssh/id_rsa',
|
||||
'identityfile ~/.ssh/work_key',
|
||||
'identityfile ~/.ssh/id_ed25519',
|
||||
].join('\n');
|
||||
|
||||
assert.deepStrictEqual(parseSSHGOutput(output).identityFile, [
|
||||
'~/.ssh/id_rsa',
|
||||
'~/.ssh/work_key',
|
||||
'~/.ssh/id_ed25519',
|
||||
]);
|
||||
});
|
||||
|
||||
test('handles empty output', () => {
|
||||
assert.deepStrictEqual(parseSSHGOutput(''), {
|
||||
hostname: '',
|
||||
user: undefined,
|
||||
port: 22,
|
||||
identityFile: [],
|
||||
forwardAgent: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('handles values with spaces', () => {
|
||||
const output = 'hostname my host with spaces\nport 22';
|
||||
const result = parseSSHGOutput(output);
|
||||
assert.strictEqual(result.hostname, 'my host with spaces');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -217,11 +217,10 @@ suite('RemoteAgentHostService', () => {
|
||||
configService.setEntries([{ address: 'ws://bad:9999', name: 'Bad' }]);
|
||||
assert.strictEqual(createdClients.length, 1);
|
||||
|
||||
// Fail the connection
|
||||
// Fail the connection and wait for the service to react
|
||||
const connectionChanged = Event.toPromise(service.onDidChangeConnections);
|
||||
createdClients[0].connectDeferred.error(new Error('Connection refused'));
|
||||
|
||||
// Wait for async error handling
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
await connectionChanged;
|
||||
|
||||
assert.strictEqual(service.connections.length, 0);
|
||||
assert.strictEqual(service.getConnection('ws://bad:9999'), undefined);
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import assert from 'assert';
|
||||
import { Emitter, Event } from '../../../../base/common/event.js';
|
||||
import { DisposableStore } from '../../../../base/common/lifecycle.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
|
||||
import { SSHRelayTransport } from '../../electron-browser/sshRelayTransport.js';
|
||||
import type { ISSHRelayMessage, ISSHRemoteAgentHostMainService, ISSHConnectProgress, ISSHConnectResult, ISSHAgentHostConfig, ISSHResolvedConfig } from '../../common/sshRemoteAgentHost.js';
|
||||
|
||||
/**
|
||||
* Minimal mock of ISSHRemoteAgentHostMainService for testing the relay transport.
|
||||
*/
|
||||
class MockSSHMainService {
|
||||
private readonly _onDidRelayMessage = new Emitter<ISSHRelayMessage>();
|
||||
readonly onDidRelayMessage = this._onDidRelayMessage.event;
|
||||
|
||||
private readonly _onDidRelayClose = new Emitter<string>();
|
||||
readonly onDidRelayClose = this._onDidRelayClose.event;
|
||||
|
||||
readonly onDidChangeConnections = Event.None;
|
||||
readonly onDidCloseConnection = Event.None;
|
||||
readonly onDidReportConnectProgress = Event.None as Event<ISSHConnectProgress>;
|
||||
|
||||
readonly sentMessages: { connectionId: string; message: string }[] = [];
|
||||
|
||||
async relaySend(connectionId: string, message: string): Promise<void> {
|
||||
this.sentMessages.push({ connectionId, message });
|
||||
}
|
||||
|
||||
async connect(_config: ISSHAgentHostConfig): Promise<ISSHConnectResult> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
async disconnect(_host: string): Promise<void> { }
|
||||
async listSSHConfigHosts(): Promise<string[]> { return []; }
|
||||
async resolveSSHConfig(_host: string): Promise<ISSHResolvedConfig> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
async reconnect(_sshConfigHost: string, _name: string): Promise<ISSHConnectResult> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
// Test helpers
|
||||
fireRelayMessage(msg: ISSHRelayMessage): void {
|
||||
this._onDidRelayMessage.fire(msg);
|
||||
}
|
||||
|
||||
fireRelayClose(connectionId: string): void {
|
||||
this._onDidRelayClose.fire(connectionId);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._onDidRelayMessage.dispose();
|
||||
this._onDidRelayClose.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
suite('SSHRelayTransport', () => {
|
||||
|
||||
const disposables = new DisposableStore();
|
||||
let mockService: MockSSHMainService;
|
||||
|
||||
setup(() => {
|
||||
mockService = new MockSSHMainService();
|
||||
disposables.add({ dispose: () => mockService.dispose() });
|
||||
});
|
||||
|
||||
teardown(() => disposables.clear());
|
||||
ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
test('receives messages matching connectionId', () => {
|
||||
const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService));
|
||||
|
||||
const received: unknown[] = [];
|
||||
disposables.add(transport.onMessage(msg => received.push(msg)));
|
||||
|
||||
mockService.fireRelayMessage({ connectionId: 'conn-1', data: '{"jsonrpc":"2.0","id":1}' });
|
||||
|
||||
assert.strictEqual(received.length, 1);
|
||||
assert.deepStrictEqual(received[0], { jsonrpc: '2.0', id: 1 });
|
||||
});
|
||||
|
||||
test('ignores messages for other connectionIds', () => {
|
||||
const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService));
|
||||
|
||||
const received: unknown[] = [];
|
||||
disposables.add(transport.onMessage(msg => received.push(msg)));
|
||||
|
||||
mockService.fireRelayMessage({ connectionId: 'conn-2', data: '{"jsonrpc":"2.0","id":1}' });
|
||||
|
||||
assert.strictEqual(received.length, 0);
|
||||
});
|
||||
|
||||
test('drops malformed JSON messages', () => {
|
||||
const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService));
|
||||
|
||||
const received: unknown[] = [];
|
||||
disposables.add(transport.onMessage(msg => received.push(msg)));
|
||||
|
||||
// Should not throw
|
||||
mockService.fireRelayMessage({ connectionId: 'conn-1', data: 'not-json{{{' });
|
||||
|
||||
assert.strictEqual(received.length, 0);
|
||||
});
|
||||
|
||||
test('fires onClose when relay closes for matching connectionId', () => {
|
||||
const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService));
|
||||
|
||||
let closed = false;
|
||||
disposables.add(transport.onClose(() => { closed = true; }));
|
||||
|
||||
mockService.fireRelayClose('conn-1');
|
||||
|
||||
assert.strictEqual(closed, true);
|
||||
});
|
||||
|
||||
test('does not fire onClose for other connectionIds', () => {
|
||||
const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService));
|
||||
|
||||
let closed = false;
|
||||
disposables.add(transport.onClose(() => { closed = true; }));
|
||||
|
||||
mockService.fireRelayClose('conn-2');
|
||||
|
||||
assert.strictEqual(closed, false);
|
||||
});
|
||||
|
||||
test('send() calls relaySend with correct connectionId', async () => {
|
||||
const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService));
|
||||
|
||||
const msg = { jsonrpc: '2.0' as const, method: 'test', id: 42 };
|
||||
transport.send(msg as never);
|
||||
|
||||
// Give the async relaySend a tick to register
|
||||
await new Promise<void>(r => queueMicrotask(r));
|
||||
|
||||
assert.strictEqual(mockService.sentMessages.length, 1);
|
||||
assert.strictEqual(mockService.sentMessages[0].connectionId, 'conn-1');
|
||||
assert.deepStrictEqual(JSON.parse(mockService.sentMessages[0].message), msg);
|
||||
});
|
||||
|
||||
test('receives multiple messages in order', () => {
|
||||
const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService));
|
||||
|
||||
const received: unknown[] = [];
|
||||
disposables.add(transport.onMessage(msg => received.push(msg)));
|
||||
|
||||
mockService.fireRelayMessage({ connectionId: 'conn-1', data: '{"id":1}' });
|
||||
mockService.fireRelayMessage({ connectionId: 'conn-1', data: '{"id":2}' });
|
||||
mockService.fireRelayMessage({ connectionId: 'conn-1', data: '{"id":3}' });
|
||||
|
||||
assert.strictEqual(received.length, 3);
|
||||
assert.deepStrictEqual(received, [{ id: 1 }, { id: 2 }, { id: 3 }]);
|
||||
});
|
||||
|
||||
test('no events after dispose', () => {
|
||||
const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService));
|
||||
|
||||
const received: unknown[] = [];
|
||||
let closed = false;
|
||||
disposables.add(transport.onMessage(msg => received.push(msg)));
|
||||
disposables.add(transport.onClose(() => { closed = true; }));
|
||||
|
||||
transport.dispose();
|
||||
|
||||
mockService.fireRelayMessage({ connectionId: 'conn-1', data: '{"id":1}' });
|
||||
mockService.fireRelayClose('conn-1');
|
||||
|
||||
assert.strictEqual(received.length, 0);
|
||||
assert.strictEqual(closed, false);
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ import { isSessionAction } from '../../../../platform/agentHost/common/state/ses
|
||||
import { SessionClientState } from '../../../../platform/agentHost/common/state/sessionClientState.js';
|
||||
import { ROOT_STATE_URI, type IAgentInfo, type ICustomizationRef, type IRootState } from '../../../../platform/agentHost/common/state/sessionState.js';
|
||||
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
|
||||
import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js';
|
||||
import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js';
|
||||
import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js';
|
||||
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
|
||||
import { ILogService } from '../../../../platform/log/common/log.js';
|
||||
@@ -40,6 +40,7 @@ import { ISessionsProvidersService } from '../../sessions/browser/sessionsProvid
|
||||
import { createRemoteAgentHarnessDescriptor, RemoteAgentCustomizationItemProvider } from './remoteAgentHostCustomizationHarness.js';
|
||||
import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js';
|
||||
import { SyncedCustomizationBundler } from './syncedCustomizationBundler.js';
|
||||
import { ISSHRemoteAgentHostService } from '../../../../platform/agentHost/common/sshRemoteAgentHost.js';
|
||||
|
||||
/** Per-connection state bundle, disposed when a connection is removed. */
|
||||
class ConnectionState extends Disposable {
|
||||
@@ -80,6 +81,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
|
||||
/** Per-address sessions providers, registered for all configured entries. */
|
||||
private readonly _providerStores = this._register(new DisposableMap<string, DisposableStore>());
|
||||
private readonly _providerInstances = new Map<string, RemoteAgentHostSessionsProvider>();
|
||||
private readonly _pendingSSHReconnects = new Set<string>();
|
||||
|
||||
constructor(
|
||||
@IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService,
|
||||
@@ -93,6 +95,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
|
||||
@ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService,
|
||||
@IConfigurationService private readonly _configurationService: IConfigurationService,
|
||||
@IAgentHostFileSystemService private readonly _agentHostFileSystemService: IAgentHostFileSystemService,
|
||||
@ISSHRemoteAgentHostService private readonly _sshService: ISSHRemoteAgentHostService,
|
||||
@ICustomizationHarnessService private readonly _customizationHarnessService: ICustomizationHarnessService,
|
||||
@IStorageService private readonly _storageService: IStorageService,
|
||||
@IAgentPluginService private readonly _agentPluginService: IAgentPluginService,
|
||||
@@ -122,6 +125,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
|
||||
private _reconcile(): void {
|
||||
this._reconcileProviders();
|
||||
this._reconcileConnections();
|
||||
this._reconnectSSHEntries();
|
||||
|
||||
// Ensure every live connection is wired to its provider.
|
||||
// This covers the case where a provider was recreated (e.g. name
|
||||
@@ -182,6 +186,35 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
|
||||
this._providerStores.set(entry.address, store);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-establish SSH connections for configured entries that have an
|
||||
* sshConfigHost but no active connection.
|
||||
*/
|
||||
private _reconnectSSHEntries(): void {
|
||||
const entries = this._remoteAgentHostService.configuredEntries;
|
||||
for (const entry of entries) {
|
||||
if (!entry.sshConfigHost) {
|
||||
continue;
|
||||
}
|
||||
// Skip if already connected or reconnecting
|
||||
const hasConnection = this._remoteAgentHostService.connections.some(
|
||||
c => c.address === entry.address && c.status === RemoteAgentHostConnectionStatus.Connected
|
||||
);
|
||||
if (hasConnection || this._pendingSSHReconnects.has(entry.sshConfigHost)) {
|
||||
continue;
|
||||
}
|
||||
this._pendingSSHReconnects.add(entry.sshConfigHost);
|
||||
this._logService.info(`[RemoteAgentHost] Re-establishing SSH tunnel for ${entry.sshConfigHost}`);
|
||||
this._sshService.reconnect(entry.sshConfigHost, entry.name).then(() => {
|
||||
this._pendingSSHReconnects.delete(entry.sshConfigHost!);
|
||||
this._logService.info(`[RemoteAgentHost] SSH tunnel re-established for ${entry.sshConfigHost}`);
|
||||
}).catch(err => {
|
||||
this._pendingSSHReconnects.delete(entry.sshConfigHost!);
|
||||
this._logService.error(`[RemoteAgentHost] SSH reconnect failed for ${entry.sshConfigHost}`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _reconcileConnections(): void {
|
||||
const currentConnections = this._remoteAgentHostService.connections;
|
||||
const connectedAddresses = new Set(
|
||||
@@ -537,7 +570,15 @@ Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).regis
|
||||
type: 'boolean',
|
||||
description: nls.localize('chat.remoteAgentHosts.enabled', "Enable connecting to remote agent hosts."),
|
||||
default: false,
|
||||
tags: ['experimental'],
|
||||
scope: ConfigurationScope.APPLICATION,
|
||||
tags: ['experimental', 'advanced'],
|
||||
},
|
||||
'chat.sshRemoteAgentHostCommand': {
|
||||
type: 'string',
|
||||
description: nls.localize('chat.sshRemoteAgentHostCommand', "For development: Override the command used to start the remote agent host over SSH. When set, skips automatic CLI installation and runs this command instead. The command must print a WebSocket URL matching ws://127.0.0.1:PORT (optionally with ?tkn=TOKEN) to stdout or stderr./"),
|
||||
default: '',
|
||||
scope: ConfigurationScope.APPLICATION,
|
||||
tags: ['experimental', 'advanced'],
|
||||
},
|
||||
[RemoteAgentHostsSettingId]: {
|
||||
type: 'array',
|
||||
@@ -547,11 +588,13 @@ Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).regis
|
||||
address: { type: 'string', description: nls.localize('chat.remoteAgentHosts.address', "The address of the remote agent host (e.g. \"localhost:3000\").") },
|
||||
name: { type: 'string', description: nls.localize('chat.remoteAgentHosts.name', "A display name for this remote agent host.") },
|
||||
connectionToken: { type: 'string', description: nls.localize('chat.remoteAgentHosts.connectionToken', "An optional connection token for authenticating with the remote agent host.") },
|
||||
sshConfigHost: { type: 'string', description: nls.localize('chat.remoteAgentHosts.sshConfigHost', "SSH config host alias for automatic reconnection via SSH tunnel.") },
|
||||
},
|
||||
required: ['address', 'name'],
|
||||
},
|
||||
description: nls.localize('chat.remoteAgentHosts', "A list of remote agent host addresses to connect to (e.g. \"localhost:3000\")."),
|
||||
default: [],
|
||||
scope: ConfigurationScope.APPLICATION,
|
||||
tags: ['experimental', 'advanced'],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -6,11 +6,16 @@
|
||||
import { localize, localize2 } from '../../../../nls.js';
|
||||
import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js';
|
||||
import { IRemoteAgentHostService, parseRemoteAgentHostInput, RemoteAgentHostInputValidationError, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js';
|
||||
import { ISSHRemoteAgentHostService, SSHAuthMethod, type ISSHAgentHostConfig, type ISSHAgentHostConnection, type ISSHResolvedConfig } from '../../../../platform/agentHost/common/sshRemoteAgentHost.js';
|
||||
import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
|
||||
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
|
||||
import { INotificationService } from '../../../../platform/notification/common/notification.js';
|
||||
import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js';
|
||||
import { ServicesAccessor, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
|
||||
import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';
|
||||
import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js';
|
||||
import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js';
|
||||
import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js';
|
||||
import { NewChatViewPane, SessionsViewId } from '../../chat/browser/newChatViewPane.js';
|
||||
import { ISessionsProvidersService } from '../../sessions/browser/sessionsProvidersService.js';
|
||||
import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js';
|
||||
|
||||
registerAction2(class extends Action2 {
|
||||
constructor() {
|
||||
@@ -80,3 +85,386 @@ registerAction2(class extends Action2 {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Connect via SSH -------------------------------------------------------
|
||||
|
||||
interface ISSHAuthMethodPickItem extends IQuickPickItem {
|
||||
readonly method: SSHAuthMethod;
|
||||
}
|
||||
|
||||
interface ISSHHostPickItem extends IQuickPickItem {
|
||||
readonly hostAlias?: string;
|
||||
}
|
||||
|
||||
async function promptToConnectViaSSH(
|
||||
accessor: ServicesAccessor,
|
||||
): Promise<void> {
|
||||
const sshService = accessor.get(ISSHRemoteAgentHostService);
|
||||
const quickInputService = accessor.get(IQuickInputService);
|
||||
const notificationService = accessor.get(INotificationService);
|
||||
const instantiationService = accessor.get(IInstantiationService);
|
||||
|
||||
let host: string;
|
||||
let username: string | undefined;
|
||||
let port: number | undefined;
|
||||
let resolvedConfig: ISSHResolvedConfig | undefined;
|
||||
let suggestedName: string | undefined;
|
||||
let defaultAuthMethod: SSHAuthMethod | undefined;
|
||||
let defaultKeyPath: string | undefined;
|
||||
|
||||
const configHosts = await sshService.listSSHConfigHosts().catch(() => [] as string[]);
|
||||
if (configHosts.length > 0) {
|
||||
const hostPicks: ISSHHostPickItem[] = configHosts.map(h => ({
|
||||
label: h,
|
||||
hostAlias: h,
|
||||
}));
|
||||
hostPicks.push({
|
||||
label: localize('sshEnterManually', "Enter Manually..."),
|
||||
description: localize('sshEnterManuallyDesc', "Type in host, username, and port"),
|
||||
});
|
||||
|
||||
const picked = await quickInputService.pick(hostPicks, {
|
||||
title: localize('sshHostTitle', "Connect via SSH"),
|
||||
placeHolder: localize('sshPickHostPlaceholder', "Select an SSH host or enter manually"),
|
||||
});
|
||||
if (!picked) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (picked.hostAlias) {
|
||||
try {
|
||||
resolvedConfig = await sshService.resolveSSHConfig(picked.hostAlias);
|
||||
} catch (err) {
|
||||
notificationService.error(localize('sshResolveConfigFailed', "Failed to resolve SSH config for {0}: {1}", picked.hostAlias, String(err)));
|
||||
return;
|
||||
}
|
||||
|
||||
host = resolvedConfig.hostname;
|
||||
username = resolvedConfig.user;
|
||||
port = resolvedConfig.port !== 22 ? resolvedConfig.port : undefined;
|
||||
suggestedName = picked.hostAlias;
|
||||
|
||||
// Determine auth method from resolved config
|
||||
if (resolvedConfig.identityFile.length > 0) {
|
||||
const firstKey = resolvedConfig.identityFile[0];
|
||||
const defaultKeys = ['~/.ssh/id_rsa', '~/.ssh/id_ecdsa', '~/.ssh/id_ed25519', '~/.ssh/id_dsa', '~/.ssh/id_xmss'];
|
||||
if (!defaultKeys.includes(firstKey)) {
|
||||
defaultAuthMethod = SSHAuthMethod.KeyFile;
|
||||
defaultKeyPath = firstKey;
|
||||
}
|
||||
}
|
||||
// If no explicit key, default to SSH agent
|
||||
if (!defaultAuthMethod) {
|
||||
defaultAuthMethod = SSHAuthMethod.Agent;
|
||||
}
|
||||
|
||||
// Config host has enough info — connect directly, skip all prompts
|
||||
if (username) {
|
||||
const config: ISSHAgentHostConfig = {
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
authMethod: defaultAuthMethod,
|
||||
privateKeyPath: defaultKeyPath,
|
||||
name: suggestedName,
|
||||
sshConfigHost: picked.hostAlias,
|
||||
};
|
||||
const connection = await instantiationService.invokeFunction(accessor =>
|
||||
connectWithProgress(accessor, config, suggestedName!)
|
||||
);
|
||||
if (connection) {
|
||||
await instantiationService.invokeFunction(accessor => promptForRemoteFolder(accessor, connection));
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const manualResult = await promptForManualHost(quickInputService);
|
||||
if (!manualResult) {
|
||||
return;
|
||||
}
|
||||
host = manualResult.host;
|
||||
username = manualResult.username;
|
||||
port = manualResult.port;
|
||||
}
|
||||
} else {
|
||||
const manualResult = await promptForManualHost(quickInputService);
|
||||
if (!manualResult) {
|
||||
return;
|
||||
}
|
||||
host = manualResult.host;
|
||||
username = manualResult.username;
|
||||
port = manualResult.port;
|
||||
}
|
||||
|
||||
if (!username) {
|
||||
const usernameInput = await quickInputService.input({
|
||||
title: localize('sshUsernameTitle', "SSH Username"),
|
||||
prompt: localize('sshUsernamePrompt', "Enter the username for {0}.", host),
|
||||
placeHolder: 'root',
|
||||
ignoreFocusLost: true,
|
||||
validateInput: async value => value.trim() ? undefined : localize('sshUsernameEmpty', "Enter a username."),
|
||||
});
|
||||
if (!usernameInput) {
|
||||
return;
|
||||
}
|
||||
username = usernameInput.trim();
|
||||
}
|
||||
|
||||
const authPicks: ISSHAuthMethodPickItem[] = [
|
||||
{
|
||||
method: SSHAuthMethod.Agent,
|
||||
label: localize('sshAuthAgent', "SSH Agent"),
|
||||
description: localize('sshAuthAgentDesc', "Use the running SSH agent for authentication"),
|
||||
},
|
||||
{
|
||||
method: SSHAuthMethod.KeyFile,
|
||||
label: localize('sshAuthKey', "Private Key File"),
|
||||
description: localize('sshAuthKeyDesc', "Authenticate with a private key file"),
|
||||
},
|
||||
{
|
||||
method: SSHAuthMethod.Password,
|
||||
label: localize('sshAuthPassword', "Password"),
|
||||
description: localize('sshAuthPasswordDesc', "Authenticate with a password"),
|
||||
},
|
||||
];
|
||||
|
||||
let authMethod: SSHAuthMethod;
|
||||
if (defaultAuthMethod) {
|
||||
authMethod = defaultAuthMethod;
|
||||
} else {
|
||||
const authPicked = await quickInputService.pick(authPicks, {
|
||||
title: localize('sshAuthTitle', "Authentication Method"),
|
||||
placeHolder: localize('sshAuthPlaceholder', "Choose how to authenticate with {0}", host),
|
||||
});
|
||||
if (!authPicked) {
|
||||
return;
|
||||
}
|
||||
authMethod = authPicked.method;
|
||||
}
|
||||
|
||||
let privateKeyPath: string | undefined;
|
||||
let password: string | undefined;
|
||||
|
||||
if (authMethod === SSHAuthMethod.KeyFile) {
|
||||
const keyPath = await quickInputService.input({
|
||||
title: localize('sshKeyTitle', "Private Key Path"),
|
||||
prompt: localize('sshKeyPrompt', "Enter the path to your SSH private key."),
|
||||
placeHolder: '~/.ssh/id_rsa',
|
||||
value: defaultKeyPath ?? '~/.ssh/id_rsa',
|
||||
ignoreFocusLost: true,
|
||||
validateInput: async value => value.trim() ? undefined : localize('sshKeyEmpty', "Enter a key file path."),
|
||||
});
|
||||
if (!keyPath) {
|
||||
return;
|
||||
}
|
||||
privateKeyPath = keyPath.trim();
|
||||
} else if (authMethod === SSHAuthMethod.Password) {
|
||||
const pw = await quickInputService.input({
|
||||
title: localize('sshPasswordTitle', "SSH Password"),
|
||||
prompt: localize('sshPasswordPrompt', "Enter the password for {0}@{1}.", username, host),
|
||||
password: true,
|
||||
ignoreFocusLost: true,
|
||||
validateInput: async value => value ? undefined : localize('sshPasswordEmpty', "Enter a password."),
|
||||
});
|
||||
if (!pw) {
|
||||
return;
|
||||
}
|
||||
password = pw;
|
||||
}
|
||||
|
||||
const defaultName = suggestedName ?? `${username}@${host}`;
|
||||
const name = await quickInputService.input({
|
||||
title: localize('sshNameTitle', "Name Remote"),
|
||||
prompt: localize('sshNamePrompt', "Enter a display name for this SSH remote."),
|
||||
placeHolder: localize('sshNamePlaceholder', "My Remote"),
|
||||
value: defaultName,
|
||||
valueSelection: [0, defaultName.length],
|
||||
ignoreFocusLost: true,
|
||||
validateInput: async value => value.trim() ? undefined : localize('sshNameEmpty', "Enter a name."),
|
||||
});
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config: ISSHAgentHostConfig = {
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
authMethod,
|
||||
privateKeyPath,
|
||||
password,
|
||||
name: name.trim(),
|
||||
};
|
||||
|
||||
const connection = await instantiationService.invokeFunction(accessor =>
|
||||
connectWithProgress(accessor, config, host)
|
||||
);
|
||||
if (connection) {
|
||||
await instantiationService.invokeFunction(accessor => promptForRemoteFolder(accessor, connection));
|
||||
}
|
||||
}
|
||||
|
||||
async function connectWithProgress(
|
||||
accessor: ServicesAccessor,
|
||||
config: ISSHAgentHostConfig,
|
||||
displayHost: string,
|
||||
): Promise<ISSHAgentHostConnection | undefined> {
|
||||
const sshService = accessor.get(ISSHRemoteAgentHostService);
|
||||
const notificationService = accessor.get(INotificationService);
|
||||
|
||||
const handle = notificationService.notify({
|
||||
severity: Severity.Info,
|
||||
message: localize('sshConnecting', "Connecting to {0} via SSH...", displayHost),
|
||||
progress: { infinite: true },
|
||||
});
|
||||
|
||||
// Build the expected connection key to filter progress events.
|
||||
// Must match the key logic in the shared process service.
|
||||
const expectedKey = config.sshConfigHost
|
||||
? `ssh:${config.sshConfigHost}`
|
||||
: `${config.username}@${config.host}:${config.port ?? 22}`;
|
||||
|
||||
const progressListener = sshService.onDidReportConnectProgress?.(progress => {
|
||||
if (progress.connectionKey === expectedKey) {
|
||||
handle.updateMessage(progress.message);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const connection = await sshService.connect(config);
|
||||
handle.close();
|
||||
return connection;
|
||||
} catch (err) {
|
||||
handle.close();
|
||||
notificationService.error(localize('sshConnectFailed', "Failed to connect via SSH to {0}: {1}", displayHost, String(err)));
|
||||
return undefined;
|
||||
} finally {
|
||||
progressListener?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After a successful SSH connection, show the remote folder picker and
|
||||
* pre-select the chosen folder in the workspace picker.
|
||||
*/
|
||||
async function promptForRemoteFolder(
|
||||
accessor: ServicesAccessor,
|
||||
connection: ISSHAgentHostConnection,
|
||||
): Promise<void> {
|
||||
const viewsService = accessor.get(IViewsService);
|
||||
const sessionsProvidersService = accessor.get(ISessionsProvidersService);
|
||||
const sessionsManagementService = accessor.get(ISessionsManagementService);
|
||||
|
||||
// The provider is created synchronously during addSSHConnection's
|
||||
// onDidChangeConnections event, so it should exist by now.
|
||||
const provider = sessionsProvidersService.getProviders().find(p => p.remoteAddress === connection.localAddress);
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the provider's existing browse action to show the folder picker
|
||||
const browseAction = provider.browseActions[0];
|
||||
if (!browseAction) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspace = await browseAction.execute();
|
||||
if (!workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
sessionsManagementService.openNewSessionView();
|
||||
const view = await viewsService.openView<NewChatViewPane>(SessionsViewId, true);
|
||||
view?.selectWorkspace({ providerId: provider.id, workspace });
|
||||
}
|
||||
|
||||
async function promptForManualHost(
|
||||
quickInputService: IQuickInputService,
|
||||
): Promise<{ host: string; username: string | undefined; port: number | undefined } | undefined> {
|
||||
const validateSshHostInput = (value: string): string | undefined => {
|
||||
const v = value.trim();
|
||||
if (!v) {
|
||||
return localize('sshHostEmpty', "Enter an SSH host.");
|
||||
}
|
||||
const atIdx = v.indexOf('@');
|
||||
if (atIdx === 0) {
|
||||
return localize('sshUsernameMissingInHost', "Enter a username before '@'.");
|
||||
}
|
||||
if (atIdx === v.length - 1) {
|
||||
return localize('sshHostMissingAfterAt', "Enter a host name after '@'.");
|
||||
}
|
||||
const hostPart = atIdx !== -1 ? v.substring(atIdx + 1) : v;
|
||||
if (!hostPart) {
|
||||
return localize('sshHostMissingAfterAt', "Enter a host name after '@'.");
|
||||
}
|
||||
const colonIdx = hostPart.lastIndexOf(':');
|
||||
if (colonIdx !== -1) {
|
||||
const hostName = hostPart.substring(0, colonIdx);
|
||||
const portStr = hostPart.substring(colonIdx + 1);
|
||||
if (!hostName) {
|
||||
return localize('sshHostMissingAfterAt', "Enter a host name after '@'.");
|
||||
}
|
||||
if (portStr) {
|
||||
const portNum = Number(portStr);
|
||||
if (!Number.isInteger(portNum) || portNum <= 0 || portNum > 65535) {
|
||||
return localize('sshHostInvalidPort', "Enter a valid port number.");
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const hostInput = await quickInputService.input({
|
||||
title: localize('sshManualHostTitle', "Connect via SSH"),
|
||||
prompt: localize('sshHostPrompt', "Enter the SSH host (e.g. user@hostname or user@hostname:port)."),
|
||||
placeHolder: 'user@myserver.example.com',
|
||||
ignoreFocusLost: true,
|
||||
validateInput: async value => validateSshHostInput(value),
|
||||
});
|
||||
if (!hostInput) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = hostInput.trim();
|
||||
let username: string | undefined;
|
||||
let host: string;
|
||||
let port: number | undefined;
|
||||
const atIndex = trimmed.indexOf('@');
|
||||
|
||||
let hostPart: string;
|
||||
if (atIndex !== -1) {
|
||||
username = trimmed.substring(0, atIndex);
|
||||
hostPart = trimmed.substring(atIndex + 1);
|
||||
} else {
|
||||
hostPart = trimmed;
|
||||
}
|
||||
|
||||
const colonIndex = hostPart.lastIndexOf(':');
|
||||
if (colonIndex !== -1) {
|
||||
host = hostPart.substring(0, colonIndex);
|
||||
const portStr = hostPart.substring(colonIndex + 1);
|
||||
if (portStr) {
|
||||
port = Number(portStr);
|
||||
}
|
||||
} else {
|
||||
host = hostPart;
|
||||
}
|
||||
|
||||
return { host, username, port };
|
||||
}
|
||||
|
||||
registerAction2(class extends Action2 {
|
||||
constructor() {
|
||||
super({
|
||||
id: 'workbench.action.sessions.connectViaSSH',
|
||||
title: localize2('connectViaSSH', "Connect to Remote Agent Host via SSH"),
|
||||
category: CHAT_CATEGORY,
|
||||
f1: true,
|
||||
precondition: ContextKeyExpr.equals(`config.${RemoteAgentHostsEnabledSettingId}`, true),
|
||||
});
|
||||
}
|
||||
|
||||
override async run(accessor: ServicesAccessor): Promise<void> {
|
||||
await promptToConnectViaSSH(accessor);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -200,6 +200,7 @@ import '../workbench/contrib/policyExport/electron-browser/policyExport.contribu
|
||||
// Remote Agent Host
|
||||
import '../platform/agentHost/electron-browser/agentHostService.js';
|
||||
import '../platform/agentHost/electron-browser/remoteAgentHostService.js';
|
||||
import '../platform/agentHost/electron-browser/sshRemoteAgentHostService.js';
|
||||
import './contrib/remoteAgentHost/browser/remoteAgentHost.contribution.js';
|
||||
import './contrib/remoteAgentHost/browser/remoteAgentHostActions.js';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user