Merge remote-tracking branch 'origin/main' into model-selection

This commit is contained in:
Mathew Pareles
2025-03-01 17:43:18 -08:00
41 changed files with 3363 additions and 22 deletions

View File

@ -11,6 +11,7 @@
**/extensions/markdown-language-features/notebook-out/**
**/extensions/markdown-math/notebook-out/**
**/extensions/notebook-renderers/renderer-out/index.js
**/extensions/open-remote-ssh/out/extension.js
**/extensions/simple-browser/media/index.js
**/extensions/typescript-language-features/test-workspace/**
**/extensions/typescript-language-features/extension.webpack.config.js

View File

@ -18,7 +18,7 @@ We highly recommend reading [this](https://github.com/microsoft/vscode/wiki/Sour
We wrote a [guide to working in VSCode].
-->
Most of Void's code lives in the two folders called `void/`.
Most of Void's code lives in the folder `src/vs/workbench/contrib/void/`.

View File

@ -134,6 +134,7 @@ module.exports.indentationFilter = [
'!extensions/markdown-math/notebook-out/*.js',
'!extensions/ipynb/notebook-out/**',
'!extensions/notebook-renderers/renderer-out/*.js',
'!extensions/open-remote-ssh/out/*.js',
'!extensions/simple-browser/media/*.js',
];

View File

@ -55,6 +55,7 @@ const compilations = [
'extensions/microsoft-authentication/tsconfig.json',
'extensions/notebook-renderers/tsconfig.json',
'extensions/npm/tsconfig.json',
'extensions/open-remote-ssh/tsconfig.json',
'extensions/php-language-features/tsconfig.json',
'extensions/references-view/tsconfig.json',
'extensions/search-result/tsconfig.json',

View File

@ -516,7 +516,7 @@ function tweakProductForServerWeb(product) {
['', 'min'].forEach(minified => {
const sourceFolderName = `out-vscode-${type}${dashed(minified)}`;
const destinationFolderName = `vscode-${type}${dashed(platform)}${dashed(arch)}`;
const destinationFolderName = `void-${type}${dashed(platform)}${dashed(arch)}`;
const serverTaskCI = task.define(`vscode-${type}${dashed(platform)}${dashed(arch)}${dashed(minified)}-ci`, task.series(
gulp.task(`node-${platform}-${arch}`),

View File

@ -36,6 +36,7 @@ const dirs = [
'extensions/microsoft-authentication',
'extensions/notebook-renderers',
'extensions/npm',
'extensions/open-remote-ssh',
'extensions/php-language-features',
'extensions/references-view',
'extensions/search-result',

View File

@ -0,0 +1,74 @@
## 0.0.48
- Support `%n` in ProxyCommand
- fix: add missing direct @types/ssh2-stream dependency (#177)
- fix Win32 internal error (#178)
## 0.0.47
- Add support for loong64 (#175)
- Add s390x support (#174)
- Support vscodium alpine reh (#142)
## 0.0.46
- Add riscv64 support (#147)
## 0.0.45
- Use windows-x64 server on windows-arm64
## 0.0.44
- Update ssh2 lib
- Properly set extensionHost env variables
## 0.0.43
- Fix parsing multiple include directives
## 0.0.42
- Fix remote label to show port when connecting to a port other than 22
## 0.0.41
- Take into account parsed port from ssh destination. Fixes (#110)
## 0.0.40
- Update ssh-config package
## 0.0.39
- output error messages when downloading vscode server (#39)
- Add PreferredAuthentications support (#97)
## 0.0.38
- Enable remote support for ppc64le (#93)
## 0.0.37
- Default to Current OS User in Connection String if No User Provided (#91)
- Add support for (unofficial) DragonFly reh (#86)
## 0.0.36
- Make wget support continue download (#85)
## 0.0.35
- Fixes hardcoded agentsock for windows breaks pageant compatibility (#81)
## 0.0.34
- Add remote.SSH.connectTimeout setting
- adding %r username replacement to proxycommand (#77)
## 0.0.33
- feat: support %r user substitution in proxycommand
## 0.0.32
- feat: use serverDownloadUrlTemplate from product.json (#59)
## 0.0.31
- feat: support glob patterns in SSH include directives
## 0.0.30
- feat: support file patterns in SSH include directives

View File

@ -0,0 +1,48 @@
# Open Remote - SSH
## SSH Host Requirements
You can connect to a running SSH server on the following platforms.
**Supported**:
- x86_64 Debian 8+, Ubuntu 16.04+, CentOS / RHEL 7+ Linux.
- ARMv7l (AArch32) Raspbian Stretch/9+ (32-bit).
- ARMv8l (AArch64) Ubuntu 18.04+ (64-bit).
- macOS 10.14+ (Mojave)
- Windows 10+
- FreeBSD 13 (Requires manual remote-extension-host installation)
- DragonFlyBSD (Requires manual remote-extension-host installation)
## Requirements
**Activation**
Enable the extension in your `argv.json`
```json
{
...
"enable-proposed-api": [
...,
"jeanp413.open-remote-ssh",
]
...
}
```
which you can open by running the `Preferences: Configure Runtime Arguments` command.
The file is located in `~/.vscode-oss/argv.json`.
**Alpine linux**
When running on alpine linux, the packages `libstdc++` and `bash` are necessary and can be installed via
running
```bash
sudo apk add bash libstdc++
```
## SSH configuration file
[OpenSSH](https://www.openssh.com/) supports using a [configuration file](https://linuxize.com/post/using-the-ssh-config-file/) to store all your different SSH connections. To use an SSH config file, run the `Remote-SSH: Open SSH Configuration File...` command.

View File

@ -0,0 +1,17 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
//@ts-check
'use strict';
const withBrowserDefaults = require('../shared.webpack.config').browser;
module.exports = withBrowserDefaults({
context: __dirname,
entry: {
extension: './src/extension.ts'
}
});

View File

@ -0,0 +1,34 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
//@ts-check
'use strict';
const withDefaults = require('../shared.webpack.config');
const { IgnorePlugin } = require('webpack');
module.exports = withDefaults({
context: __dirname,
resolve: {
mainFields: ['module', 'main']
},
entry: {
extension: './src/extension.ts',
},
externals: {
vscode: "commonjs vscode",
bufferutil: "commonjs bufferutil",
"utf-8-validate": "commonjs utf-8-validate",
},
plugins: [
new IgnorePlugin({
resourceRegExp: /crypto\/build\/Release\/sshcrypto\.node$/,
}),
new IgnorePlugin({
resourceRegExp: /cpu-features/,
})
]
});

View File

@ -0,0 +1,370 @@
{
"name": "open-remote-ssh",
"version": "0.0.48",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-remote-ssh",
"version": "0.0.48",
"dependencies": {
"@jeanp413/ssh-config": "^4.3.1",
"glob": "^9.3.1",
"simple-socks": "git+https://github.com/jeanp413/simple-socks#main",
"socks": "^2.5.0",
"ssh2": "git+https://github.com/jeanp413/ssh2#master"
},
"devDependencies": {
"@types/ssh2": "^0.5.52",
"@types/ssh2-streams": "0.1.12"
},
"engines": {
"vscode": "^1.70.2"
}
},
"node_modules/@babel/runtime-corejs3": {
"version": "7.26.0",
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.26.0.tgz",
"integrity": "sha512-YXHu5lN8kJCb1LOb9PgV6pvak43X2h4HvRApcN5SdWeaItQOzfn1hgP6jasD6KWQyJDBxrVmA9o9OivlnNJK/w==",
"license": "MIT",
"dependencies": {
"core-js-pure": "^3.30.2",
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@jeanp413/ssh-config": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@jeanp413/ssh-config/-/ssh-config-4.3.1.tgz",
"integrity": "sha512-x0EaWRdjs5sPDNmYr11wVB1GdwWQgRekc7SbueuO5FK7YZUav98qZKtZZU5iSDKyxJkooCs3rgVizB1wIWrF7g==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.10.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz",
"integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
}
},
"node_modules/@types/ssh2": {
"version": "0.5.52",
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.52.tgz",
"integrity": "sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/ssh2-streams": "*"
}
},
"node_modules/@types/ssh2-streams": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@types/ssh2-streams/-/ssh2-streams-0.1.12.tgz",
"integrity": "sha512-Sy8tpEmCce4Tq0oSOYdfqaBpA3hDM8SoxoFh5vzFsu2oL+znzGz8oVWW7xb4K920yYMUY+PIG31qZnFMfPWNCg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"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/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"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/binary": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz",
"integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==",
"license": "MIT",
"dependencies": {
"buffers": "~0.1.1",
"chainsaw": "~0.1.0"
},
"engines": {
"node": "*"
}
},
"node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/buffers": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz",
"integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==",
"engines": {
"node": ">=0.2.0"
}
},
"node_modules/buildcheck": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz",
"integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==",
"optional": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/chainsaw": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz",
"integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==",
"license": "MIT/X11",
"dependencies": {
"traverse": ">=0.3.0 <0.4"
},
"engines": {
"node": "*"
}
},
"node_modules/core-js-pure": {
"version": "3.40.0",
"resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.40.0.tgz",
"integrity": "sha512-AtDzVIgRrmRKQai62yuSIN5vNiQjcJakJb4fbhVw3ehxx7Lohphvw9SGNWKhLFqSxC4ilD0g/L1huAYFQU3Q6A==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cpu-features": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"buildcheck": "~0.0.6",
"nan": "^2.19.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC"
},
"node_modules/glob": {
"version": "9.3.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz",
"integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"minimatch": "^8.0.2",
"minipass": "^4.2.4",
"path-scurry": "^1.6.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/ip-address": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz",
"integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==",
"license": "MIT",
"dependencies": {
"jsbn": "1.1.0",
"sprintf-js": "^1.1.3"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/jsbn": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
"integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.4.tgz",
"integrity": "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "4.2.8",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz",
"integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==",
"license": "ISC",
"engines": {
"node": ">=8"
}
},
"node_modules/nan": {
"version": "2.22.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.22.0.tgz",
"integrity": "sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==",
"license": "MIT",
"optional": true
},
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-scurry/node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT"
},
"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/simple-socks": {
"version": "2.2.2",
"resolved": "git+ssh://git@github.com/jeanp413/simple-socks.git#2ac739301a82d6baff04804ed494436a026acb60",
"license": "MIT",
"dependencies": {
"@babel/runtime-corejs3": "^7.16.8",
"binary": "^0.3.0"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz",
"integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==",
"license": "MIT",
"dependencies": {
"ip-address": "^9.0.5",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/sprintf-js": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
"license": "BSD-3-Clause"
},
"node_modules/ssh2": {
"version": "1.14.0",
"resolved": "git+ssh://git@github.com/jeanp413/ssh2.git#a169f627213aa663e0aa2fd2f0ef5c8931890c26",
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.9",
"nan": "^2.17.0"
}
},
"node_modules/traverse": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz",
"integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==",
"license": "MIT/X11",
"engines": {
"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-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"dev": true,
"license": "MIT"
}
}
}

View File

@ -0,0 +1,351 @@
{
"name": "open-remote-ssh",
"displayName": "Open Remote - SSH",
"publisher": "voideditor",
"description": "Use any remote machine with a SSH server as your development environment.",
"version": "0.0.48",
"icon": "resources/icon.png",
"engines": {
"vscode": "^1.70.2"
},
"extensionKind": [
"ui"
],
"enabledApiProposals": [
"resolvers",
"contribViewsRemote"
],
"keywords": [
"remote development",
"remote",
"ssh"
],
"api": "none",
"activationEvents": [
"onCommand:openremotessh.openEmptyWindow",
"onCommand:openremotessh.openEmptyWindowInCurrentWindow",
"onCommand:openremotessh.openConfigFile",
"onCommand:openremotessh.showLog",
"onResolveRemoteAuthority:ssh-remote",
"onView:sshHosts"
],
"main": "./out/extension.js",
"contributes": {
"configuration": {
"title": "Remote - SSH",
"properties": {
"remote.SSH.configFile": {
"type": "string",
"description": "The absolute file path to a custom SSH config file.",
"default": "",
"scope": "application"
},
"remote.SSH.connectTimeout": {
"type": "number",
"description": "Specifies the timeout in seconds used for the SSH command that connects to the remote.",
"default": 60,
"scope": "application",
"minimum": 1
},
"remote.SSH.defaultExtensions": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of extensions that should be installed automatically on all SSH hosts.",
"scope": "application"
},
"remote.SSH.enableDynamicForwarding": {
"type": "boolean",
"description": "Whether to use SSH dynamic forwarding to allow setting up new port tunnels over an existing SSH connection.",
"scope": "application",
"default": true
},
"remote.SSH.enableAgentForwarding": {
"type": "boolean",
"markdownDescription": "Enable fixing the remote environment so that the SSH config option `ForwardAgent` will take effect as expected from VS Code's remote extension host.",
"scope": "application",
"default": true
},
"remote.SSH.serverDownloadUrlTemplate": {
"type": "string",
"description": "The URL from where the vscode server will be downloaded. You can use the following variables and they will be replaced dynamically:\n- ${quality}: vscode server quality, e.g. stable or insiders\n- ${version}: vscode server version, e.g. 1.69.0\n- ${commit}: vscode server release commit\n- ${arch}: vscode server arch, e.g. x64, armhf, arm64\n- ${release}: release number",
"scope": "application",
"default": "https://github.com/voideditor/${NAME_OF_REPO}/releases/download/${version}.${release}/void-server-${os}-${arch}-${version}.${release}.tar.gz"
},
"remote.SSH.remotePlatform": {
"type": "object",
"description": "A map of the remote hostname to the platform for that remote. Valid values: linux, macos, windows.",
"scope": "application",
"default": {},
"additionalProperties": {
"type": "string",
"enum": [
"linux",
"macos",
"windows"
]
}
},
"remote.SSH.remoteServerListenOnSocket": {
"type": "boolean",
"description": "When true, the remote vscode server will listen on a socket path instead of opening a port. Only valid for Linux and macOS remotes. Requires `AllowStreamLocalForwarding` to be enabled for the SSH server.",
"default": false
},
"remote.SSH.experimental.serverBinaryName": {
"type": "string",
"description": "**Experimental:** The name of the server binary, use this **only if** you are using a client without a corresponding server release",
"scope": "application",
"default": ""
}
}
},
"views": {
"remote": [
{
"id": "sshHosts",
"name": "SSH Targets",
"group": "targets@1",
"remoteName": "ssh-remote"
}
]
},
"commands": [
{
"command": "openremotessh.openEmptyWindow",
"title": "Connect to Host...",
"category": "Remote-SSH"
},
{
"command": "openremotessh.openEmptyWindowInCurrentWindow",
"title": "Connect Current Window to Host...",
"category": "Remote-SSH"
},
{
"command": "openremotessh.openConfigFile",
"title": "Open SSH Configuration File...",
"category": "Remote-SSH"
},
{
"command": "openremotessh.showLog",
"title": "Show Log",
"category": "Remote-SSH"
},
{
"command": "openremotessh.explorer.emptyWindowInNewWindow",
"title": "Connect to Host in New Window",
"icon": "$(empty-window)"
},
{
"command": "openremotessh.explorer.emptyWindowInCurrentWindow",
"title": "Connect to Host in Current Window"
},
{
"command": "openremotessh.explorer.reopenFolderInCurrentWindow",
"title": "Open on SSH Host in Current Window"
},
{
"command": "openremotessh.explorer.reopenFolderInNewWindow",
"title": "Open on SSH Host in New Window",
"icon": "$(folder-opened)"
},
{
"command": "openremotessh.explorer.deleteFolderHistoryItem",
"title": "Remove From Recent List",
"icon": "$(x)"
},
{
"command": "openremotessh.explorer.refresh",
"title": "Refresh",
"icon": "$(refresh)"
},
{
"command": "openremotessh.explorer.configure",
"title": "Configure",
"icon": "$(gear)"
},
{
"command": "openremotessh.explorer.add",
"title": "Add New",
"icon": "$(plus)"
}
],
"resourceLabelFormatters": [
{
"scheme": "vscode-remote",
"authority": "ssh-remote+*",
"formatting": {
"label": "${path}",
"separator": "/",
"tildify": true,
"workspaceSuffix": "SSH"
}
}
],
"menus": {
"statusBar/remoteIndicator": [
{
"command": "openremotessh.openEmptyWindow",
"when": "remoteName =~ /^ssh-remote$/ && remoteConnectionState == connected",
"group": "remote_20_ssh_1general@1"
},
{
"command": "openremotessh.openEmptyWindowInCurrentWindow",
"when": "remoteName =~ /^ssh-remote$/ && remoteConnectionState == connected",
"group": "remote_20_ssh_1general@2"
},
{
"command": "openremotessh.openConfigFile",
"when": "remoteName =~ /^ssh-remote$/ && remoteConnectionState == connected",
"group": "remote_20_ssh_1general@3"
},
{
"command": "openremotessh.showLog",
"when": "remoteName =~ /^ssh-remote$/ && remoteConnectionState == connected",
"group": "remote_20_ssh_1general@4"
},
{
"command": "openremotessh.openEmptyWindow",
"when": "remoteConnectionState == disconnected",
"group": "remote_20_ssh_3local@1"
},
{
"command": "openremotessh.openEmptyWindowInCurrentWindow",
"when": "remoteConnectionState == disconnected",
"group": "remote_20_ssh_3local@2"
},
{
"command": "openremotessh.openConfigFile",
"when": "remoteConnectionState == disconnected",
"group": "remote_20_ssh_3local@3"
},
{
"command": "openremotessh.openEmptyWindow",
"when": "!remoteName && !virtualWorkspace",
"group": "remote_20_ssh_3local@5"
},
{
"command": "openremotessh.openEmptyWindowInCurrentWindow",
"when": "!remoteName && !virtualWorkspace",
"group": "remote_20_ssh_3local@6"
},
{
"command": "openremotessh.openConfigFile",
"when": "!remoteName && !virtualWorkspace",
"group": "remote_20_ssh_3local@7"
}
],
"commandPalette": [
{
"command": "openremotessh.explorer.refresh",
"when": "false"
},
{
"command": "openremotessh.explorer.configure",
"when": "false"
},
{
"command": "openremotessh.explorer.add",
"when": "false"
},
{
"command": "openremotessh.explorer.emptyWindowInNewWindow",
"when": "false"
},
{
"command": "openremotessh.explorer.emptyWindowInCurrentWindow",
"when": "false"
},
{
"command": "openremotessh.explorer.reopenFolderInCurrentWindow",
"when": "false"
},
{
"command": "openremotessh.explorer.reopenFolderInNewWindow",
"when": "false"
},
{
"command": "openremotessh.explorer.deleteFolderHistoryItem",
"when": "false"
}
],
"view/title": [
{
"command": "openremotessh.explorer.add",
"when": "view == sshHosts",
"group": "navigation"
},
{
"command": "openremotessh.explorer.configure",
"when": "view == sshHosts",
"group": "navigation"
},
{
"command": "openremotessh.explorer.refresh",
"when": "view == sshHosts",
"group": "navigation"
}
],
"view/item/context": [
{
"command": "openremotessh.explorer.emptyWindowInNewWindow",
"when": "viewItem =~ /^openremotessh.explorer.host$/",
"group": "inline@1"
},
{
"command": "openremotessh.explorer.emptyWindowInNewWindow",
"when": "viewItem =~ /^openremotessh.explorer.host$/",
"group": "navigation@2"
},
{
"command": "openremotessh.explorer.emptyWindowInCurrentWindow",
"when": "viewItem =~ /^openremotessh.explorer.host$/",
"group": "navigation@1"
},
{
"command": "openremotessh.explorer.reopenFolderInNewWindow",
"when": "viewItem == openremotessh.explorer.folder",
"group": "inline@1"
},
{
"command": "openremotessh.explorer.reopenFolderInNewWindow",
"when": "viewItem == openremotessh.explorer.folder",
"group": "navigation@2"
},
{
"command": "openremotessh.explorer.reopenFolderInCurrentWindow",
"when": "viewItem == openremotessh.explorer.folder",
"group": "navigation@1"
},
{
"command": "openremotessh.explorer.deleteFolderHistoryItem",
"when": "viewItem =~ /^openremotessh.explorer.folder/",
"group": "navigation@3"
},
{
"command": "openremotessh.explorer.deleteFolderHistoryItem",
"when": "viewItem =~ /^openremotessh.explorer.folder/",
"group": "inline@2"
}
]
}
},
"scripts": {
"vscode:prepublish": "npm run compile",
"compile": "gulp compile-extension:open-remote-ssh",
"compile-web": "npx webpack-cli --config extension-browser.webpack.config --mode none",
"watch": "gulp watch-extension:open-remote-ssh",
"watch-web": "npx webpack-cli --config extension-browser.webpack.config --mode none --watch --info-verbosity verbose"
},
"devDependencies": {
"@types/ssh2": "^0.5.52",
"@types/ssh2-streams": "0.1.12"
},
"dependencies": {
"glob": "^9.3.1",
"simple-socks": "git+https://github.com/jeanp413/simple-socks#main",
"socks": "^2.5.0",
"@jeanp413/ssh-config": "^4.3.1",
"ssh2": "git+https://github.com/jeanp413/ssh2#master"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,464 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as cp from 'child_process';
import * as fs from 'fs';
import * as net from 'net';
import * as stream from 'stream';
import { SocksClient, SocksClientOptions } from 'socks';
import * as vscode from 'vscode';
import * as ssh2 from 'ssh2';
import type { ParsedKey } from 'ssh2-streams';
import Log from './common/logger';
import SSHDestination from './ssh/sshDestination';
import SSHConnection, { SSHTunnelConfig } from './ssh/sshConnection';
import SSHConfiguration from './ssh/sshConfig';
import { gatherIdentityFiles } from './ssh/identityFiles';
import { untildify, exists as fileExists } from './common/files';
import { findRandomPort } from './common/ports';
import { disposeAll } from './common/disposable';
import { installCodeServer, ServerInstallError } from './serverSetup';
import { isWindows } from './common/platform';
import * as os from 'os';
const PASSWORD_RETRY_COUNT = 3;
const PASSPHRASE_RETRY_COUNT = 3;
export const REMOTE_SSH_AUTHORITY = 'ssh-remote';
export function getRemoteAuthority(host: string) {
return `${REMOTE_SSH_AUTHORITY}+${host}`;
}
class TunnelInfo implements vscode.Disposable {
constructor(
readonly localPort: number,
readonly remotePortOrSocketPath: number | string,
private disposables: vscode.Disposable[]
) {
}
dispose() {
disposeAll(this.disposables);
}
}
interface SSHKey {
filename: string;
parsedKey: ParsedKey;
fingerprint: string;
agentSupport?: boolean;
isPrivate?: boolean;
}
export class RemoteSSHResolver implements vscode.RemoteAuthorityResolver, vscode.Disposable {
private proxyConnections: SSHConnection[] = [];
private sshConnection: SSHConnection | undefined;
private sshAgentSock: string | undefined;
private proxyCommandProcess: cp.ChildProcessWithoutNullStreams | undefined;
private socksTunnel: SSHTunnelConfig | undefined;
private tunnels: TunnelInfo[] = [];
private labelFormatterDisposable: vscode.Disposable | undefined;
constructor(
readonly context: vscode.ExtensionContext,
readonly logger: Log
) {
}
resolve(authority: string, context: vscode.RemoteAuthorityResolverContext): Thenable<vscode.ResolverResult> {
const [type, dest] = authority.split('+');
if (type !== REMOTE_SSH_AUTHORITY) {
throw new Error(`Invalid authority type for SSH resolver: ${type}`);
}
this.logger.info(`Resolving ssh remote authority '${authority}' (attemp #${context.resolveAttempt})`);
const sshDest = SSHDestination.parseEncoded(dest);
// It looks like default values are not loaded yet when resolving a remote,
// so let's hardcode the default values here
const remoteSSHconfig = vscode.workspace.getConfiguration('remote.SSH');
const enableDynamicForwarding = remoteSSHconfig.get<boolean>('enableDynamicForwarding', true)!;
const enableAgentForwarding = remoteSSHconfig.get<boolean>('enableAgentForwarding', true)!;
const serverDownloadUrlTemplate = remoteSSHconfig.get<string>('serverDownloadUrlTemplate');
const defaultExtensions = remoteSSHconfig.get<string[]>('defaultExtensions', []);
const remotePlatformMap = remoteSSHconfig.get<Record<string, string>>('remotePlatform', {});
const remoteServerListenOnSocket = remoteSSHconfig.get<boolean>('remoteServerListenOnSocket', false)!;
const connectTimeout = remoteSSHconfig.get<number>('connectTimeout', 60)!;
return vscode.window.withProgress({
title: `Setting up SSH Host ${sshDest.hostname}`,
location: vscode.ProgressLocation.Notification,
cancellable: false
}, async () => {
try {
const sshconfig = await SSHConfiguration.loadFromFS();
const sshHostConfig = sshconfig.getHostConfiguration(sshDest.hostname);
const sshHostName = sshHostConfig['HostName'] ? sshHostConfig['HostName'].replace('%h', sshDest.hostname) : sshDest.hostname;
const sshUser = sshHostConfig['User'] || sshDest.user || os.userInfo().username || ''; // https://github.com/openssh/openssh-portable/blob/5ec5504f1d328d5bfa64280cd617c3efec4f78f3/sshconnect.c#L1561-L1562
const sshPort = sshHostConfig['Port'] ? parseInt(sshHostConfig['Port'], 10) : (sshDest.port || 22);
this.sshAgentSock = sshHostConfig['IdentityAgent'] || process.env['SSH_AUTH_SOCK'] || (isWindows ? '\\\\.\\pipe\\openssh-ssh-agent' : undefined);
this.sshAgentSock = this.sshAgentSock ? untildify(this.sshAgentSock) : undefined;
const agentForward = enableAgentForwarding && (sshHostConfig['ForwardAgent'] || 'no').toLowerCase() === 'yes';
const agent = agentForward && this.sshAgentSock ? new ssh2.OpenSSHAgent(this.sshAgentSock) : undefined;
const preferredAuthentications = sshHostConfig['PreferredAuthentications'] ? sshHostConfig['PreferredAuthentications'].split(',').map(s => s.trim()) : ['publickey', 'password', 'keyboard-interactive'];
const identityFiles: string[] = (sshHostConfig['IdentityFile'] as unknown as string[]) || [];
const identitiesOnly = (sshHostConfig['IdentitiesOnly'] || 'no').toLowerCase() === 'yes';
const identityKeys = await gatherIdentityFiles(identityFiles, this.sshAgentSock, identitiesOnly, this.logger);
// Create proxy jump connections if any
let proxyStream: ssh2.ClientChannel | stream.Duplex | undefined;
if (sshHostConfig['ProxyJump']) {
const proxyJumps = sshHostConfig['ProxyJump'].split(',').filter(i => !!i.trim())
.map(i => {
const proxy = SSHDestination.parse(i);
const proxyHostConfig = sshconfig.getHostConfiguration(proxy.hostname);
return [proxy, proxyHostConfig] as [SSHDestination, Record<string, string>];
});
for (let i = 0; i < proxyJumps.length; i++) {
const [proxy, proxyHostConfig] = proxyJumps[i];
const proxyHostName = proxyHostConfig['HostName'] || proxy.hostname;
const proxyUser = proxyHostConfig['User'] || proxy.user || sshUser;
const proxyPort = proxyHostConfig['Port'] ? parseInt(proxyHostConfig['Port'], 10) : (proxy.port || sshPort);
const proxyAgentForward = enableAgentForwarding && (proxyHostConfig['ForwardAgent'] || 'no').toLowerCase() === 'yes';
const proxyAgent = proxyAgentForward && this.sshAgentSock ? new ssh2.OpenSSHAgent(this.sshAgentSock) : undefined;
const proxyIdentityFiles: string[] = (proxyHostConfig['IdentityFile'] as unknown as string[]) || [];
const proxyIdentitiesOnly = (proxyHostConfig['IdentitiesOnly'] || 'no').toLowerCase() === 'yes';
const proxyIdentityKeys = await gatherIdentityFiles(proxyIdentityFiles, this.sshAgentSock, proxyIdentitiesOnly, this.logger);
const proxyAuthHandler = this.getSSHAuthHandler(proxyUser, proxyHostName, proxyIdentityKeys, preferredAuthentications);
const proxyConnection = new SSHConnection({
host: !proxyStream ? proxyHostName : undefined,
port: !proxyStream ? proxyPort : undefined,
sock: proxyStream,
username: proxyUser,
readyTimeout: connectTimeout * 1000,
strictVendor: false,
agentForward: proxyAgentForward,
agent: proxyAgent,
authHandler: (arg0, arg1, arg2) => (proxyAuthHandler(arg0, arg1, arg2), undefined)
});
this.proxyConnections.push(proxyConnection);
const nextProxyJump = i < proxyJumps.length - 1 ? proxyJumps[i + 1] : undefined;
const destIP = nextProxyJump ? (nextProxyJump[1]['HostName'] || nextProxyJump[0].hostname) : sshHostName;
const destPort = nextProxyJump ? ((nextProxyJump[1]['Port'] && parseInt(nextProxyJump[1]['Port'], 10)) || nextProxyJump[0].port || 22) : sshPort;
proxyStream = await proxyConnection.forwardOut('127.0.0.1', 0, destIP, destPort);
}
} else if (sshHostConfig['ProxyCommand']) {
let proxyArgs = (sshHostConfig['ProxyCommand'] as unknown as string[])
.map((arg) => arg.replace('%h', sshHostName).replace('%n', sshDest.hostname).replace('%p', sshPort.toString()).replace('%r', sshUser));
let proxyCommand = proxyArgs.shift()!;
let options = {};
if (isWindows && /\.(bat|cmd)$/.test(proxyCommand)) {
proxyCommand = `"${proxyCommand}"`;
proxyArgs = proxyArgs.map((arg) => arg.includes(' ') ? `"${arg}"` : arg);
options = { shell: true, windowsHide: true, windowsVerbatimArguments: true };
}
this.logger.trace(`Spawning ProxyCommand: ${proxyCommand} ${proxyArgs.join(' ')}`);
const child = cp.spawn(proxyCommand, proxyArgs, options);
proxyStream = stream.Duplex.from({ readable: child.stdout, writable: child.stdin });
this.proxyCommandProcess = child;
}
// Create final shh connection
const sshAuthHandler = this.getSSHAuthHandler(sshUser, sshHostName, identityKeys, preferredAuthentications);
this.sshConnection = new SSHConnection({
host: !proxyStream ? sshHostName : undefined,
port: !proxyStream ? sshPort : undefined,
sock: proxyStream,
username: sshUser,
readyTimeout: connectTimeout * 1000,
strictVendor: false,
agentForward,
agent,
authHandler: (arg0, arg1, arg2) => (sshAuthHandler(arg0, arg1, arg2), undefined),
});
await this.sshConnection.connect();
const envVariables: Record<string, string | null> = {};
if (agentForward) {
envVariables['SSH_AUTH_SOCK'] = null;
}
const installResult = await installCodeServer(this.sshConnection, serverDownloadUrlTemplate, defaultExtensions, Object.keys(envVariables), remotePlatformMap[sshDest.hostname], remoteServerListenOnSocket, this.logger);
for (const key of Object.keys(envVariables)) {
if (installResult[key] !== undefined) {
envVariables[key] = installResult[key];
}
}
// Update terminal env variables
this.context.environmentVariableCollection.persistent = false;
for (const [key, value] of Object.entries(envVariables)) {
if (value) {
this.context.environmentVariableCollection.replace(key, value);
}
}
if (enableDynamicForwarding) {
const socksPort = await findRandomPort();
this.socksTunnel = await this.sshConnection!.addTunnel({
name: `ssh_tunnel_socks_${socksPort}`,
localPort: socksPort,
socks: true
});
}
const tunnelConfig = await this.openTunnel(0, installResult.listeningOn);
this.tunnels.push(tunnelConfig);
// Enable ports view
vscode.commands.executeCommand('setContext', 'forwardedPortsViewEnabled', true);
this.labelFormatterDisposable?.dispose();
this.labelFormatterDisposable = vscode.workspace.registerResourceLabelFormatter({
scheme: 'vscode-remote',
authority: `${REMOTE_SSH_AUTHORITY}+*`,
formatting: {
label: '${path}',
separator: '/',
tildify: true,
workspaceSuffix: `SSH: ${sshDest.hostname}` + (sshDest.port && sshDest.port !== 22 ? `:${sshDest.port}` : '')
}
});
const resolvedResult: vscode.ResolverResult = new vscode.ResolvedAuthority('127.0.0.1', tunnelConfig.localPort, installResult.connectionToken);
resolvedResult.extensionHostEnv = envVariables;
return resolvedResult;
} catch (e: unknown) {
this.logger.error(`Error resolving authority`, e);
// Initial connection
if (context.resolveAttempt === 1) {
this.logger.show();
const closeRemote = 'Close Remote';
const retry = 'Retry';
const result = await vscode.window.showErrorMessage(`Could not establish connection to "${sshDest.hostname}"`, { modal: true }, closeRemote, retry);
if (result === closeRemote) {
await vscode.commands.executeCommand('workbench.action.remote.close');
} else if (result === retry) {
await vscode.commands.executeCommand('workbench.action.reloadWindow');
}
}
if (e instanceof ServerInstallError || !(e instanceof Error)) {
throw vscode.RemoteAuthorityResolverError.NotAvailable(e instanceof Error ? e.message : String(e));
} else {
throw vscode.RemoteAuthorityResolverError.TemporarilyNotAvailable(e.message);
}
}
});
}
private async openTunnel(localPort: number, remotePortOrSocketPath: number | string) {
localPort = localPort > 0 ? localPort : await findRandomPort();
const disposables: vscode.Disposable[] = [];
const remotePort = typeof remotePortOrSocketPath === 'number' ? remotePortOrSocketPath : undefined;
const remoteSocketPath = typeof remotePortOrSocketPath === 'string' ? remotePortOrSocketPath : undefined;
if (this.socksTunnel && remotePort) {
const forwardingServer = await new Promise<net.Server>((resolve, reject) => {
this.logger.trace(`Creating forwarding server ${localPort}(local) => ${this.socksTunnel!.localPort!}(socks) => ${remotePort}(remote)`);
const socksOptions: SocksClientOptions = {
proxy: {
host: '127.0.0.1',
port: this.socksTunnel!.localPort!,
type: 5
},
command: 'connect',
destination: {
host: '127.0.0.1',
port: remotePort
}
};
const server: net.Server = net.createServer()
.on('error', reject)
.on('connection', async (socket: net.Socket) => {
try {
const socksConn = await SocksClient.createConnection(socksOptions);
socket.pipe(socksConn.socket);
socksConn.socket.pipe(socket);
} catch (error) {
this.logger.error(`Error while creating SOCKS connection`, error);
}
})
.on('listening', () => resolve(server))
.listen(localPort);
});
disposables.push({
dispose: () => forwardingServer.close(() => {
this.logger.trace(`SOCKS forwading server closed`);
}),
});
} else {
this.logger.trace(`Opening tunnel ${localPort}(local) => ${remotePortOrSocketPath}(remote)`);
const tunnelConfig = await this.sshConnection!.addTunnel({
name: `ssh_tunnel_${localPort}_${remotePortOrSocketPath}`,
remoteAddr: '127.0.0.1',
remotePort,
remoteSocketPath,
localPort
});
disposables.push({
dispose: () => {
this.sshConnection?.closeTunnel(tunnelConfig.name);
this.logger.trace(`Tunnel ${tunnelConfig.name} closed`);
}
});
}
return new TunnelInfo(localPort, remotePortOrSocketPath, disposables);
}
private getSSHAuthHandler(sshUser: string, sshHostName: string, identityKeys: SSHKey[], preferredAuthentications: string[]) {
let passwordRetryCount = PASSWORD_RETRY_COUNT;
let keyboardRetryCount = PASSWORD_RETRY_COUNT;
identityKeys = identityKeys.slice();
return async (methodsLeft: string[] | null, _partialSuccess: boolean | null, callback: (nextAuth: ssh2.AuthHandlerResult) => void) => {
if (methodsLeft === null) {
this.logger.info(`Trying no-auth authentication`);
return callback({
type: 'none',
username: sshUser,
});
}
if (methodsLeft.includes('publickey') && identityKeys.length && preferredAuthentications.includes('publickey')) {
const identityKey = identityKeys.shift()!;
this.logger.info(`Trying publickey authentication: ${identityKey.filename} ${identityKey.parsedKey.type} SHA256:${identityKey.fingerprint}`);
if (identityKey.agentSupport) {
return callback({
type: 'agent',
username: sshUser,
agent: new class extends ssh2.OpenSSHAgent {
// Only return the current key
override getIdentities(callback: (err: Error | undefined, publicKeys?: ParsedKey[]) => void): void {
callback(undefined, [identityKey.parsedKey]);
}
}(this.sshAgentSock!)
});
}
if (identityKey.isPrivate) {
return callback({
type: 'publickey',
username: sshUser,
key: identityKey.parsedKey
});
}
if (!await fileExists(identityKey.filename)) {
// Try next identity file
return callback(null as any);
}
const keyBuffer = await fs.promises.readFile(identityKey.filename);
let result = ssh2.utils.parseKey(keyBuffer); // First try without passphrase
if (result instanceof Error && result.message === 'Encrypted private OpenSSH key detected, but no passphrase given') {
let passphraseRetryCount = PASSPHRASE_RETRY_COUNT;
while (result instanceof Error && passphraseRetryCount > 0) {
const passphrase = await vscode.window.showInputBox({
title: `Enter passphrase for ${identityKey.filename}`,
password: true,
ignoreFocusOut: true
});
if (!passphrase) {
break;
}
result = ssh2.utils.parseKey(keyBuffer, passphrase);
passphraseRetryCount--;
}
}
if (!result || result instanceof Error) {
// Try next identity file
return callback(null as any);
}
const key = Array.isArray(result) ? result[0] : result;
return callback({
type: 'publickey',
username: sshUser,
key
});
}
if (methodsLeft.includes('password') && passwordRetryCount > 0 && preferredAuthentications.includes('password')) {
if (passwordRetryCount === PASSWORD_RETRY_COUNT) {
this.logger.info(`Trying password authentication`);
}
const password = await vscode.window.showInputBox({
title: `Enter password for ${sshUser}@${sshHostName}`,
password: true,
ignoreFocusOut: true
});
passwordRetryCount--;
return callback(password
? {
type: 'password',
username: sshUser,
password
}
: false);
}
if (methodsLeft.includes('keyboard-interactive') && keyboardRetryCount > 0 && preferredAuthentications.includes('keyboard-interactive')) {
if (keyboardRetryCount === PASSWORD_RETRY_COUNT) {
this.logger.info(`Trying keyboard-interactive authentication`);
}
return callback({
type: 'keyboard-interactive',
username: sshUser,
prompt: async (_name, _instructions, _instructionsLang, prompts, finish) => {
const responses: string[] = [];
for (const prompt of prompts) {
const response = await vscode.window.showInputBox({
title: `(${sshUser}@${sshHostName}) ${prompt.prompt}`,
password: !prompt.echo,
ignoreFocusOut: true
});
if (response === undefined) {
keyboardRetryCount = 0;
break;
}
responses.push(response);
}
keyboardRetryCount--;
finish(responses);
}
});
}
callback(false);
};
}
dispose() {
disposeAll(this.tunnels);
// If there's proxy connections then just close the parent connection
if (this.proxyConnections.length) {
this.proxyConnections[0].close();
} else {
this.sshConnection?.close();
}
this.proxyCommandProcess?.kill();
this.labelFormatterDisposable?.dispose();
}
}

View File

@ -0,0 +1,68 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import * as fs from 'fs';
import { getRemoteAuthority } from './authResolver';
import { getSSHConfigPath } from './ssh/sshConfig';
import { exists as fileExists } from './common/files';
import SSHDestination from './ssh/sshDestination';
export async function promptOpenRemoteSSHWindow(reuseWindow: boolean) {
const host = await vscode.window.showInputBox({
title: 'Enter [user@]hostname[:port]'
});
if (!host) {
return;
}
const sshDest = new SSHDestination(host);
openRemoteSSHWindow(sshDest.toEncodedString(), reuseWindow);
}
export function openRemoteSSHWindow(host: string, reuseWindow: boolean) {
vscode.commands.executeCommand('vscode.newWindow', { remoteAuthority: getRemoteAuthority(host), reuseWindow });
}
export function openRemoteSSHLocationWindow(host: string, path: string, reuseWindow: boolean) {
vscode.commands.executeCommand('vscode.openFolder', vscode.Uri.from({ scheme: 'vscode-remote', authority: getRemoteAuthority(host), path }), { forceNewWindow: !reuseWindow });
}
export async function addNewHost() {
const sshConfigPath = getSSHConfigPath();
if (!await fileExists(sshConfigPath)) {
await fs.promises.appendFile(sshConfigPath, '');
}
await vscode.commands.executeCommand('vscode.open', vscode.Uri.file(sshConfigPath), { preview: false });
const textEditor = vscode.window.activeTextEditor;
if (textEditor?.document.uri.fsPath !== sshConfigPath) {
return;
}
const textDocument = textEditor.document;
const lastLine = textDocument.lineAt(textDocument.lineCount - 1);
if (!lastLine.isEmptyOrWhitespace) {
await textEditor.edit((editBuilder: vscode.TextEditorEdit) => {
editBuilder.insert(lastLine.range.end, '\n');
});
}
const snippet = '\nHost ${1:dev}\n\tHostName ${2:dev.example.com}\n\tUser ${3:john}';
await textEditor.insertSnippet(
new vscode.SnippetString(snippet),
new vscode.Position(textDocument.lineCount, 0)
);
}
export async function openSSHConfigFile() {
const sshConfigPath = getSSHConfigPath();
if (!await fileExists(sshConfigPath)) {
await fs.promises.appendFile(sshConfigPath, '');
}
vscode.commands.executeCommand('vscode.open', vscode.Uri.file(sshConfigPath));
}

View File

@ -0,0 +1,41 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
export function disposeAll(disposables: vscode.Disposable[]): void {
while (disposables.length) {
const item = disposables.pop();
if (item) {
item.dispose();
}
}
}
export abstract class Disposable {
private _isDisposed = false;
protected _disposables: vscode.Disposable[] = [];
public dispose(): any {
if (this._isDisposed) {
return;
}
this._isDisposed = true;
disposeAll(this._disposables);
}
protected _register<T extends vscode.Disposable>(value: T): T {
if (this._isDisposed) {
value.dispose();
} else {
this._disposables.push(value);
}
return value;
}
protected get isDisposed(): boolean {
return this._isDisposed;
}
}

View File

@ -0,0 +1,25 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as fs from 'fs';
import * as os from 'os';
const homeDir = os.homedir();
export async function exists(path: string) {
try {
await fs.promises.access(path);
return true;
} catch {
return false;
}
}
export function untildify(path: string) {
return path.replace(/^~(?=$|\/|\\)/, homeDir);
}
export function normalizeToSlash(path: string) {
return path.replace(/\\/g, '/');
}

View File

@ -0,0 +1,63 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
type LogLevel = 'Trace' | 'Info' | 'Error';
export default class Log {
private output: vscode.OutputChannel;
constructor(name: string) {
this.output = vscode.window.createOutputChannel(name);
}
private data2String(data: any): string {
if (data instanceof Error) {
return data.stack || data.message;
}
if (data.success === false && data.message) {
return data.message;
}
return data.toString();
}
public trace(message: string, data?: any): void {
this.logLevel('Trace', message, data);
}
public info(message: string, data?: any): void {
this.logLevel('Info', message, data);
}
public error(message: string, data?: any): void {
this.logLevel('Error', message, data);
}
public logLevel(level: LogLevel, message: string, data?: any): void {
this.output.appendLine(`[${level} - ${this.now()}] ${message}`);
if (data) {
this.output.appendLine(this.data2String(data));
}
}
private now(): string {
const now = new Date();
return padLeft(now.getUTCHours() + '', 2, '0')
+ ':' + padLeft(now.getMinutes() + '', 2, '0')
+ ':' + padLeft(now.getUTCSeconds() + '', 2, '0') + '.' + now.getMilliseconds();
}
public show() {
this.output.show();
}
public dispose() {
this.output.dispose();
}
}
function padLeft(s: string, n: number, pad = ' ') {
return pad.repeat(Math.max(0, n - s.length)) + s;
}

View File

@ -0,0 +1,7 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
export const isWindows = process.platform === 'win32';
export const isMacintosh = process.platform === 'darwin';
export const isLinux = process.platform === 'linux';

View File

@ -0,0 +1,133 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as net from 'net';
/**
* Finds a random unused port assigned by the operating system. Will reject in case no free port can be found.
*/
export function findRandomPort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer({ pauseOnConnect: true });
server.on('error', reject);
server.on('listening', () => {
const port = (server.address() as net.AddressInfo).port;
server.close(() => resolve(port));
});
server.listen(0, '127.0.0.1');
});
}
/**
* Given a start point and a max number of retries, will find a port that
* is openable. Will return 0 in case no free port can be found.
*/
export function findFreePort(startPort: number, giveUpAfter: number, timeout: number, stride = 1): Promise<number> {
let done = false;
return new Promise(resolve => {
const timeoutHandle = setTimeout(() => {
if (!done) {
done = true;
return resolve(0);
}
}, timeout);
doFindFreePort(startPort, giveUpAfter, stride, (port) => {
if (!done) {
done = true;
clearTimeout(timeoutHandle);
return resolve(port);
}
});
});
}
function doFindFreePort(startPort: number, giveUpAfter: number, stride: number, clb: (port: number) => void): void {
if (giveUpAfter === 0) {
return clb(0);
}
const client = new net.Socket();
// If we can connect to the port it means the port is already taken so we continue searching
client.once('connect', () => {
dispose(client);
return doFindFreePort(startPort + stride, giveUpAfter - 1, stride, clb);
});
client.once('data', () => {
// this listener is required since node.js 8.x
});
client.once('error', (err: Error & { code?: string }) => {
dispose(client);
// If we receive any non ECONNREFUSED error, it means the port is used but we cannot connect
if (err.code !== 'ECONNREFUSED') {
return doFindFreePort(startPort + stride, giveUpAfter - 1, stride, clb);
}
// Otherwise it means the port is free to use!
return clb(startPort);
});
client.connect(startPort, '127.0.0.1');
}
/**
* Uses listen instead of connect. Is faster, but if there is another listener on 0.0.0.0 then this will take 127.0.0.1 from that listener.
*/
export function findFreePortFaster(startPort: number, giveUpAfter: number, timeout: number): Promise<number> {
let resolved = false;
let timeoutHandle: NodeJS.Timeout | undefined = undefined;
let countTried = 1;
const server = net.createServer({ pauseOnConnect: true });
function doResolve(port: number, resolve: (port: number) => void) {
if (!resolved) {
resolved = true;
server.removeAllListeners();
server.close();
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
resolve(port);
}
}
return new Promise<number>(resolve => {
timeoutHandle = setTimeout(() => {
doResolve(0, resolve);
}, timeout);
server.on('listening', () => {
doResolve(startPort, resolve);
});
server.on('error', err => {
if (err && ((<any>err).code === 'EADDRINUSE' || (<any>err).code === 'EACCES') && (countTried < giveUpAfter)) {
startPort++;
countTried++;
server.listen(startPort, '127.0.0.1');
} else {
doResolve(0, resolve);
}
});
server.on('close', () => {
doResolve(0, resolve);
});
server.listen(startPort, '127.0.0.1');
});
}
function dispose(socket: net.Socket): void {
try {
socket.removeAllListeners('connect');
socket.removeAllListeners('error');
socket.end();
socket.destroy();
socket.unref();
} catch (error) {
console.error(error); // otherwise this error would get lost in the callback chain
}
}

View File

@ -0,0 +1,37 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import Log from './common/logger';
import { RemoteSSHResolver, REMOTE_SSH_AUTHORITY } from './authResolver';
import { openSSHConfigFile, promptOpenRemoteSSHWindow } from './commands';
import { HostTreeDataProvider } from './hostTreeView';
import { getRemoteWorkspaceLocationData, RemoteLocationHistory } from './remoteLocationHistory';
export async function activate(context: vscode.ExtensionContext) {
const logger = new Log('Remote - SSH');
context.subscriptions.push(logger);
const remoteSSHResolver = new RemoteSSHResolver(context, logger);
context.subscriptions.push(vscode.workspace.registerRemoteAuthorityResolver(REMOTE_SSH_AUTHORITY, remoteSSHResolver));
context.subscriptions.push(remoteSSHResolver);
const locationHistory = new RemoteLocationHistory(context);
const locationData = getRemoteWorkspaceLocationData();
if (locationData) {
await locationHistory.addLocation(locationData[0], locationData[1]);
}
const hostTreeDataProvider = new HostTreeDataProvider(locationHistory);
context.subscriptions.push(vscode.window.createTreeView('sshHosts', { treeDataProvider: hostTreeDataProvider }));
context.subscriptions.push(hostTreeDataProvider);
context.subscriptions.push(vscode.commands.registerCommand('openremotessh.openEmptyWindow', () => promptOpenRemoteSSHWindow(false)));
context.subscriptions.push(vscode.commands.registerCommand('openremotessh.openEmptyWindowInCurrentWindow', () => promptOpenRemoteSSHWindow(true)));
context.subscriptions.push(vscode.commands.registerCommand('openremotessh.openConfigFile', () => openSSHConfigFile()));
context.subscriptions.push(vscode.commands.registerCommand('openremotessh.showLog', () => logger.show()));
}
export function deactivate() {
}

View File

@ -0,0 +1,109 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import * as path from 'path';
import SSHConfiguration, { getSSHConfigPath } from './ssh/sshConfig';
import { RemoteLocationHistory } from './remoteLocationHistory';
import { Disposable } from './common/disposable';
import { addNewHost, openRemoteSSHLocationWindow, openRemoteSSHWindow, openSSHConfigFile } from './commands';
import SSHDestination from './ssh/sshDestination';
class HostItem {
constructor(
public hostname: string,
public locations: string[]
) {
}
}
class HostLocationItem {
constructor(
public path: string,
public hostname: string
) {
}
}
type DataTreeItem = HostItem | HostLocationItem;
export class HostTreeDataProvider extends Disposable implements vscode.TreeDataProvider<DataTreeItem> {
private readonly _onDidChangeTreeData = this._register(new vscode.EventEmitter<DataTreeItem | DataTreeItem[] | void>());
public readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
constructor(
private locationHistory: RemoteLocationHistory
) {
super();
this._register(vscode.commands.registerCommand('openremotessh.explorer.add', () => addNewHost()));
this._register(vscode.commands.registerCommand('openremotessh.explorer.configure', () => openSSHConfigFile()));
this._register(vscode.commands.registerCommand('openremotessh.explorer.refresh', () => this.refresh()));
this._register(vscode.commands.registerCommand('openremotessh.explorer.emptyWindowInNewWindow', e => this.openRemoteSSHWindow(e, false)));
this._register(vscode.commands.registerCommand('openremotessh.explorer.emptyWindowInCurrentWindow', e => this.openRemoteSSHWindow(e, true)));
this._register(vscode.commands.registerCommand('openremotessh.explorer.reopenFolderInNewWindow', e => this.openRemoteSSHLocationWindow(e, false)));
this._register(vscode.commands.registerCommand('openremotessh.explorer.reopenFolderInCurrentWindow', e => this.openRemoteSSHLocationWindow(e, true)));
this._register(vscode.commands.registerCommand('openremotessh.explorer.deleteFolderHistoryItem', e => this.deleteHostLocation(e)));
this._register(vscode.workspace.onDidChangeConfiguration(e => {
if (e.affectsConfiguration('remote.SSH.configFile')) {
this.refresh();
}
}));
this._register(vscode.workspace.onDidSaveTextDocument(e => {
if (e.uri.fsPath === getSSHConfigPath()) {
this.refresh();
}
}));
}
getTreeItem(element: DataTreeItem): vscode.TreeItem {
if (element instanceof HostLocationItem) {
const label = path.posix.basename(element.path).replace(/\.code-workspace$/, ' (Workspace)');
const treeItem = new vscode.TreeItem(label);
treeItem.description = path.posix.dirname(element.path);
treeItem.iconPath = new vscode.ThemeIcon('folder');
treeItem.contextValue = 'openremotessh.explorer.folder';
return treeItem;
}
const treeItem = new vscode.TreeItem(element.hostname);
treeItem.collapsibleState = element.locations.length ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None;
treeItem.iconPath = new vscode.ThemeIcon('vm');
treeItem.contextValue = 'openremotessh.explorer.host';
return treeItem;
}
async getChildren(element?: HostItem): Promise<DataTreeItem[]> {
if (!element) {
const sshConfigFile = await SSHConfiguration.loadFromFS();
const hosts = sshConfigFile.getAllConfiguredHosts();
return hosts.map(hostname => new HostItem(hostname, this.locationHistory.getHistory(hostname)));
}
if (element instanceof HostItem) {
return element.locations.map(location => new HostLocationItem(location, element.hostname));
}
return [];
}
private refresh() {
this._onDidChangeTreeData.fire();
}
private async deleteHostLocation(element: HostLocationItem) {
await this.locationHistory.removeLocation(element.hostname, element.path);
this.refresh();
}
private async openRemoteSSHWindow(element: HostItem, reuseWindow: boolean) {
const sshDest = new SSHDestination(element.hostname);
openRemoteSSHWindow(sshDest.toEncodedString(), reuseWindow);
}
private async openRemoteSSHLocationWindow(element: HostLocationItem, reuseWindow: boolean) {
const sshDest = new SSHDestination(element.hostname);
openRemoteSSHLocationWindow(sshDest.toEncodedString(), element.path, reuseWindow);
}
}

View File

@ -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 * as vscode from 'vscode';
import { REMOTE_SSH_AUTHORITY } from './authResolver';
import SSHDestination from './ssh/sshDestination';
export class RemoteLocationHistory {
private static STORAGE_KEY = 'remoteLocationHistory_v0';
private remoteLocationHistory: Record<string, string[]> = {};
constructor(private context: vscode.ExtensionContext) {
// context.globalState.update(RemoteLocationHistory.STORAGE_KEY, undefined);
this.remoteLocationHistory = context.globalState.get(RemoteLocationHistory.STORAGE_KEY) || {};
}
getHistory(host: string): string[] {
return this.remoteLocationHistory[host] || [];
}
async addLocation(host: string, path: string) {
const hostLocations = this.remoteLocationHistory[host] || [];
if (!hostLocations.includes(path)) {
hostLocations.unshift(path);
this.remoteLocationHistory[host] = hostLocations;
await this.context.globalState.update(RemoteLocationHistory.STORAGE_KEY, this.remoteLocationHistory);
}
}
async removeLocation(host: string, path: string) {
let hostLocations = this.remoteLocationHistory[host] || [];
hostLocations = hostLocations.filter(l => l !== path);
this.remoteLocationHistory[host] = hostLocations;
await this.context.globalState.update(RemoteLocationHistory.STORAGE_KEY, this.remoteLocationHistory);
}
}
export function getRemoteWorkspaceLocationData(): [string, string] | undefined {
let location = vscode.workspace.workspaceFile;
if (location && location.scheme === 'vscode-remote' && location.authority.startsWith(REMOTE_SSH_AUTHORITY) && location.path.endsWith('.code-workspace')) {
const [, host] = location.authority.split('+');
const sshDest = SSHDestination.parseEncoded(host);
return [sshDest.hostname, location.path];
}
location = vscode.workspace.workspaceFolders?.[0].uri;
if (location && location.scheme === 'vscode-remote' && location.authority.startsWith(REMOTE_SSH_AUTHORITY)) {
const [, host] = location.authority.split('+');
const sshDest = SSHDestination.parseEncoded(host);
return [sshDest.hostname, location.path];
}
return undefined;
}

View File

@ -0,0 +1,43 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
let vscodeProductJson: any;
async function getVSCodeProductJson() {
if (!vscodeProductJson) {
const productJsonStr = await fs.promises.readFile(path.join(vscode.env.appRoot, 'product.json'), 'utf8');
vscodeProductJson = JSON.parse(productJsonStr);
}
return vscodeProductJson;
}
export interface IServerConfig {
version: string;
commit: string;
quality: string;
release?: string; // void-like specific
serverApplicationName: string;
serverDataFolderName: string;
serverDownloadUrlTemplate?: string; // void-like specific
}
export async function getVSCodeServerConfig(): Promise<IServerConfig> {
const productJson = await getVSCodeProductJson();
const customServerBinaryName = vscode.workspace.getConfiguration('remote.SSH.experimental').get<string>('serverBinaryName', '');
return {
version: vscode.version.replace('-insider', ''),
commit: productJson.commit,
quality: productJson.quality,
release: productJson.release,
serverApplicationName: customServerBinaryName || productJson.serverApplicationName,
serverDataFolderName: productJson.serverDataFolderName,
serverDownloadUrlTemplate: productJson.serverDownloadUrlTemplate
};
}

View File

@ -0,0 +1,626 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as crypto from 'crypto';
import Log from './common/logger';
import { getVSCodeServerConfig } from './serverConfig';
import SSHConnection from './ssh/sshConnection';
export interface ServerInstallOptions {
id: string;
quality: string;
commit: string;
version: string;
release?: string; // void specific
extensionIds: string[];
envVariables: string[];
useSocketPath: boolean;
serverApplicationName: string;
serverDataFolderName: string;
serverDownloadUrlTemplate: string;
}
export interface ServerInstallResult {
exitCode: number;
listeningOn: number | string;
connectionToken: string;
logFile: string;
osReleaseId: string;
arch: string;
platform: string;
tmpDir: string;
[key: string]: any;
}
export class ServerInstallError extends Error {
constructor(message: string) {
super(message);
}
}
const DEFAULT_DOWNLOAD_URL_TEMPLATE = 'https://github.com/voideditor/void-updates-server/releases/download/test/void-server-${os}-${arch}.tar.gz';
export async function installCodeServer(conn: SSHConnection, serverDownloadUrlTemplate: string | undefined, extensionIds: string[], envVariables: string[], platform: string | undefined, useSocketPath: boolean, logger: Log): Promise<ServerInstallResult> {
let shell = 'powershell';
// detect platform and shell for windows
if (!platform || platform === 'windows') {
const result = await conn.exec('uname -s');
if (result.stdout) {
if (result.stdout.includes('windows32')) {
platform = 'windows';
} else if (result.stdout.includes('MINGW64')) {
platform = 'windows';
shell = 'bash';
}
} else if (result.stderr) {
if (result.stderr.includes('FullyQualifiedErrorId : CommandNotFoundException')) {
platform = 'windows';
}
if (result.stderr.includes('is not recognized as an internal or external command')) {
platform = 'windows';
shell = 'cmd';
}
}
if (platform) {
logger.trace(`Detected platform: ${platform}, ${shell}`);
}
}
const scriptId = crypto.randomBytes(12).toString('hex');
const vscodeServerConfig = await getVSCodeServerConfig();
const installOptions: ServerInstallOptions = {
id: scriptId,
version: vscodeServerConfig.version,
commit: vscodeServerConfig.commit,
quality: vscodeServerConfig.quality,
release: vscodeServerConfig.release,
extensionIds,
envVariables,
useSocketPath,
serverApplicationName: vscodeServerConfig.serverApplicationName,
serverDataFolderName: vscodeServerConfig.serverDataFolderName,
serverDownloadUrlTemplate: serverDownloadUrlTemplate ?? vscodeServerConfig.serverDownloadUrlTemplate ?? DEFAULT_DOWNLOAD_URL_TEMPLATE,
};
let commandOutput: { stdout: string; stderr: string };
if (platform === 'windows') {
const installServerScript = generatePowerShellInstallScript(installOptions);
logger.trace('Server install command:', installServerScript);
const installDir = `$HOME\\${vscodeServerConfig.serverDataFolderName}\\install`;
const installScript = `${installDir}\\${vscodeServerConfig.commit}.ps1`;
const endRegex = new RegExp(`${scriptId}: end`);
// investigate if it's possible to use `-EncodedCommand` flag
// https://devblogs.microsoft.com/powershell/invoking-powershell-with-complex-expressions-using-scriptblocks/
let command = '';
if (shell === 'powershell') {
command = `md -Force ${installDir}; echo @'\n${installServerScript}\n'@ | Set-Content ${installScript}; powershell -ExecutionPolicy ByPass -File "${installScript}"`;
} else if (shell === 'bash') {
command = `mkdir -p ${installDir.replace(/\\/g, '/')} && echo '\n${installServerScript.replace(/'/g, '\'"\'"\'')}\n' > ${installScript.replace(/\\/g, '/')} && powershell -ExecutionPolicy ByPass -File "${installScript}"`;
} else if (shell === 'cmd') {
const script = installServerScript.trim()
// remove comments
.replace(/^#.*$/gm, '')
// remove empty lines
.replace(/\n{2,}/gm, '\n')
// remove leading spaces
.replace(/^\s*/gm, '')
// escape double quotes (from powershell/cmd)
.replace(/"/g, '"""')
// escape single quotes (from cmd)
.replace(/'/g, `''`)
// escape redirect (from cmd)
.replace(/>/g, `^>`)
// escape new lines (from powershell/cmd)
.replace(/\n/g, '\'`n\'');
command = `powershell "md -Force ${installDir}" && powershell "echo '${script}'" > ${installScript.replace('$HOME', '%USERPROFILE%')} && powershell -ExecutionPolicy ByPass -File "${installScript.replace('$HOME', '%USERPROFILE%')}"`;
logger.trace('Command length (8191 max):', command.length);
if (command.length > 8191) {
throw new ServerInstallError(`Command line too long`);
}
} else {
throw new ServerInstallError(`Not supported shell: ${shell}`);
}
commandOutput = await conn.execPartial(command, (stdout: string) => endRegex.test(stdout));
} else {
const installServerScript = generateBashInstallScript(installOptions);
logger.trace('Server install command:', installServerScript);
// Fish shell does not support heredoc so let's workaround it using -c option,
// also replace single quotes (') within the script with ('\'') as there's no quoting within single quotes, see https://unix.stackexchange.com/a/24676
commandOutput = await conn.exec(`bash -c '${installServerScript.replace(/'/g, `'\\''`)}'`);
}
if (commandOutput.stderr) {
logger.trace('Server install command stderr:', commandOutput.stderr);
}
logger.trace('Server install command stdout:', commandOutput.stdout);
const resultMap = parseServerInstallOutput(commandOutput.stdout, scriptId);
if (!resultMap) {
throw new ServerInstallError(`Failed parsing install script output`);
}
const exitCode = parseInt(resultMap.exitCode, 10);
if (exitCode !== 0) {
throw new ServerInstallError(`Couldn't install vscode server on remote server, install script returned non-zero exit status`);
}
const listeningOn = resultMap.listeningOn.match(/^\d+$/)
? parseInt(resultMap.listeningOn, 10)
: resultMap.listeningOn;
const remoteEnvVars = Object.fromEntries(Object.entries(resultMap).filter(([key,]) => envVariables.includes(key)));
return {
exitCode,
listeningOn,
connectionToken: resultMap.connectionToken,
logFile: resultMap.logFile,
osReleaseId: resultMap.osReleaseId,
arch: resultMap.arch,
platform: resultMap.platform,
tmpDir: resultMap.tmpDir,
...remoteEnvVars
};
}
function parseServerInstallOutput(str: string, scriptId: string): { [k: string]: string } | undefined {
const startResultStr = `${scriptId}: start`;
const endResultStr = `${scriptId}: end`;
const startResultIdx = str.indexOf(startResultStr);
if (startResultIdx < 0) {
return undefined;
}
const endResultIdx = str.indexOf(endResultStr, startResultIdx + startResultStr.length);
if (endResultIdx < 0) {
return undefined;
}
const installResult = str.substring(startResultIdx + startResultStr.length, endResultIdx);
const resultMap: { [k: string]: string } = {};
const resultArr = installResult.split(/\r?\n/);
for (const line of resultArr) {
const [key, value] = line.split('==');
resultMap[key] = value;
}
return resultMap;
}
function generateBashInstallScript({ id, quality, version, commit, release, extensionIds, envVariables, useSocketPath, serverApplicationName, serverDataFolderName, serverDownloadUrlTemplate }: ServerInstallOptions) {
const extensions = extensionIds.map(id => '--install-extension ' + id).join(' ');
return `
# Server installation script
TMP_DIR="\${XDG_RUNTIME_DIR:-"/tmp"}"
DISTRO_VERSION="${version}"
DISTRO_COMMIT="${commit}"
DISTRO_QUALITY="${quality}"
DISTRO_VOID_RELEASE="${release ?? ''}"
SERVER_APP_NAME="${serverApplicationName}"
SERVER_INITIAL_EXTENSIONS="${extensions}"
SERVER_LISTEN_FLAG="${useSocketPath ? `--socket-path="$TMP_DIR/vscode-server-sock-${crypto.randomUUID()}"` : '--port=0'}"
SERVER_DATA_DIR="$HOME/${serverDataFolderName}"
SERVER_DIR="$SERVER_DATA_DIR/bin/$DISTRO_COMMIT"
SERVER_SCRIPT="$SERVER_DIR/bin/$SERVER_APP_NAME"
SERVER_LOGFILE="$SERVER_DATA_DIR/.$DISTRO_COMMIT.log"
SERVER_PIDFILE="$SERVER_DATA_DIR/.$DISTRO_COMMIT.pid"
SERVER_TOKENFILE="$SERVER_DATA_DIR/.$DISTRO_COMMIT.token"
SERVER_ARCH=
SERVER_CONNECTION_TOKEN=
SERVER_DOWNLOAD_URL=
LISTENING_ON=
OS_RELEASE_ID=
ARCH=
PLATFORM=
# Mimic output from logs of remote-ssh extension
print_install_results_and_exit() {
echo "${id}: start"
echo "exitCode==$1=="
echo "listeningOn==$LISTENING_ON=="
echo "connectionToken==$SERVER_CONNECTION_TOKEN=="
echo "logFile==$SERVER_LOGFILE=="
echo "osReleaseId==$OS_RELEASE_ID=="
echo "arch==$ARCH=="
echo "platform==$PLATFORM=="
echo "tmpDir==$TMP_DIR=="
${envVariables.map(envVar => `echo "${envVar}==$${envVar}=="`).join('\n')}
echo "${id}: end"
exit 0
}
# Check if platform is supported
KERNEL="$(uname -s)"
case $KERNEL in
Darwin)
PLATFORM="darwin"
;;
Linux)
PLATFORM="linux"
;;
FreeBSD)
PLATFORM="freebsd"
;;
DragonFly)
PLATFORM="dragonfly"
;;
*)
echo "Error platform not supported: $KERNEL"
print_install_results_and_exit 1
;;
esac
# Check machine architecture
ARCH="$(uname -m)"
case $ARCH in
x86_64 | amd64)
SERVER_ARCH="x64"
;;
armv7l | armv8l)
SERVER_ARCH="armhf"
;;
arm64 | aarch64)
SERVER_ARCH="arm64"
;;
ppc64le)
SERVER_ARCH="ppc64le"
;;
riscv64)
SERVER_ARCH="riscv64"
;;
loongarch64)
SERVER_ARCH="loong64"
;;
s390x)
SERVER_ARCH="s390x"
;;
*)
echo "Error architecture not supported: $ARCH"
print_install_results_and_exit 1
;;
esac
# https://www.freedesktop.org/software/systemd/man/os-release.html
OS_RELEASE_ID="$(grep -i '^ID=' /etc/os-release 2>/dev/null | sed 's/^ID=//gi' | sed 's/"//g')"
if [[ -z $OS_RELEASE_ID ]]; then
OS_RELEASE_ID="$(grep -i '^ID=' /usr/lib/os-release 2>/dev/null | sed 's/^ID=//gi' | sed 's/"//g')"
if [[ -z $OS_RELEASE_ID ]]; then
OS_RELEASE_ID="unknown"
fi
fi
# Create installation folder
if [[ ! -d $SERVER_DIR ]]; then
mkdir -p $SERVER_DIR
if (( $? > 0 )); then
echo "Error creating server install directory"
print_install_results_and_exit 1
fi
fi
# adjust platform for void download, if needed
if [[ $OS_RELEASE_ID = alpine ]]; then
PLATFORM=$OS_RELEASE_ID
fi
SERVER_DOWNLOAD_URL="$(echo "${serverDownloadUrlTemplate.replace(/\$\{/g, '\\${')}" | sed "s/\\\${quality}/$DISTRO_QUALITY/g" | sed "s/\\\${version}/$DISTRO_VERSION/g" | sed "s/\\\${commit}/$DISTRO_COMMIT/g" | sed "s/\\\${os}/$PLATFORM/g" | sed "s/\\\${arch}/$SERVER_ARCH/g" | sed "s/\\\${release}/$DISTRO_VOID_RELEASE/g")"
# Check if server script is already installed
if [[ ! -f $SERVER_SCRIPT ]]; then
case "$PLATFORM" in
darwin | linux | alpine )
;;
*)
echo "Error '$PLATFORM' needs manual installation of remote extension host"
print_install_results_and_exit 1
;;
esac
pushd $SERVER_DIR > /dev/null
if [[ ! -z $(which wget) ]]; then
wget --tries=3 --timeout=10 --continue --no-verbose -O vscode-server.tar.gz $SERVER_DOWNLOAD_URL
elif [[ ! -z $(which curl) ]]; then
curl --retry 3 --connect-timeout 10 --location --show-error --silent --output vscode-server.tar.gz $SERVER_DOWNLOAD_URL
else
echo "Error no tool to download server binary"
print_install_results_and_exit 1
fi
if (( $? > 0 )); then
echo "Error downloading server from $SERVER_DOWNLOAD_URL"
print_install_results_and_exit 1
fi
tar -xf vscode-server.tar.gz --strip-components 1
if (( $? > 0 )); then
echo "Error while extracting server contents"
print_install_results_and_exit 1
fi
if [[ ! -f $SERVER_SCRIPT ]]; then
echo "Error server contents are corrupted"
print_install_results_and_exit 1
fi
rm -f vscode-server.tar.gz
popd > /dev/null
else
echo "Server script already installed in $SERVER_SCRIPT"
fi
# Try to find if server is already running
if [[ -f $SERVER_PIDFILE ]]; then
SERVER_PID="$(cat $SERVER_PIDFILE)"
SERVER_RUNNING_PROCESS="$(ps -o pid,args -p $SERVER_PID | grep $SERVER_SCRIPT)"
else
SERVER_RUNNING_PROCESS="$(ps -o pid,args -A | grep $SERVER_SCRIPT | grep -v grep)"
fi
if [[ -z $SERVER_RUNNING_PROCESS ]]; then
if [[ -f $SERVER_LOGFILE ]]; then
rm $SERVER_LOGFILE
fi
if [[ -f $SERVER_TOKENFILE ]]; then
rm $SERVER_TOKENFILE
fi
touch $SERVER_TOKENFILE
chmod 600 $SERVER_TOKENFILE
SERVER_CONNECTION_TOKEN="${crypto.randomUUID()}"
echo $SERVER_CONNECTION_TOKEN > $SERVER_TOKENFILE
$SERVER_SCRIPT --start-server --host=127.0.0.1 $SERVER_LISTEN_FLAG $SERVER_INITIAL_EXTENSIONS --connection-token-file $SERVER_TOKENFILE --telemetry-level off --enable-remote-auto-shutdown --accept-server-license-terms &> $SERVER_LOGFILE &
echo $! > $SERVER_PIDFILE
else
echo "Server script is already running $SERVER_SCRIPT"
fi
if [[ -f $SERVER_TOKENFILE ]]; then
SERVER_CONNECTION_TOKEN="$(cat $SERVER_TOKENFILE)"
else
echo "Error server token file not found $SERVER_TOKENFILE"
print_install_results_and_exit 1
fi
if [[ -f $SERVER_LOGFILE ]]; then
for i in {1..5}; do
LISTENING_ON="$(cat $SERVER_LOGFILE | grep -E 'Extension host agent listening on .+' | sed 's/Extension host agent listening on //')"
if [[ -n $LISTENING_ON ]]; then
break
fi
sleep 0.5
done
if [[ -z $LISTENING_ON ]]; then
echo "Error server did not start successfully"
print_install_results_and_exit 1
fi
else
echo "Error server log file not found $SERVER_LOGFILE"
print_install_results_and_exit 1
fi
# Finish server setup
print_install_results_and_exit 0
`;
}
function generatePowerShellInstallScript({ id, quality, version, commit, release, extensionIds, envVariables, useSocketPath, serverApplicationName, serverDataFolderName, serverDownloadUrlTemplate }: ServerInstallOptions) {
const extensions = extensionIds.map(id => '--install-extension ' + id).join(' ');
const downloadUrl = serverDownloadUrlTemplate
.replace(/\$\{quality\}/g, quality)
.replace(/\$\{version\}/g, version)
.replace(/\$\{commit\}/g, commit)
.replace(/\$\{os\}/g, 'win32')
.replace(/\$\{arch\}/g, 'x64')
.replace(/\$\{release\}/g, release ?? '');
return `
# Server installation script
$TMP_DIR="$env:TEMP\\$([System.IO.Path]::GetRandomFileName())"
$ProgressPreference = "SilentlyContinue"
$DISTRO_VERSION="${version}"
$DISTRO_COMMIT="${commit}"
$DISTRO_QUALITY="${quality}"
$DISTRO_VOID_RELEASE="${release ?? ''}"
$SERVER_APP_NAME="${serverApplicationName}"
$SERVER_INITIAL_EXTENSIONS="${extensions}"
$SERVER_LISTEN_FLAG="${useSocketPath ? `--socket-path="$TMP_DIR/vscode-server-sock-${crypto.randomUUID()}"` : '--port=0'}"
$SERVER_DATA_DIR="$(Resolve-Path ~)\\${serverDataFolderName}"
$SERVER_DIR="$SERVER_DATA_DIR\\bin\\$DISTRO_COMMIT"
$SERVER_SCRIPT="$SERVER_DIR\\bin\\$SERVER_APP_NAME.cmd"
$SERVER_LOGFILE="$SERVER_DATA_DIR\\.$DISTRO_COMMIT.log"
$SERVER_PIDFILE="$SERVER_DATA_DIR\\.$DISTRO_COMMIT.pid"
$SERVER_TOKENFILE="$SERVER_DATA_DIR\\.$DISTRO_COMMIT.token"
$SERVER_ARCH=
$SERVER_CONNECTION_TOKEN=
$SERVER_DOWNLOAD_URL=
$LISTENING_ON=
$OS_RELEASE_ID=
$ARCH=
$PLATFORM="win32"
function printInstallResults($code) {
"${id}: start"
"exitCode==$code=="
"listeningOn==$LISTENING_ON=="
"connectionToken==$SERVER_CONNECTION_TOKEN=="
"logFile==$SERVER_LOGFILE=="
"osReleaseId==$OS_RELEASE_ID=="
"arch==$ARCH=="
"platform==$PLATFORM=="
"tmpDir==$TMP_DIR=="
${envVariables.map(envVar => `"${envVar}==$${envVar}=="`).join('\n')}
"${id}: end"
}
# Check machine architecture
$ARCH=$env:PROCESSOR_ARCHITECTURE
# Use x64 version for ARM64, as it's not yet available.
if(($ARCH -eq "AMD64") -or ($ARCH -eq "IA64") -or ($ARCH -eq "ARM64")) {
$SERVER_ARCH="x64"
}
else {
"Error architecture not supported: $ARCH"
printInstallResults 1
exit 0
}
# Create installation folder
if(!(Test-Path $SERVER_DIR)) {
try {
ni -it d $SERVER_DIR -f -ea si
} catch {
"Error creating server install directory - $($_.ToString())"
exit 1
}
if(!(Test-Path $SERVER_DIR)) {
"Error creating server install directory"
exit 1
}
}
cd $SERVER_DIR
# Check if server script is already installed
if(!(Test-Path $SERVER_SCRIPT)) {
del vscode-server.tar.gz
$REQUEST_ARGUMENTS = @{
Uri="${downloadUrl}"
TimeoutSec=20
OutFile="vscode-server.tar.gz"
UseBasicParsing=$True
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-RestMethod @REQUEST_ARGUMENTS
if(Test-Path "vscode-server.tar.gz") {
tar -xf vscode-server.tar.gz --strip-components 1
del vscode-server.tar.gz
}
if(!(Test-Path $SERVER_SCRIPT)) {
"Error while installing the server binary"
exit 1
}
}
else {
"Server script already installed in $SERVER_SCRIPT"
}
# Try to find if server is already running
if(Get-Process node -ErrorAction SilentlyContinue | Where-Object Path -Like "$SERVER_DIR\\*") {
echo "Server script is already running $SERVER_SCRIPT"
}
else {
if(Test-Path $SERVER_LOGFILE) {
del $SERVER_LOGFILE
}
if(Test-Path $SERVER_PIDFILE) {
del $SERVER_PIDFILE
}
if(Test-Path $SERVER_TOKENFILE) {
del $SERVER_TOKENFILE
}
$SERVER_CONNECTION_TOKEN="${crypto.randomUUID()}"
[System.IO.File]::WriteAllLines($SERVER_TOKENFILE, $SERVER_CONNECTION_TOKEN)
$SCRIPT_ARGUMENTS="--start-server --host=127.0.0.1 $SERVER_LISTEN_FLAG $SERVER_INITIAL_EXTENSIONS --connection-token-file $SERVER_TOKENFILE --telemetry-level off --enable-remote-auto-shutdown --accept-server-license-terms *> '$SERVER_LOGFILE'"
$START_ARGUMENTS = @{
FilePath = "powershell.exe"
WindowStyle = "hidden"
ArgumentList = @(
"-ExecutionPolicy", "Unrestricted", "-NoLogo", "-NoProfile", "-NonInteractive", "-c", "$SERVER_SCRIPT $SCRIPT_ARGUMENTS"
)
PassThru = $True
}
$SERVER_ID = (start @START_ARGUMENTS).ID
if($SERVER_ID) {
[System.IO.File]::WriteAllLines($SERVER_PIDFILE, $SERVER_ID)
}
}
if(Test-Path $SERVER_TOKENFILE) {
$SERVER_CONNECTION_TOKEN="$(cat $SERVER_TOKENFILE)"
}
else {
"Error server token file not found $SERVER_TOKENFILE"
printInstallResults 1
exit 0
}
sleep -Milliseconds 500
$SELECT_ARGUMENTS = @{
Path = $SERVER_LOGFILE
Pattern = "Extension host agent listening on (\\d+)"
}
for($I = 1; $I -le 5; $I++) {
if(Test-Path $SERVER_LOGFILE) {
$GROUPS = (Select-String @SELECT_ARGUMENTS).Matches.Groups
if($GROUPS) {
$LISTENING_ON = $GROUPS[1].Value
break
}
}
sleep -Milliseconds 500
}
if(!(Test-Path $SERVER_LOGFILE)) {
"Error server log file not found $SERVER_LOGFILE"
printInstallResults 1
exit 0
}
# Finish server setup
printInstallResults 0
if($SERVER_ID) {
while($True) {
if(!(gps -Id $SERVER_ID)) {
"server died, exit"
exit 0
}
sleep 30
}
}
`;
}

View File

@ -0,0 +1,46 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as os from 'os';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { exists as folderExists } from '../common/files';
const PATH_SSH_USER_DIR = path.join(os.homedir(), '.ssh');
const KNOW_HOST_FILE = path.join(PATH_SSH_USER_DIR, 'known_hosts');
const HASH_MAGIC = '|1|';
const HASH_DELIM = '|';
export async function checkNewHostInHostkeys(host: string): Promise<boolean> {
const fileContent = await fs.promises.readFile(KNOW_HOST_FILE, { encoding: 'utf8' });
const lines = fileContent.split(/\r?\n/);
for (let line of lines) {
line = line.trim();
if (!line.startsWith(HASH_MAGIC)) {
continue;
}
const [hostEncripted_] = line.split(' ');
const [salt_, hostHash_] = hostEncripted_.substring(HASH_MAGIC.length).split(HASH_DELIM);
const hostHash = crypto.createHmac('sha1', Buffer.from(salt_, 'base64')).update(host).digest();
if (hostHash.toString('base64') === hostHash_) {
return false;
}
}
return true;
}
export async function addHostToHostFile(host: string, hostKey: Buffer, type: string): Promise<void> {
if (!folderExists(PATH_SSH_USER_DIR)) {
await fs.promises.mkdir(PATH_SSH_USER_DIR, 0o700);
}
const salt = crypto.randomBytes(20);
const hostHash = crypto.createHmac('sha1', salt).update(host).digest();
const entry = `${HASH_MAGIC}${salt.toString('base64')}${HASH_DELIM}${hostHash.toString('base64')} ${type} ${hostKey.toString('base64')}\n`;
await fs.promises.appendFile(KNOW_HOST_FILE, entry);
}

View File

@ -0,0 +1,120 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as crypto from 'crypto';
import type { ParsedKey } from 'ssh2-streams';
import * as ssh2 from 'ssh2';
import { untildify, exists as fileExists } from '../common/files';
import Log from '../common/logger';
const homeDir = os.homedir();
const PATH_SSH_CLIENT_ID_DSA = path.join(homeDir, '.ssh', '/id_dsa');
const PATH_SSH_CLIENT_ID_ECDSA = path.join(homeDir, '.ssh', '/id_ecdsa');
const PATH_SSH_CLIENT_ID_RSA = path.join(homeDir, '.ssh', '/id_rsa');
const PATH_SSH_CLIENT_ID_ED25519 = path.join(homeDir, '.ssh', '/id_ed25519');
const PATH_SSH_CLIENT_ID_XMSS = path.join(homeDir, '.ssh', '/id_xmss');
const PATH_SSH_CLIENT_ID_ECDSA_SK = path.join(homeDir, '.ssh', '/id_ecdsa_sk');
const PATH_SSH_CLIENT_ID_ED25519_SK = path.join(homeDir, '.ssh', '/id_ed25519_sk');
const DEFAULT_IDENTITY_FILES: string[] = [
PATH_SSH_CLIENT_ID_RSA,
PATH_SSH_CLIENT_ID_ECDSA,
PATH_SSH_CLIENT_ID_ECDSA_SK,
PATH_SSH_CLIENT_ID_ED25519,
PATH_SSH_CLIENT_ID_ED25519_SK,
PATH_SSH_CLIENT_ID_XMSS,
PATH_SSH_CLIENT_ID_DSA,
];
export interface SSHKey {
filename: string;
parsedKey: ParsedKey;
fingerprint: string;
agentSupport?: boolean;
isPrivate?: boolean;
}
// From https://github.com/openssh/openssh-portable/blob/acb2059febaddd71ee06c2ebf63dcf211d9ab9f2/sshconnect2.c#L1689-L1690
export async function gatherIdentityFiles(identityFiles: string[], sshAgentSock: string | undefined, identitiesOnly: boolean, logger: Log) {
identityFiles = identityFiles.map(untildify).map(i => i.replace(/\.pub$/, ''));
if (identityFiles.length === 0) {
identityFiles.push(...DEFAULT_IDENTITY_FILES);
}
const identityFileContentsResult = await Promise.allSettled(identityFiles.map(async keyPath => {
keyPath = await fileExists(keyPath + '.pub') ? keyPath + '.pub' : keyPath;
return fs.promises.readFile(keyPath);
}));
const fileKeys: SSHKey[] = identityFileContentsResult.map((result, i) => {
if (result.status === 'rejected') {
return undefined;
}
const parsedResult = ssh2.utils.parseKey(result.value);
if (parsedResult instanceof Error || !parsedResult) {
logger.error(`Error while parsing SSH public key ${identityFiles[i]}:`, parsedResult);
return undefined;
}
const parsedKey = Array.isArray(parsedResult) ? parsedResult[0] : parsedResult;
const fingerprint = crypto.createHash('sha256').update(parsedKey.getPublicSSH()).digest('base64');
return {
filename: identityFiles[i],
parsedKey,
fingerprint
};
}).filter(<T>(v: T | undefined): v is T => !!v);
let sshAgentParsedKeys: ParsedKey[] = [];
try {
if (!sshAgentSock) {
throw new Error(`SSH_AUTH_SOCK environment variable not defined`);
}
sshAgentParsedKeys = await new Promise<ParsedKey[]>((resolve, reject) => {
const sshAgent = new ssh2.OpenSSHAgent(sshAgentSock);
sshAgent.getIdentities((err, publicKeys) => {
if (err) {
reject(err);
} else {
resolve(publicKeys || []);
}
});
});
} catch (e) {
logger.error(`Couldn't get identities from OpenSSH agent`, e);
}
const sshAgentKeys: SSHKey[] = sshAgentParsedKeys.map(parsedKey => {
const fingerprint = crypto.createHash('sha256').update(parsedKey.getPublicSSH()).digest('base64');
return {
filename: parsedKey.comment,
parsedKey,
fingerprint,
agentSupport: true
};
});
const agentKeys: SSHKey[] = [];
const preferredIdentityKeys: SSHKey[] = [];
for (const agentKey of sshAgentKeys) {
const foundIdx = fileKeys.findIndex(k => agentKey.parsedKey.type === k.parsedKey.type && agentKey.fingerprint === k.fingerprint);
if (foundIdx >= 0) {
preferredIdentityKeys.push({ ...fileKeys[foundIdx], agentSupport: true });
fileKeys.splice(foundIdx, 1);
} else if (!identitiesOnly) {
agentKeys.push(agentKey);
}
}
preferredIdentityKeys.push(...agentKeys);
preferredIdentityKeys.push(...fileKeys);
logger.trace(`Identity keys:`, preferredIdentityKeys.length ? preferredIdentityKeys.map(k => `${k.filename} ${k.parsedKey.type} SHA256:${k.fingerprint}`).join('\n') : 'None');
return preferredIdentityKeys;
}

View File

@ -0,0 +1,129 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as os from 'os';
import * as fs from 'fs';
import * as path from 'path';
import SSHConfig, { Directive, Line, Section } from '@jeanp413/ssh-config';
import * as vscode from 'vscode';
import { exists as fileExists, normalizeToSlash, untildify } from '../common/files';
import { isWindows } from '../common/platform';
import { glob } from 'glob';
const systemSSHConfig = isWindows ? path.resolve(process.env.ALLUSERSPROFILE || 'C:\\ProgramData', 'ssh\\ssh_config') : '/etc/ssh/ssh_config';
const defaultSSHConfigPath = path.resolve(os.homedir(), '.ssh/config');
export function getSSHConfigPath() {
const sshConfigPath = vscode.workspace.getConfiguration('remote.SSH').get<string>('configFile');
return sshConfigPath ? untildify(sshConfigPath) : defaultSSHConfigPath;
}
function isDirective(line: Line): line is Directive {
return line.type === SSHConfig.DIRECTIVE;
}
function isHostSection(line: Line): line is Section {
return isDirective(line) && line.param === 'Host' && !!line.value && !!(line as Section).config;
}
function isIncludeDirective(line: Line): line is Section {
return isDirective(line) && line.param === 'Include' && !!line.value;
}
const SSH_CONFIG_PROPERTIES: Record<string, string> = {
'host': 'Host',
'hostname': 'HostName',
'user': 'User',
'port': 'Port',
'identityagent': 'IdentityAgent',
'identitiesonly': 'IdentitiesOnly',
'identityfile': 'IdentityFile',
'forwardagent': 'ForwardAgent',
'preferredauthentications': 'PreferredAuthentications',
'proxyjump': 'ProxyJump',
'proxycommand': 'ProxyCommand',
'include': 'Include',
};
function normalizeProp(prop: Directive) {
prop.param = SSH_CONFIG_PROPERTIES[prop.param.toLowerCase()] || prop.param;
}
function normalizeSSHConfig(config: SSHConfig) {
for (const line of config) {
if (isDirective(line)) {
normalizeProp(line);
}
if (isHostSection(line)) {
normalizeSSHConfig(line.config);
}
}
return config;
}
async function parseSSHConfigFromFile(filePath: string, userConfig: boolean) {
let content = '';
if (await fileExists(filePath)) {
content = (await fs.promises.readFile(filePath, 'utf8')).trim();
}
const config = normalizeSSHConfig(SSHConfig.parse(content));
const includedConfigs: [number, SSHConfig[]][] = [];
for (let i = 0; i < config.length; i++) {
const line = config[i];
if (isIncludeDirective(line)) {
const values = (line.value as string).split(',').map(s => s.trim());
const configs: SSHConfig[] = [];
for (const value of values) {
const includePaths = await glob(normalizeToSlash(untildify(value)), {
absolute: true,
cwd: normalizeToSlash(path.dirname(userConfig ? defaultSSHConfigPath : systemSSHConfig))
});
for (const p of includePaths) {
configs.push(await parseSSHConfigFromFile(p, userConfig));
}
}
includedConfigs.push([i, configs]);
}
}
for (const [idx, includeConfigs] of includedConfigs.reverse()) {
config.splice(idx, 1, ...includeConfigs.flat());
}
return config;
}
export default class SSHConfiguration {
static async loadFromFS(): Promise<SSHConfiguration> {
const config = await parseSSHConfigFromFile(getSSHConfigPath(), true);
config.push(...await parseSSHConfigFromFile(systemSSHConfig, false));
return new SSHConfiguration(config);
}
constructor(private sshConfig: SSHConfig) {
}
getAllConfiguredHosts(): string[] {
const hosts = new Set<string>();
for (const line of this.sshConfig) {
if (isHostSection(line)) {
const value = Array.isArray(line.value) ? line.value[0] : line.value;
const isPattern = /^!/.test(value) || /[?*]/.test(value);
if (!isPattern) {
hosts.add(value);
}
}
}
return [...hosts.keys()];
}
getHostConfiguration(host: string): Record<string, string> {
// Only a few directives return an array
// https://github.com/jeanp413/ssh-config/blob/8d187bb8f1d83a51ff2b1d127e6b6269d24092b5/src/ssh-config.ts#L9C1-L9C118
return this.sshConfig.compute(host) as Record<string, string>;
}
}

View File

@ -0,0 +1,367 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { EventEmitter } from 'events';
import * as net from 'net';
import * as fs from 'fs';
import * as stream from 'stream';
import { Client, ClientChannel, ClientErrorExtensions, ExecOptions, ShellOptions, ConnectConfig } from 'ssh2';
import { Server } from 'net';
import { SocksConnectionInfo, createServer as createSocksServer } from 'simple-socks';
export interface SSHConnectConfig extends ConnectConfig {
/** Optional Unique ID attached to ssh connection. */
uniqueId?: string;
/** Automatic retry to connect, after disconnect. Default true */
reconnect?: boolean;
/** Number of reconnect retry, after disconnect. Default 10 */
reconnectTries?: number;
/** Delay after which reconnect should be done. Default 5000ms */
reconnectDelay?: number;
/** Path to private key */
identity?: string | Buffer;
}
export interface SSHTunnelConfig {
/** Remote Address to connect */
remoteAddr?: string;
/** Local port to bind to. By default, it will bind to a random port, if not passed */
localPort?: number;
/** Remote Port to connect */
remotePort?: number;
/** Remote socket path to connect */
remoteSocketPath?: string;
socks?: boolean;
/** Unique name */
name?: string;
}
const defaultOptions: Partial<SSHConnectConfig> = {
reconnect: false,
port: 22,
reconnectTries: 3,
reconnectDelay: 5000
};
const SSHConstants = {
'CHANNEL': {
SSH: 'ssh',
TUNNEL: 'tunnel',
X11: 'x11'
},
'STATUS': {
BEFORECONNECT: 'beforeconnect',
CONNECT: 'connect',
BEFOREDISCONNECT: 'beforedisconnect',
DISCONNECT: 'disconnect'
}
};
export default class SSHConnection extends EventEmitter {
public config: SSHConnectConfig;
private activeTunnels: { [index: string]: SSHTunnelConfig & { server: Server } } = {};
private __$connectPromise: Promise<SSHConnection> | null = null;
private __retries: number = 0;
private __err: Error & ClientErrorExtensions & { code?: string } | null = null;
private sshConnection: Client | null = null;
constructor(options: SSHConnectConfig) {
super();
this.config = Object.assign({}, defaultOptions, options);
this.config.uniqueId = this.config.uniqueId || `${this.config.username}@${this.config.host}`;
}
/**
* Emit message on this channel
*/
override emit(channel: string, status: string, payload?: any): boolean {
super.emit(channel, status, this, payload);
return super.emit(`${channel}:${status}`, this, payload);
}
/**
* Get shell socket
*/
shell(options: ShellOptions = {}): Promise<ClientChannel> {
return this.connect().then(() => {
return new Promise<ClientChannel>((resolve, reject) => {
this.sshConnection!.shell(options, (err, stream) => err ? reject(err) : resolve(stream));
});
});
}
/**
* Exec a command
*/
exec(cmd: string, params?: Array<string>, options: ExecOptions = {}): Promise<{ stdout: string; stderr: string }> {
cmd += (Array.isArray(params) ? (' ' + params.join(' ')) : '');
return this.connect().then(() => {
return new Promise((resolve, reject) => {
this.sshConnection!.exec(cmd, options, (err, stream) => {
if (err) {
return reject(err);
}
let stdout = '';
let stderr = '';
stream.on('close', function () {
return resolve({ stdout, stderr });
}).on('data', function (data: Buffer | string) {
stdout += data.toString();
}).stderr.on('data', function (data: Buffer | string) {
stderr += data.toString();
});
});
});
});
}
/**
* Exec a command
*/
execPartial(cmd: string, tester: (stdout: string, stderr: string) => boolean, params?: Array<string>, options: ExecOptions = {}): Promise<{ stdout: string; stderr: string }> {
cmd += (Array.isArray(params) ? (' ' + params.join(' ')) : '');
return this.connect().then(() => {
return new Promise((resolve, reject) => {
this.sshConnection!.exec(cmd, options, (err, stream) => {
if (err) {
return reject(err);
}
let stdout = '';
let stderr = '';
let resolved = false;
stream.on('close', function () {
if (!resolved) {
return resolve({ stdout, stderr });
}
}).on('data', function (data: Buffer | string) {
stdout += data.toString();
if (tester(stdout, stderr)) {
resolved = true;
return resolve({ stdout, stderr });
}
}).stderr.on('data', function (data: Buffer | string) {
stderr += data.toString();
if (tester(stdout, stderr)) {
resolved = true;
return resolve({ stdout, stderr });
}
});
});
});
});
}
/**
* Forward out
*/
forwardOut(srcIP: string, srcPort: number, destIP: string, destPort: number): Promise<ClientChannel> {
return this.connect().then(() => {
return new Promise((resolve, reject) => {
this.sshConnection!.forwardOut(srcIP, srcPort, destIP, destPort, (err, stream) => {
if (err) {
return reject(err);
}
resolve(stream);
});
});
});
}
/**
* Get a Socks Port
*/
getSocksPort(localPort: number): Promise<number> {
return this.addTunnel({ name: '__socksServer', socks: true, localPort: localPort }).then((tunnel) => {
return tunnel.localPort!;
});
}
/**
* Close SSH Connection
*/
close(): Promise<void> {
this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.BEFOREDISCONNECT);
return this.closeTunnel().then(() => {
if (this.sshConnection) {
this.sshConnection.end();
this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.DISCONNECT);
}
});
}
/**
* Connect the SSH Connection
*/
connect(c?: SSHConnectConfig): Promise<SSHConnection> {
this.config = Object.assign(this.config, c);
++this.__retries;
if (this.__$connectPromise) {
return this.__$connectPromise;
}
this.__$connectPromise = new Promise((resolve, reject) => {
this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.BEFORECONNECT);
if (!this.config || typeof this.config === 'function' || !(this.config.host || this.config.sock) || !this.config.username) {
reject(`Invalid SSH connection configuration host/username can't be empty`);
this.__$connectPromise = null;
return;
}
if (this.config.identity) {
if (fs.existsSync(this.config.identity)) {
this.config.privateKey = fs.readFileSync(this.config.identity);
}
delete this.config.identity;
}
//Start ssh server connection
this.sshConnection = new Client();
this.sshConnection.on('ready', (err: Error & ClientErrorExtensions) => {
if (err) {
this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.DISCONNECT, { err: err });
this.__$connectPromise = null;
return reject(err);
}
this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.CONNECT);
this.__retries = 0;
this.__err = null;
resolve(this);
}).on('error', (err) => {
this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.DISCONNECT, { err: err });
this.__err = err;
}).on('close', () => {
this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.DISCONNECT, { err: this.__err });
if (this.config.reconnect && this.__retries <= this.config.reconnectTries! && this.__err && this.__err.level !== 'client-authentication' && this.__err.code !== 'ENOTFOUND') {
setTimeout(() => {
this.__$connectPromise = null;
resolve(this.connect());
}, this.config.reconnectDelay);
} else {
reject(this.__err);
}
}).connect(this.config);
});
return this.__$connectPromise;
}
/**
* Get existing tunnel by name
*/
getTunnel(name: string) {
return this.activeTunnels[name];
}
/**
* Add new tunnel if not exist
*/
addTunnel(SSHTunnelConfig: SSHTunnelConfig): Promise<SSHTunnelConfig & { server: Server }> {
SSHTunnelConfig.name = SSHTunnelConfig.name || `${SSHTunnelConfig.remoteAddr}@${SSHTunnelConfig.remotePort || SSHTunnelConfig.remoteSocketPath}`;
this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.BEFORECONNECT, { SSHTunnelConfig: SSHTunnelConfig });
if (this.getTunnel(SSHTunnelConfig.name)) {
this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.CONNECT, { SSHTunnelConfig: SSHTunnelConfig });
return Promise.resolve(this.getTunnel(SSHTunnelConfig.name));
} else {
return new Promise((resolve, reject) => {
let server: net.Server;
if (SSHTunnelConfig.socks) {
server = createSocksServer({
connectionFilter: (destination: SocksConnectionInfo, origin: SocksConnectionInfo, callback: (err?: any, dest?: stream.Duplex) => void) => {
this.connect().then(() => {
this.sshConnection!.forwardOut(
origin.address,
origin.port,
destination.address,
destination.port,
(err, stream) => {
if (err) {
this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.DISCONNECT, { SSHTunnelConfig: SSHTunnelConfig, err: err });
return callback(err);
}
return callback(null, stream);
});
});
}
}).on('proxyError', (err: any) => {
this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.DISCONNECT, { SSHTunnelConfig: SSHTunnelConfig, err: err });
});
} else {
server = net.createServer()
.on('connection', (socket) => {
this.connect().then(() => {
if (SSHTunnelConfig.remotePort) {
this.sshConnection!.forwardOut('127.0.0.1', 0, SSHTunnelConfig.remoteAddr!, SSHTunnelConfig.remotePort!, (err, stream) => {
if (err) {
this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.DISCONNECT, { SSHTunnelConfig: SSHTunnelConfig, err: err });
return;
}
stream.pipe(socket);
socket.pipe(stream);
});
} else {
this.sshConnection!.openssh_forwardOutStreamLocal(SSHTunnelConfig.remoteSocketPath!, (err, stream) => {
if (err) {
this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.DISCONNECT, { SSHTunnelConfig: SSHTunnelConfig, err: err });
return;
}
stream.pipe(socket);
socket.pipe(stream);
});
}
});
});
}
SSHTunnelConfig.localPort = SSHTunnelConfig.localPort || 0;
server.on('listening', () => {
SSHTunnelConfig.localPort = (server.address() as net.AddressInfo).port;
this.activeTunnels[SSHTunnelConfig.name!] = Object.assign({}, { server }, SSHTunnelConfig);
this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.CONNECT, { SSHTunnelConfig: SSHTunnelConfig });
resolve(this.activeTunnels[SSHTunnelConfig.name!]);
}).on('error', (err: any) => {
this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.DISCONNECT, { SSHTunnelConfig: SSHTunnelConfig, err: err });
server.close();
reject(err);
delete this.activeTunnels[SSHTunnelConfig.name!];
}).listen(SSHTunnelConfig.localPort);
});
}
}
/**
* Close the tunnel
*/
closeTunnel(name?: string): Promise<void> {
if (name && this.activeTunnels[name]) {
return new Promise((resolve) => {
const tunnel = this.activeTunnels[name];
this.emit(
SSHConstants.CHANNEL.TUNNEL,
SSHConstants.STATUS.BEFOREDISCONNECT,
{ SSHTunnelConfig: tunnel }
);
tunnel.server.close(() => {
this.emit(
SSHConstants.CHANNEL.TUNNEL,
SSHConstants.STATUS.DISCONNECT,
{ SSHTunnelConfig: this.activeTunnels[name] }
);
delete this.activeTunnels[name];
resolve();
});
});
} else if (!name) {
const tunnels = Object.keys(this.activeTunnels).map((key) => this.closeTunnel(key));
return Promise.all(tunnels).then(() => { });
}
return Promise.resolve();
}
}

View File

@ -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.
*--------------------------------------------------------------------------------------------*/
export default class SSHDestination {
constructor(
public readonly hostname: string,
public readonly user?: string,
public readonly port?: number
) {
}
static parse(dest: string): SSHDestination {
let user: string | undefined;
const atPos = dest.lastIndexOf('@');
if (atPos !== -1) {
user = dest.substring(0, atPos);
}
let port: number | undefined;
const colonPos = dest.lastIndexOf(':');
if (colonPos !== -1) {
port = parseInt(dest.substring(colonPos + 1), 10);
}
const start = atPos !== -1 ? atPos + 1 : 0;
const end = colonPos !== -1 ? colonPos : dest.length;
const hostname = dest.substring(start, end);
return new SSHDestination(hostname, user, port);
}
toString(): string {
let result = this.hostname;
if (this.user) {
result = `${this.user}@` + result;
}
if (this.port) {
result = result + `:${this.port}`;
}
return result;
}
// vscode.uri implementation lowercases the authority, so when reopen or restore
// a remote session from the recently openend list the connection fails
static parseEncoded(dest: string): SSHDestination {
try {
const data = JSON.parse(Buffer.from(dest, 'hex').toString());
return new SSHDestination(data.hostName, data.user, data.port);
} catch {
}
return SSHDestination.parse(dest.replace(/\\x([0-9a-f]{2})/g, (_, charCode) => String.fromCharCode(parseInt(charCode, 16))));
}
toEncodedString(): string {
return this.toString().replace(/[A-Z]/g, (ch) => `\\x${ch.charCodeAt(0).toString(16).toLowerCase()}`);
}
}

View File

@ -0,0 +1,12 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./out",
},
"include": [
"src/**/*",
"../../src/vscode-dts/vscode.d.ts",
"../../src/vscode-dts/vscode.proposed.resolvers.d.ts",
"../../src/vscode-dts/vscode.proposed.contribViewsRemote.d.ts",
]
}

19
package-lock.json generated
View File

@ -10,7 +10,7 @@
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.32.1",
"@anthropic-ai/sdk": "^0.37.0",
"@floating-ui/react": "^0.27.3",
"@google/generative-ai": "^0.21.0",
"@microsoft/1ds-core-js": "^3.2.13",
@ -42,6 +42,7 @@
"@xterm/headless": "^5.6.0-beta.64",
"@xterm/xterm": "^5.6.0-beta.64",
"ajv": "^8.17.1",
"cross-spawn": "^7.0.6",
"diff": "^7.0.0",
"groq-sdk": "^0.9.0",
"http-proxy-agent": "^7.0.0",
@ -222,9 +223,10 @@
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.32.1",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.32.1.tgz",
"integrity": "sha512-U9JwTrDvdQ9iWuABVsMLj8nJVwAyQz6QXvgLsVhryhCEPkLsbcP/MXxm+jYcAwLoV8ESbaTTjnD4kuAFa+Hyjg==",
"version": "0.37.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.37.0.tgz",
"integrity": "sha512-tHjX2YbkUBwEgg0JZU3EFSSAQPoK4qQR/NFYa8Vtzd5UAyXzZksCw2In69Rml4R/TyHPBfRYaLK35XiOe33pjw==",
"license": "MIT",
"dependencies": {
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
@ -7116,7 +7118,7 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
@ -14249,8 +14251,7 @@
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
},
"node_modules/isobject": {
"version": "3.0.1",
@ -17541,7 +17542,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"engines": {
"node": ">=8"
}
@ -20246,7 +20246,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"dependencies": {
"shebang-regex": "^3.0.0"
},
@ -20258,7 +20257,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"engines": {
"node": ">=8"
}
@ -23738,7 +23736,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"dependencies": {
"isexe": "^2.0.0"
},

View File

@ -78,7 +78,7 @@
"update-build-ts-version": "npm install typescript@next && tsc -p ./build/tsconfig.build.json"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.32.1",
"@anthropic-ai/sdk": "^0.37.0",
"@floating-ui/react": "^0.27.3",
"@google/generative-ai": "^0.21.0",
"@microsoft/1ds-core-js": "^3.2.13",
@ -110,6 +110,7 @@
"@xterm/headless": "^5.6.0-beta.64",
"@xterm/xterm": "^5.6.0-beta.64",
"ajv": "^8.17.1",
"cross-spawn": "^7.0.6",
"diff": "^7.0.0",
"groq-sdk": "^0.9.0",
"http-proxy-agent": "^7.0.0",

View File

@ -38,6 +38,7 @@
"native-watchdog": "^1.4.1",
"node-pty": "1.1.0-beta21",
"tas-client-umd": "0.2.0",
"tslib": "^2.8.1",
"vscode-oniguruma": "1.7.0",
"vscode-regexpp": "^3.1.0",
"vscode-textmate": "9.1.0",
@ -1035,6 +1036,11 @@
"node": ">=8.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",

View File

@ -33,6 +33,7 @@
"native-watchdog": "^1.4.1",
"node-pty": "1.1.0-beta21",
"tas-client-umd": "0.2.0",
"tslib": "^2.8.1",
"vscode-oniguruma": "1.7.0",
"vscode-regexpp": "^3.1.0",
"vscode-textmate": "9.1.0",

View File

@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import { safeStorage as safeStorageElectron, app } from 'electron';
import { isMacintosh, isWindows } from '../../../base/common/platform.js';
import { isMacintosh, isWindows, isLinux } from '../../../base/common/platform.js';
import { KnownStorageProvider, IEncryptionMainService, PasswordStoreCLIOption } from '../common/encryptionService.js';
import { ILogService } from '../../log/common/log.js';
@ -23,6 +23,11 @@ export class EncryptionMainService implements IEncryptionMainService {
constructor(
@ILogService private readonly logService: ILogService
) {
if (isLinux && !app.commandLine.getSwitchValue('password-store')) {
this.logService.trace('[EncryptionMainService] No password-store switch, defaulting to basic...');
app.commandLine.appendSwitch('password-store', PasswordStoreCLIOption.basic);
}
// if this commandLine switch is set, the user has opted in to using basic text encryption
if (app.commandLine.getSwitchValue('password-store') === PasswordStoreCLIOption.basic) {
this.logService.trace('[EncryptionMainService] setting usePlainTextEncryption to true...');

View File

@ -3,7 +3,8 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { spawn, execSync } from 'child_process';
import { execSync } from 'child_process';
import { spawn } from 'cross-spawn'
// Added lines below
import fs from 'fs';
import path from 'path';

View File

@ -68,7 +68,9 @@ export const voidTools = {
required: ['query'],
},
// go_to_definition:
// go_to_definition: {
// },
// go_to_usages:

View File

@ -56,7 +56,8 @@ export const defaultModelsOfProvider = {
'gpt-4o-mini',
],
anthropic: [ // https://docs.anthropic.com/en/docs/about-claude/models
'claude-3-5-sonnet-latest',
'claude-3-7-sonnet-latest',
// 'claude-3-5-sonnet-latest',
'claude-3-5-haiku-latest',
'claude-3-opus-latest',
],

View File

@ -146,6 +146,15 @@ const openAISettings: ProviderSettings = {
// ---------------- ANTHROPIC ----------------
const anthropicModelOptions = {
'claude-3-7-sonnet-20250219': { // https://docs.anthropic.com/en/docs/about-claude/models/all-models#model-comparison-table
contextWindow: 200_000,
maxOutputTokens: 8_192, // TODO!!! 64_000 for extended thinking, can bump it to 128_000 with output-128k-2025-02-19
cost: { input: 3.00, cache_read: 0.30, cache_write: 3.75, output: 15.00 },
supportsFIM: false,
supportsSystemMessage: 'separated',
supportsTools: 'anthropic-style',
supportsReasoningOutput: {}, // TODO!!!!
},
'claude-3-5-sonnet-20241022': {
contextWindow: 200_000,
maxOutputTokens: 8_192,
@ -187,6 +196,7 @@ const anthropicSettings: ProviderSettings = {
modelOptions: anthropicModelOptions,
modelOptionsFallback: (modelName) => {
let fallbackName: keyof typeof anthropicModelOptions | null = null
if (modelName.includes('claude-3-7-sonnet')) fallbackName = 'claude-3-7-sonnet-20250219'
if (modelName.includes('claude-3-5-sonnet')) fallbackName = 'claude-3-5-sonnet-20241022'
if (modelName.includes('claude-3-5-haiku')) fallbackName = 'claude-3-5-haiku-20241022'
if (modelName.includes('claude-3-opus')) fallbackName = 'claude-3-opus-20240229'

View File

@ -14,9 +14,22 @@ export const parseObject = (args: unknown) => {
}
const prepareMessages_cloneAndTrim = ({ messages: messages_ }: { messages: LLMChatMessage[] }) => {
const messages = deepClone(messages_).map(m => ({ ...m, content: m.content.trim(), }))
return { messages }
const prepareMessages_normalize = ({ messages: messages_ }: { messages: LLMChatMessage[] }) => {
const messages = deepClone(messages_)
const newMessages: LLMChatMessage[] = []
for (let i = 1; i < messages.length; i += 1) {
const curr = messages[i]
const prev = messages[i - 1]
// if found a repeated role, put the current content in the prev
if ((curr.role === 'user' && prev.role === 'user') || (curr.role === 'assistant' && prev.role === 'assistant')) {
prev.content += '\n' + curr.content
continue
}
// add the message
newMessages.push(curr)
}
const finalMessages = newMessages.map(m => ({ ...m, content: m.content.trim() }))
return { messages: finalMessages }
}
// no matter whether the model supports a system message or not (or what format it supports), add it in some way
@ -313,7 +326,7 @@ export const prepareMessages = ({
supportsSystemMessage: false | 'system-role' | 'developer-role' | 'separated',
supportsTools: false | 'anthropic-style' | 'openai-style',
}) => {
const { messages: messages1 } = prepareMessages_cloneAndTrim({ messages })
const { messages: messages1 } = prepareMessages_normalize({ messages })
const { messages: messages2, separateSystemMessageStr } = prepareMessages_systemMessage({ messages: messages1, aiInstructions, supportsSystemMessage })
const { messages: messages3 } = prepareMessages_tools({ messages: messages2, supportsTools })
return {