From 6f6f43c46e22e3e03f9ce6ab71b39eb2d8c06f01 Mon Sep 17 00:00:00 2001 From: Joaquin Coromina <75667013+bjoaquinc@users.noreply.github.com> Date: Mon, 10 Feb 2025 19:46:32 +0700 Subject: [PATCH 01/18] Fixed compilation for void-reh bug by updating package.json in remote and changed destination folder to say void --- build/gulpfile.reh.js | 2 +- remote/package-lock.json | 6 ++++++ remote/package.json | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/build/gulpfile.reh.js b/build/gulpfile.reh.js index e3147945..4c6841ce 100644 --- a/build/gulpfile.reh.js +++ b/build/gulpfile.reh.js @@ -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}`), diff --git a/remote/package-lock.json b/remote/package-lock.json index 0d0df312..617682af 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -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", diff --git a/remote/package.json b/remote/package.json index cf913ad2..e3475d33 100644 --- a/remote/package.json +++ b/remote/package.json @@ -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", From 79fb8f63a75e111bf4fc3957610dc620d816ccc5 Mon Sep 17 00:00:00 2001 From: Joaquin Coromina <75667013+bjoaquinc@users.noreply.github.com> Date: Thu, 13 Feb 2025 01:33:43 +0700 Subject: [PATCH 02/18] Added Open Remote-SSH extension to Void --- build/gulpfile.extensions.js | 1 + extensions/open-remote-ssh/CHANGELOG.md | 74 +++ extensions/open-remote-ssh/README.md | 48 ++ .../extension-browser.webpack.config.js | 17 + .../extension.webpack.config.js | 34 + extensions/open-remote-ssh/package-lock.json | 370 +++++++++++ extensions/open-remote-ssh/package.json | 351 ++++++++++ extensions/open-remote-ssh/resources/icon.png | Bin 0 -> 17422 bytes .../open-remote-ssh/src/authResolver.ts | 464 +++++++++++++ extensions/open-remote-ssh/src/commands.ts | 68 ++ .../open-remote-ssh/src/common/disposable.ts | 41 ++ .../open-remote-ssh/src/common/files.ts | 25 + .../open-remote-ssh/src/common/logger.ts | 63 ++ .../open-remote-ssh/src/common/platform.ts | 7 + .../open-remote-ssh/src/common/ports.ts | 133 ++++ extensions/open-remote-ssh/src/extension.ts | 37 ++ .../open-remote-ssh/src/hostTreeView.ts | 109 +++ .../src/remoteLocationHistory.ts | 58 ++ .../open-remote-ssh/src/serverConfig.ts | 43 ++ extensions/open-remote-ssh/src/serverSetup.ts | 626 ++++++++++++++++++ .../open-remote-ssh/src/ssh/hostfile.ts | 46 ++ .../open-remote-ssh/src/ssh/identityFiles.ts | 120 ++++ .../open-remote-ssh/src/ssh/sshConfig.ts | 129 ++++ .../open-remote-ssh/src/ssh/sshConnection.ts | 367 ++++++++++ .../open-remote-ssh/src/ssh/sshDestination.ts | 58 ++ extensions/open-remote-ssh/tsconfig.json | 12 + 26 files changed, 3301 insertions(+) create mode 100644 extensions/open-remote-ssh/CHANGELOG.md create mode 100644 extensions/open-remote-ssh/README.md create mode 100644 extensions/open-remote-ssh/extension-browser.webpack.config.js create mode 100644 extensions/open-remote-ssh/extension.webpack.config.js create mode 100644 extensions/open-remote-ssh/package-lock.json create mode 100644 extensions/open-remote-ssh/package.json create mode 100644 extensions/open-remote-ssh/resources/icon.png create mode 100644 extensions/open-remote-ssh/src/authResolver.ts create mode 100644 extensions/open-remote-ssh/src/commands.ts create mode 100644 extensions/open-remote-ssh/src/common/disposable.ts create mode 100644 extensions/open-remote-ssh/src/common/files.ts create mode 100644 extensions/open-remote-ssh/src/common/logger.ts create mode 100644 extensions/open-remote-ssh/src/common/platform.ts create mode 100644 extensions/open-remote-ssh/src/common/ports.ts create mode 100644 extensions/open-remote-ssh/src/extension.ts create mode 100644 extensions/open-remote-ssh/src/hostTreeView.ts create mode 100644 extensions/open-remote-ssh/src/remoteLocationHistory.ts create mode 100644 extensions/open-remote-ssh/src/serverConfig.ts create mode 100644 extensions/open-remote-ssh/src/serverSetup.ts create mode 100644 extensions/open-remote-ssh/src/ssh/hostfile.ts create mode 100644 extensions/open-remote-ssh/src/ssh/identityFiles.ts create mode 100644 extensions/open-remote-ssh/src/ssh/sshConfig.ts create mode 100644 extensions/open-remote-ssh/src/ssh/sshConnection.ts create mode 100644 extensions/open-remote-ssh/src/ssh/sshDestination.ts create mode 100644 extensions/open-remote-ssh/tsconfig.json diff --git a/build/gulpfile.extensions.js b/build/gulpfile.extensions.js index 4f745aeb..65ea5daf 100644 --- a/build/gulpfile.extensions.js +++ b/build/gulpfile.extensions.js @@ -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', diff --git a/extensions/open-remote-ssh/CHANGELOG.md b/extensions/open-remote-ssh/CHANGELOG.md new file mode 100644 index 00000000..71a79fd2 --- /dev/null +++ b/extensions/open-remote-ssh/CHANGELOG.md @@ -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 diff --git a/extensions/open-remote-ssh/README.md b/extensions/open-remote-ssh/README.md new file mode 100644 index 00000000..2a208f68 --- /dev/null +++ b/extensions/open-remote-ssh/README.md @@ -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. diff --git a/extensions/open-remote-ssh/extension-browser.webpack.config.js b/extensions/open-remote-ssh/extension-browser.webpack.config.js new file mode 100644 index 00000000..7fcc53a7 --- /dev/null +++ b/extensions/open-remote-ssh/extension-browser.webpack.config.js @@ -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' + } +}); diff --git a/extensions/open-remote-ssh/extension.webpack.config.js b/extensions/open-remote-ssh/extension.webpack.config.js new file mode 100644 index 00000000..e124b7f4 --- /dev/null +++ b/extensions/open-remote-ssh/extension.webpack.config.js @@ -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/, + }) + ] +}); diff --git a/extensions/open-remote-ssh/package-lock.json b/extensions/open-remote-ssh/package-lock.json new file mode 100644 index 00000000..8b9b7d82 --- /dev/null +++ b/extensions/open-remote-ssh/package-lock.json @@ -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" + } + } +} diff --git a/extensions/open-remote-ssh/package.json b/extensions/open-remote-ssh/package.json new file mode 100644 index 00000000..f6fc75a8 --- /dev/null +++ b/extensions/open-remote-ssh/package.json @@ -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" + } +} diff --git a/extensions/open-remote-ssh/resources/icon.png b/extensions/open-remote-ssh/resources/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..aa5a7b9fb1b8fc098240411765c04e58da0ccf8a GIT binary patch literal 17422 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 mF^wi2`NPg)mdN1{9&P!5d$wS`Otvlq5O})!xvX { + 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('enableDynamicForwarding', true)!; + const enableAgentForwarding = remoteSSHconfig.get('enableAgentForwarding', true)!; + const serverDownloadUrlTemplate = remoteSSHconfig.get('serverDownloadUrlTemplate'); + const defaultExtensions = remoteSSHconfig.get('defaultExtensions', []); + const remotePlatformMap = remoteSSHconfig.get>('remotePlatform', {}); + const remoteServerListenOnSocket = remoteSSHconfig.get('remoteServerListenOnSocket', false)!; + const connectTimeout = remoteSSHconfig.get('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]; + }); + 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 = {}; + 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((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(); + } +} diff --git a/extensions/open-remote-ssh/src/commands.ts b/extensions/open-remote-ssh/src/commands.ts new file mode 100644 index 00000000..ccc9c7ff --- /dev/null +++ b/extensions/open-remote-ssh/src/commands.ts @@ -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)); +} diff --git a/extensions/open-remote-ssh/src/common/disposable.ts b/extensions/open-remote-ssh/src/common/disposable.ts new file mode 100644 index 00000000..c81eb6cd --- /dev/null +++ b/extensions/open-remote-ssh/src/common/disposable.ts @@ -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(value: T): T { + if (this._isDisposed) { + value.dispose(); + } else { + this._disposables.push(value); + } + return value; + } + + protected get isDisposed(): boolean { + return this._isDisposed; + } +} diff --git a/extensions/open-remote-ssh/src/common/files.ts b/extensions/open-remote-ssh/src/common/files.ts new file mode 100644 index 00000000..6d9ff46f --- /dev/null +++ b/extensions/open-remote-ssh/src/common/files.ts @@ -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, '/'); +} diff --git a/extensions/open-remote-ssh/src/common/logger.ts b/extensions/open-remote-ssh/src/common/logger.ts new file mode 100644 index 00000000..6489d0c0 --- /dev/null +++ b/extensions/open-remote-ssh/src/common/logger.ts @@ -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; +} diff --git a/extensions/open-remote-ssh/src/common/platform.ts b/extensions/open-remote-ssh/src/common/platform.ts new file mode 100644 index 00000000..73c65ca9 --- /dev/null +++ b/extensions/open-remote-ssh/src/common/platform.ts @@ -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'; diff --git a/extensions/open-remote-ssh/src/common/ports.ts b/extensions/open-remote-ssh/src/common/ports.ts new file mode 100644 index 00000000..e135441b --- /dev/null +++ b/extensions/open-remote-ssh/src/common/ports.ts @@ -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 { + 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 { + 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 { + 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(resolve => { + timeoutHandle = setTimeout(() => { + doResolve(0, resolve); + }, timeout); + + server.on('listening', () => { + doResolve(startPort, resolve); + }); + server.on('error', err => { + if (err && ((err).code === 'EADDRINUSE' || (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 + } +} diff --git a/extensions/open-remote-ssh/src/extension.ts b/extensions/open-remote-ssh/src/extension.ts new file mode 100644 index 00000000..e950f545 --- /dev/null +++ b/extensions/open-remote-ssh/src/extension.ts @@ -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() { +} diff --git a/extensions/open-remote-ssh/src/hostTreeView.ts b/extensions/open-remote-ssh/src/hostTreeView.ts new file mode 100644 index 00000000..5874c53e --- /dev/null +++ b/extensions/open-remote-ssh/src/hostTreeView.ts @@ -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 { + + private readonly _onDidChangeTreeData = this._register(new vscode.EventEmitter()); + 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 { + 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); + } +} diff --git a/extensions/open-remote-ssh/src/remoteLocationHistory.ts b/extensions/open-remote-ssh/src/remoteLocationHistory.ts new file mode 100644 index 00000000..6e6b7777 --- /dev/null +++ b/extensions/open-remote-ssh/src/remoteLocationHistory.ts @@ -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 = {}; + + 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; +} diff --git a/extensions/open-remote-ssh/src/serverConfig.ts b/extensions/open-remote-ssh/src/serverConfig.ts new file mode 100644 index 00000000..3f2758fa --- /dev/null +++ b/extensions/open-remote-ssh/src/serverConfig.ts @@ -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 { + const productJson = await getVSCodeProductJson(); + + const customServerBinaryName = vscode.workspace.getConfiguration('remote.SSH.experimental').get('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 + }; +} diff --git a/extensions/open-remote-ssh/src/serverSetup.ts b/extensions/open-remote-ssh/src/serverSetup.ts new file mode 100644 index 00000000..d851988a --- /dev/null +++ b/extensions/open-remote-ssh/src/serverSetup.ts @@ -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/${NAME_OF_REPO}/releases/download/${version}.${release}/void-server-${os}-${arch}-${version}.${release}.tar.gz'; + +export async function installCodeServer(conn: SSHConnection, serverDownloadUrlTemplate: string | undefined, extensionIds: string[], envVariables: string[], platform: string | undefined, useSocketPath: boolean, logger: Log): Promise { + 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 + } +} +`; +} diff --git a/extensions/open-remote-ssh/src/ssh/hostfile.ts b/extensions/open-remote-ssh/src/ssh/hostfile.ts new file mode 100644 index 00000000..51d34a9e --- /dev/null +++ b/extensions/open-remote-ssh/src/ssh/hostfile.ts @@ -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 { + 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 { + 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); +} diff --git a/extensions/open-remote-ssh/src/ssh/identityFiles.ts b/extensions/open-remote-ssh/src/ssh/identityFiles.ts new file mode 100644 index 00000000..ffb33161 --- /dev/null +++ b/extensions/open-remote-ssh/src/ssh/identityFiles.ts @@ -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((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((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; +} diff --git a/extensions/open-remote-ssh/src/ssh/sshConfig.ts b/extensions/open-remote-ssh/src/ssh/sshConfig.ts new file mode 100644 index 00000000..6ce6ed32 --- /dev/null +++ b/extensions/open-remote-ssh/src/ssh/sshConfig.ts @@ -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('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 = { + '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 { + 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(); + 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 { + // 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; + } +} diff --git a/extensions/open-remote-ssh/src/ssh/sshConnection.ts b/extensions/open-remote-ssh/src/ssh/sshConnection.ts new file mode 100644 index 00000000..abaf1471 --- /dev/null +++ b/extensions/open-remote-ssh/src/ssh/sshConnection.ts @@ -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 = { + 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 | 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 { + return this.connect().then(() => { + return new Promise((resolve, reject) => { + this.sshConnection!.shell(options, (err, stream) => err ? reject(err) : resolve(stream)); + }); + }); + } + + /** + * Exec a command + */ + exec(cmd: string, params?: Array, 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, 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 { + 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 { + return this.addTunnel({ name: '__socksServer', socks: true, localPort: localPort }).then((tunnel) => { + return tunnel.localPort!; + }); + } + + /** + * Close SSH Connection + */ + close(): Promise { + 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 { + 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.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 { + 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(); + } +} diff --git a/extensions/open-remote-ssh/src/ssh/sshDestination.ts b/extensions/open-remote-ssh/src/ssh/sshDestination.ts new file mode 100644 index 00000000..db0b4406 --- /dev/null +++ b/extensions/open-remote-ssh/src/ssh/sshDestination.ts @@ -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()}`); + } +} diff --git a/extensions/open-remote-ssh/tsconfig.json b/extensions/open-remote-ssh/tsconfig.json new file mode 100644 index 00000000..2e08e0d2 --- /dev/null +++ b/extensions/open-remote-ssh/tsconfig.json @@ -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", + ] +} From 28bf838b55c1a4e462659ccbfddfe0b67c458511 Mon Sep 17 00:00:00 2001 From: Joaquin Coromina <75667013+bjoaquinc@users.noreply.github.com> Date: Fri, 14 Feb 2025 10:05:30 +0700 Subject: [PATCH 03/18] Updated void-server endpoiont to make it dynamic --- extensions/open-remote-ssh/src/serverSetup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/open-remote-ssh/src/serverSetup.ts b/extensions/open-remote-ssh/src/serverSetup.ts index d851988a..d74ff8e9 100644 --- a/extensions/open-remote-ssh/src/serverSetup.ts +++ b/extensions/open-remote-ssh/src/serverSetup.ts @@ -39,7 +39,7 @@ export class ServerInstallError extends Error { } } -const DEFAULT_DOWNLOAD_URL_TEMPLATE = 'https://github.com/voideditor/${NAME_OF_REPO}/releases/download/${version}.${release}/void-server-${os}-${arch}-${version}.${release}.tar.gz'; +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 { let shell = 'powershell'; From 1839acab1f40fbf44c6e9283850461da5ad3e121 Mon Sep 17 00:00:00 2001 From: adrieljss Date: Fri, 14 Feb 2025 17:59:56 +0800 Subject: [PATCH 04/18] fix ENOENT error spawn on Windows --- package-lock.json | 10 +++------- package.json | 1 + src/vs/workbench/contrib/void/browser/react/build.js | 3 ++- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index bc045891..f1265773 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -7116,7 +7117,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 +14250,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", @@ -17536,7 +17536,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" } @@ -20241,7 +20240,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" }, @@ -20253,7 +20251,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" } @@ -23733,7 +23730,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" }, diff --git a/package.json b/package.json index a4ee38bb..31b355b4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/vs/workbench/contrib/void/browser/react/build.js b/src/vs/workbench/contrib/void/browser/react/build.js index ece3ebf6..ab4ea921 100755 --- a/src/vs/workbench/contrib/void/browser/react/build.js +++ b/src/vs/workbench/contrib/void/browser/react/build.js @@ -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'; From 7849b0a107249add72a35a20fffa5cc8da281058 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 15 Feb 2025 13:35:58 +0000 Subject: [PATCH 05/18] Fix Apply failure with tags (#283) - Add utility to strip tags from responses - Strip tags before computing diffs in Apply operation - Handle nested tags properly - Keep original response intact for UI display Fixes #283 Co-Authored-By: Jack Hacksman --- .../browser/actions/codeBlockOperations.ts | 7 ++--- .../contrib/chat/common/chatModel.ts | 30 +++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.ts b/src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.ts index 3149f453..947199fc 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.ts @@ -11,6 +11,7 @@ import { ResourceMap } from '../../../../../base/common/map.js'; import { isEqual } from '../../../../../base/common/resources.js'; import * as strings from '../../../../../base/common/strings.js'; import { URI } from '../../../../../base/common/uri.js'; +import { stripThinkTags } from '../../common/chatModel.js'; import { IActiveCodeEditor, isCodeEditor, isDiffEditor } from '../../../../../editor/browser/editorBrowser.js'; import { IBulkEditService, ResourceTextEdit } from '../../../../../editor/browser/services/bulkEditService.js'; import { ICodeEditorService } from '../../../../../editor/browser/services/codeEditorService.js'; @@ -382,13 +383,11 @@ function collectDocumentContextFromContext(context: ICodeBlockActionContext, res } function getChatConversation(context: ICodeBlockActionContext): (ConversationRequest | ConversationResponse)[] { - // TODO@aeschli for now create a conversation with just the current element - // this will be expanded in the future to include the request and any other responses - if (isResponseVM(context.element)) { return [{ type: 'response', - message: context.element.response.toMarkdown(), + // Strip think tags before computing diffs + message: stripThinkTags(context.element.response.toMarkdown()), references: getReferencesAsDocumentContext(context.element.contentReferences) }]; } else if (isRequestVM(context.element)) { diff --git a/src/vs/workbench/contrib/chat/common/chatModel.ts b/src/vs/workbench/contrib/chat/common/chatModel.ts index f11a40b7..ecb4d205 100644 --- a/src/vs/workbench/contrib/chat/common/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/chatModel.ts @@ -354,6 +354,36 @@ export class Response extends Disposable implements IResponse { } } +/** + * Strips tags and their content from a text string. + * Handles nested tags using a stack-based approach. + * @param text The text to strip tags from + * @returns The text with all tags and their content removed + */ +export function stripThinkTags(text: string): string { + // Handle nested tags with a stack-based approach + let result = ''; + let depth = 0; + let i = 0; + + while (i < text.length) { + if (text.startsWith('', i)) { + depth++; + i += 7; // length of '' + } else if (text.startsWith('', i)) { + depth--; + i += 8; // length of '' + } else if (depth === 0) { + result += text[i]; + i++; + } else { + i++; + } + } + + return result; +} + export class ChatResponseModel extends Disposable implements IChatResponseModel { private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange = this._onDidChange.event; From a26816d597598182779cfb2fe740722d18d894ba Mon Sep 17 00:00:00 2001 From: Andrew Pareles <43356051+andrewpareles@users.noreply.github.com> Date: Sat, 15 Feb 2025 15:46:39 -0800 Subject: [PATCH 06/18] Update src/vs/workbench/contrib/chat/common/chatModel.ts Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --- src/vs/workbench/contrib/chat/common/chatModel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/common/chatModel.ts b/src/vs/workbench/contrib/chat/common/chatModel.ts index ecb4d205..47ea9477 100644 --- a/src/vs/workbench/contrib/chat/common/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/chatModel.ts @@ -371,7 +371,7 @@ export function stripThinkTags(text: string): string { depth++; i += 7; // length of '' } else if (text.startsWith('', i)) { - depth--; + if (depth > 0) depth--; i += 8; // length of '' } else if (depth === 0) { result += text[i]; From a519bd769089667b81b387b4890365affc73abec Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 16 Feb 2025 04:59:55 +0000 Subject: [PATCH 07/18] Add streaming support for think tags and test cases Co-Authored-By: Jack Hacksman --- .../contrib/chat/common/chatModel.ts | 45 ++- .../chat/test/common/chatModel.test.ts | 283 ++---------------- 2 files changed, 67 insertions(+), 261 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/chatModel.ts b/src/vs/workbench/contrib/chat/common/chatModel.ts index 47ea9477..df982d6c 100644 --- a/src/vs/workbench/contrib/chat/common/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/chatModel.ts @@ -8,6 +8,7 @@ import { DeferredPromise } from '../../../../base/common/async.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { IMarkdownString, MarkdownString, isMarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; +import { SurroundingsRemover } from '../../../void/browser/helpers/extractCodeFromResult.js'; import { revive } from '../../../../base/common/marshalling.js'; import { equals } from '../../../../base/common/objects.js'; import { basename, isEqual } from '../../../../base/common/resources.js'; @@ -249,6 +250,11 @@ export class Response extends Disposable implements IResponse { updateContent(progress: IChatProgressResponseContent | IChatTextEdit | IChatTask, quiet?: boolean): void { if (progress.kind === 'markdownContent') { + // Handle streaming for think tags + const remover = new ThinkTagSurroundingsRemover(progress.content.value); + const [delta, ignoredSuffix] = remover.deltaInfo(progress.content.value.length); + progress.content.value = delta; + const responsePartLength = this._responseParts.length - 1; const lastResponsePart = this._responseParts[responsePartLength]; @@ -365,6 +371,7 @@ export function stripThinkTags(text: string): string { let result = ''; let depth = 0; let i = 0; + let inPartialTag = false; while (i < text.length) { if (text.startsWith('', i)) { @@ -373,17 +380,53 @@ export function stripThinkTags(text: string): string { } else if (text.startsWith('', i)) { if (depth > 0) depth--; i += 8; // length of '' - } else if (depth === 0) { + } else if (text.startsWith('', i)) { + inPartialTag = false; + } } return result; } +class ThinkTagSurroundingsRemover extends SurroundingsRemover { + constructor(s: string) { + super(s); + } + + removeThinkTags() { + const foundTag = this.removePrefix(''); + if (!foundTag) { + // Handle partial tags during streaming + if (this.originalS.startsWith('()); readonly onDidChange = this._onDidChange.event; diff --git a/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts index 0a49d0bf..8269d4fa 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts @@ -3,276 +3,39 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import assert from 'assert'; -import { timeout } from '../../../../../base/common/async.js'; -import { MarkdownString } from '../../../../../base/common/htmlContent.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { assertSnapshot } from '../../../../../base/test/common/snapshot.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { OffsetRange } from '../../../../../editor/common/core/offsetRange.js'; -import { Range } from '../../../../../editor/common/core/range.js'; -import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; -import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; -import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; -import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; -import { IStorageService } from '../../../../../platform/storage/common/storage.js'; -import { ChatAgentLocation, ChatAgentService, IChatAgentService } from '../../common/chatAgents.js'; -import { ChatModel, ISerializableChatData1, ISerializableChatData2, ISerializableChatData3, normalizeSerializableChatData, Response } from '../../common/chatModel.js'; -import { ChatRequestTextPart } from '../../common/chatParserTypes.js'; -import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; -import { TestExtensionService, TestStorageService } from '../../../../test/common/workbenchTestServices.js'; +import * as assert from 'assert'; +import { Response, stripThinkTags } from '../../../common/chatModel'; +import { MarkdownString } from '../../../../../../base/common/htmlContent'; -suite('ChatModel', () => { - const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); +suite('ChatModel - Think Tags', () => { + test('handles partial tags during streaming', () => { + const response = new Response(new MarkdownString('test') }); + assert.strictEqual(response.toString(), ''); - setup(async () => { - instantiationService = testDisposables.add(new TestInstantiationService()); - instantiationService.stub(IStorageService, testDisposables.add(new TestStorageService())); - instantiationService.stub(ILogService, new NullLogService()); - instantiationService.stub(IExtensionService, new TestExtensionService()); - instantiationService.stub(IContextKeyService, new MockContextKeyService()); - instantiationService.stub(IChatAgentService, instantiationService.createInstance(ChatAgentService)); + response.updateContent({ kind: 'markdownContent', content: new MarkdownString('test') }); + assert.strictEqual(response.toString(), 'test'); }); - test('Waits for initialization', async () => { - const model = testDisposables.add(instantiationService.createInstance(ChatModel, undefined, ChatAgentLocation.Panel)); - - let hasInitialized = false; - model.waitForInitialization().then(() => { - hasInitialized = true; - }); - - await timeout(0); - assert.strictEqual(hasInitialized, false); - - model.startInitialize(); - model.initialize(undefined); - await timeout(0); - assert.strictEqual(hasInitialized, true); + test('handles malformed tags', () => { + assert.strictEqual(stripThinkTags('unclosed'), ''); + assert.strictEqual(stripThinkTags('unopened'), 'unopened'); + assert.strictEqual(stripThinkTags('nestedtags'), ''); }); - test('must call startInitialize before initialize', async () => { - const model = testDisposables.add(instantiationService.createInstance(ChatModel, undefined, ChatAgentLocation.Panel)); + test('handles half-nested tags from conversation', () => { + const input = `Okay, the user wants me to create nested tags in my thought process. Let me start by recalling what they asked for. They initially mentioned using nested tags with multiple layers. In my first attempt, I probably just used a single pair of tags without nesting. - let hasInitialized = false; - model.waitForInitialization().then(() => { - hasInitialized = true; - }); +So, I need to make sure each opening tag is properly closed with a tag, and that these tags are nested. For example, starting with one tag, then another inside it, and so on. - await timeout(0); - assert.strictEqual(hasInitialized, false); - - assert.throws(() => model.initialize(undefined)); - assert.strictEqual(hasInitialized, false); +i tried`; + assert.strictEqual(stripThinkTags(input), 'i tried'); }); - test('deinitialize/reinitialize', async () => { - const model = testDisposables.add(instantiationService.createInstance(ChatModel, undefined, ChatAgentLocation.Panel)); - - let hasInitialized = false; - model.waitForInitialization().then(() => { - hasInitialized = true; - }); - - model.startInitialize(); - model.initialize(undefined); - await timeout(0); - assert.strictEqual(hasInitialized, true); - - model.deinitialize(); - let hasInitialized2 = false; - model.waitForInitialization().then(() => { - hasInitialized2 = true; - }); - - model.startInitialize(); - model.initialize(undefined); - await timeout(0); - assert.strictEqual(hasInitialized2, true); - }); - - test('cannot initialize twice', async () => { - const model = testDisposables.add(instantiationService.createInstance(ChatModel, undefined, ChatAgentLocation.Panel)); - - model.startInitialize(); - model.initialize(undefined); - assert.throws(() => model.initialize(undefined)); - }); - - test('Initialization fails when model is disposed', async () => { - const model = testDisposables.add(instantiationService.createInstance(ChatModel, undefined, ChatAgentLocation.Panel)); - model.dispose(); - - assert.throws(() => model.initialize(undefined)); - }); - - test('removeRequest', async () => { - const model = testDisposables.add(instantiationService.createInstance(ChatModel, undefined, ChatAgentLocation.Panel)); - - model.startInitialize(); - model.initialize(undefined); - const text = 'hello'; - model.addRequest({ text, parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, text.length, 1, text.length), text)] }, { variables: [] }, 0); - const requests = model.getRequests(); - assert.strictEqual(requests.length, 1); - - model.removeRequest(requests[0].id); - assert.strictEqual(model.getRequests().length, 0); - }); - - test('adoptRequest', async function () { - const model1 = testDisposables.add(instantiationService.createInstance(ChatModel, undefined, ChatAgentLocation.Editor)); - const model2 = testDisposables.add(instantiationService.createInstance(ChatModel, undefined, ChatAgentLocation.Panel)); - - model1.startInitialize(); - model1.initialize(undefined); - - model2.startInitialize(); - model2.initialize(undefined); - - const text = 'hello'; - const request1 = model1.addRequest({ text, parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, text.length, 1, text.length), text)] }, { variables: [] }, 0); - - assert.strictEqual(model1.getRequests().length, 1); - assert.strictEqual(model2.getRequests().length, 0); - assert.ok(request1.session === model1); - assert.ok(request1.response?.session === model1); - - model2.adoptRequest(request1); - - assert.strictEqual(model1.getRequests().length, 0); - assert.strictEqual(model2.getRequests().length, 1); - assert.ok(request1.session === model2); - assert.ok(request1.response?.session === model2); - - model2.acceptResponseProgress(request1, { content: new MarkdownString('Hello'), kind: 'markdownContent' }); - - assert.strictEqual(request1.response.response.toString(), 'Hello'); - }); -}); - -suite('Response', () => { - const store = ensureNoDisposablesAreLeakedInTestSuite(); - - test('mergeable markdown', async () => { - const response = store.add(new Response([])); - response.updateContent({ content: new MarkdownString('markdown1'), kind: 'markdownContent' }); - response.updateContent({ content: new MarkdownString('markdown2'), kind: 'markdownContent' }); - await assertSnapshot(response.value); - - assert.strictEqual(response.toString(), 'markdown1markdown2'); - }); - - test('not mergeable markdown', async () => { - const response = store.add(new Response([])); - const md1 = new MarkdownString('markdown1'); - md1.supportHtml = true; - response.updateContent({ content: md1, kind: 'markdownContent' }); - response.updateContent({ content: new MarkdownString('markdown2'), kind: 'markdownContent' }); - await assertSnapshot(response.value); - }); - - test('inline reference', async () => { - const response = store.add(new Response([])); - response.updateContent({ content: new MarkdownString('text before'), kind: 'markdownContent' }); - response.updateContent({ inlineReference: URI.parse('https://microsoft.com'), kind: 'inlineReference' }); - response.updateContent({ content: new MarkdownString('text after'), kind: 'markdownContent' }); - await assertSnapshot(response.value); - }); -}); - -suite('normalizeSerializableChatData', () => { - ensureNoDisposablesAreLeakedInTestSuite(); - - test('v1', () => { - const v1Data: ISerializableChatData1 = { - creationDate: Date.now(), - initialLocation: undefined, - isImported: false, - requesterAvatarIconUri: undefined, - requesterUsername: 'me', - requests: [], - responderAvatarIconUri: undefined, - responderUsername: 'bot', - sessionId: 'session1', - }; - - const newData = normalizeSerializableChatData(v1Data); - assert.strictEqual(newData.creationDate, v1Data.creationDate); - assert.strictEqual(newData.lastMessageDate, v1Data.creationDate); - assert.strictEqual(newData.version, 3); - assert.ok('customTitle' in newData); - }); - - test('v2', () => { - const v2Data: ISerializableChatData2 = { - version: 2, - creationDate: 100, - lastMessageDate: Date.now(), - initialLocation: undefined, - isImported: false, - requesterAvatarIconUri: undefined, - requesterUsername: 'me', - requests: [], - responderAvatarIconUri: undefined, - responderUsername: 'bot', - sessionId: 'session1', - computedTitle: 'computed title' - }; - - const newData = normalizeSerializableChatData(v2Data); - assert.strictEqual(newData.version, 3); - assert.strictEqual(newData.creationDate, v2Data.creationDate); - assert.strictEqual(newData.lastMessageDate, v2Data.lastMessageDate); - assert.strictEqual(newData.customTitle, v2Data.computedTitle); - }); - - test('old bad data', () => { - const v1Data: ISerializableChatData1 = { - // Testing the scenario where these are missing - sessionId: undefined!, - creationDate: undefined!, - - initialLocation: undefined, - isImported: false, - requesterAvatarIconUri: undefined, - requesterUsername: 'me', - requests: [], - responderAvatarIconUri: undefined, - responderUsername: 'bot', - }; - - const newData = normalizeSerializableChatData(v1Data); - assert.strictEqual(newData.version, 3); - assert.ok(newData.creationDate > 0); - assert.ok(newData.lastMessageDate > 0); - assert.ok(newData.sessionId); - }); - - test('v3 with bug', () => { - const v3Data: ISerializableChatData3 = { - // Test case where old data was wrongly normalized and these fields were missing - creationDate: undefined!, - lastMessageDate: undefined!, - - version: 3, - initialLocation: undefined, - isImported: false, - requesterAvatarIconUri: undefined, - requesterUsername: 'me', - requests: [], - responderAvatarIconUri: undefined, - responderUsername: 'bot', - sessionId: 'session1', - customTitle: 'computed title' - }; - - const newData = normalizeSerializableChatData(v3Data); - assert.strictEqual(newData.version, 3); - assert.ok(newData.creationDate > 0); - assert.ok(newData.lastMessageDate > 0); - assert.ok(newData.sessionId); + test('preserves text mentions of tags', () => { + const input = 'Let me try with tags and see how it works'; + assert.strictEqual(stripThinkTags(input), 'Let me try with tags and see how it works'); }); }); From b24cd1ecb7356d2228a801086e08bfdd6e6c1b8f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 16 Feb 2025 05:01:57 +0000 Subject: [PATCH 08/18] Improve token streaming support for think tags Co-Authored-By: Jack Hacksman --- .../workbench/contrib/chat/common/chatModel.ts | 17 ++++++++--------- .../contrib/chat/test/common/chatModel.test.ts | 16 ++++++++++++++-- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/chatModel.ts b/src/vs/workbench/contrib/chat/common/chatModel.ts index df982d6c..7fb12aac 100644 --- a/src/vs/workbench/contrib/chat/common/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/chatModel.ts @@ -409,16 +409,15 @@ class ThinkTagSurroundingsRemover extends SurroundingsRemover { } removeThinkTags() { - const foundTag = this.removePrefix(''); - if (!foundTag) { - // Handle partial tags during streaming - if (this.originalS.startsWith(''); + if (!foundOpenTag) { + // Let removePrefix handle partial matches character by character + this.removePrefix('<'); + this.removePrefix('think'); + this.removePrefix('>'); } - return true; + return foundOpenTag; } deltaInfo(recentlyAddedTextLen: number) { diff --git a/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts index 8269d4fa..a145fba6 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts @@ -8,8 +8,20 @@ import { Response, stripThinkTags } from '../../../common/chatModel'; import { MarkdownString } from '../../../../../../base/common/htmlContent'; suite('ChatModel - Think Tags', () => { - test('handles partial tags during streaming', () => { - const response = new Response(new MarkdownString(' { + const response = new Response(new MarkdownString('<')); + assert.strictEqual(response.toString(), ''); + + response.updateContent({ kind: 'markdownContent', content: new MarkdownString('') }); assert.strictEqual(response.toString(), ''); response.updateContent({ kind: 'markdownContent', content: new MarkdownString('test') }); From 2f55580e674e81740e0c13f4ce395192395026df Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 16 Feb 2025 05:06:37 +0000 Subject: [PATCH 09/18] Improve token streaming support with character-by-character matching Co-Authored-By: Jack Hacksman --- .../contrib/chat/common/chatModel.ts | 34 +++++++------------ .../chat/test/common/chatModel.test.ts | 6 ++++ 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/chatModel.ts b/src/vs/workbench/contrib/chat/common/chatModel.ts index 7fb12aac..98519d4f 100644 --- a/src/vs/workbench/contrib/chat/common/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/chatModel.ts @@ -371,7 +371,6 @@ export function stripThinkTags(text: string): string { let result = ''; let depth = 0; let i = 0; - let inPartialTag = false; while (i < text.length) { if (text.startsWith('', i)) { @@ -380,24 +379,12 @@ export function stripThinkTags(text: string): string { } else if (text.startsWith('', i)) { if (depth > 0) depth--; i += 8; // length of '' - } else if (text.startsWith('', i)) { - inPartialTag = false; - } } return result; @@ -409,15 +396,18 @@ class ThinkTagSurroundingsRemover extends SurroundingsRemover { } removeThinkTags() { - // Try to remove opening tag, handling partial tokens during streaming - const foundOpenTag = this.removePrefix(''); - if (!foundOpenTag) { - // Let removePrefix handle partial matches character by character - this.removePrefix('<'); - this.removePrefix('think'); - this.removePrefix('>'); + // Handle token streaming character by character + const chars = ['<', 't', 'h', 'i', 'n', 'k', '>']; + let foundTag = true; + + for (const char of chars) { + if (!this.removePrefix(char)) { + foundTag = false; + break; + } } - return foundOpenTag; + + return foundTag; } deltaInfo(recentlyAddedTextLen: number) { diff --git a/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts index a145fba6..24ff8129 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts @@ -15,9 +15,15 @@ suite('ChatModel - Think Tags', () => { response.updateContent({ kind: 'markdownContent', content: new MarkdownString(' Date: Sun, 16 Feb 2025 05:14:40 +0000 Subject: [PATCH 10/18] Improve token streaming support with character-by-character matching Co-Authored-By: Jack Hacksman --- .../contrib/chat/common/chatModel.ts | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/chatModel.ts b/src/vs/workbench/contrib/chat/common/chatModel.ts index 98519d4f..81b0c970 100644 --- a/src/vs/workbench/contrib/chat/common/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/chatModel.ts @@ -396,14 +396,27 @@ class ThinkTagSurroundingsRemover extends SurroundingsRemover { } removeThinkTags() { - // Handle token streaming character by character - const chars = ['<', 't', 'h', 'i', 'n', 'k', '>']; - let foundTag = true; + // Handle token streaming at a more granular level + let foundTag = false; - for (const char of chars) { - if (!this.removePrefix(char)) { - foundTag = false; - break; + // Try to remove opening tag, handling partial tokens + foundTag = this.removePrefix('<'); + if (foundTag) { + foundTag = this.removePrefix('t'); + if (foundTag) { + foundTag = this.removePrefix('h'); + if (foundTag) { + foundTag = this.removePrefix('i'); + if (foundTag) { + foundTag = this.removePrefix('n'); + if (foundTag) { + foundTag = this.removePrefix('k'); + if (foundTag) { + foundTag = this.removePrefix('>'); + } + } + } + } } } @@ -411,7 +424,10 @@ class ThinkTagSurroundingsRemover extends SurroundingsRemover { } deltaInfo(recentlyAddedTextLen: number) { + // Get the delta and suffix from parent class const [delta, ignoredSuffix] = super.deltaInfo(recentlyAddedTextLen); + + // Strip any think tags from the delta before returning return [stripThinkTags(delta), ignoredSuffix] as const; } } From a38fc81a583c889d8564c7065ab5b5efddfd2573 Mon Sep 17 00:00:00 2001 From: eswaldots Date: Sun, 16 Feb 2025 15:34:53 +0000 Subject: [PATCH 11/18] Fix encryption service dont working on Linux --- .../encryption/electron-main/encryptionMainService.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/vs/platform/encryption/electron-main/encryptionMainService.ts b/src/vs/platform/encryption/electron-main/encryptionMainService.ts index 1b4b1e05..c7fa13d9 100644 --- a/src/vs/platform/encryption/electron-main/encryptionMainService.ts +++ b/src/vs/platform/encryption/electron-main/encryptionMainService.ts @@ -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...'); From a519100852eb58054e6c19246696b048ea19cd6f Mon Sep 17 00:00:00 2001 From: Joaquin Coromina <75667013+bjoaquinc@users.noreply.github.com> Date: Sun, 16 Feb 2025 23:31:21 +0700 Subject: [PATCH 12/18] Added open-remote-ssh to npm install dirs and the output files in eslint ignore --- .eslintignore | 1 + build/filters.js | 1 + build/npm/dirs.js | 1 + 3 files changed, 3 insertions(+) diff --git a/.eslintignore b/.eslintignore index 0299f2c1..1ddb0330 100644 --- a/.eslintignore +++ b/.eslintignore @@ -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 diff --git a/build/filters.js b/build/filters.js index 4e5049f2..a065d2ed 100644 --- a/build/filters.js +++ b/build/filters.js @@ -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', ]; diff --git a/build/npm/dirs.js b/build/npm/dirs.js index 7b11bde9..d06a1ec1 100644 --- a/build/npm/dirs.js +++ b/build/npm/dirs.js @@ -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', From 04efe1d235bac2b00975fca3cedd2614d75058ee Mon Sep 17 00:00:00 2001 From: Andrew Pareles Date: Mon, 24 Feb 2025 14:57:50 -0800 Subject: [PATCH 13/18] added extractReasoningFromText in extractCodeFromResult instead --- .../browser/actions/codeBlockOperations.ts | 7 +- .../contrib/chat/common/chatModel.ts | 78 ----- .../chat/test/common/chatModel.test.ts | 301 +++++++++++++++--- 3 files changed, 264 insertions(+), 122 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.ts b/src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.ts index 947199fc..3149f453 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.ts @@ -11,7 +11,6 @@ import { ResourceMap } from '../../../../../base/common/map.js'; import { isEqual } from '../../../../../base/common/resources.js'; import * as strings from '../../../../../base/common/strings.js'; import { URI } from '../../../../../base/common/uri.js'; -import { stripThinkTags } from '../../common/chatModel.js'; import { IActiveCodeEditor, isCodeEditor, isDiffEditor } from '../../../../../editor/browser/editorBrowser.js'; import { IBulkEditService, ResourceTextEdit } from '../../../../../editor/browser/services/bulkEditService.js'; import { ICodeEditorService } from '../../../../../editor/browser/services/codeEditorService.js'; @@ -383,11 +382,13 @@ function collectDocumentContextFromContext(context: ICodeBlockActionContext, res } function getChatConversation(context: ICodeBlockActionContext): (ConversationRequest | ConversationResponse)[] { + // TODO@aeschli for now create a conversation with just the current element + // this will be expanded in the future to include the request and any other responses + if (isResponseVM(context.element)) { return [{ type: 'response', - // Strip think tags before computing diffs - message: stripThinkTags(context.element.response.toMarkdown()), + message: context.element.response.toMarkdown(), references: getReferencesAsDocumentContext(context.element.contentReferences) }]; } else if (isRequestVM(context.element)) { diff --git a/src/vs/workbench/contrib/chat/common/chatModel.ts b/src/vs/workbench/contrib/chat/common/chatModel.ts index 81b0c970..f11a40b7 100644 --- a/src/vs/workbench/contrib/chat/common/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/chatModel.ts @@ -8,7 +8,6 @@ import { DeferredPromise } from '../../../../base/common/async.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { IMarkdownString, MarkdownString, isMarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { SurroundingsRemover } from '../../../void/browser/helpers/extractCodeFromResult.js'; import { revive } from '../../../../base/common/marshalling.js'; import { equals } from '../../../../base/common/objects.js'; import { basename, isEqual } from '../../../../base/common/resources.js'; @@ -250,11 +249,6 @@ export class Response extends Disposable implements IResponse { updateContent(progress: IChatProgressResponseContent | IChatTextEdit | IChatTask, quiet?: boolean): void { if (progress.kind === 'markdownContent') { - // Handle streaming for think tags - const remover = new ThinkTagSurroundingsRemover(progress.content.value); - const [delta, ignoredSuffix] = remover.deltaInfo(progress.content.value.length); - progress.content.value = delta; - const responsePartLength = this._responseParts.length - 1; const lastResponsePart = this._responseParts[responsePartLength]; @@ -360,78 +354,6 @@ export class Response extends Disposable implements IResponse { } } -/** - * Strips tags and their content from a text string. - * Handles nested tags using a stack-based approach. - * @param text The text to strip tags from - * @returns The text with all tags and their content removed - */ -export function stripThinkTags(text: string): string { - // Handle nested tags with a stack-based approach - let result = ''; - let depth = 0; - let i = 0; - - while (i < text.length) { - if (text.startsWith('', i)) { - depth++; - i += 7; // length of '' - } else if (text.startsWith('', i)) { - if (depth > 0) depth--; - i += 8; // length of '' - } else if (depth === 0) { - result += text[i]; - i++; - } else { - i++; - } - } - - return result; -} - -class ThinkTagSurroundingsRemover extends SurroundingsRemover { - constructor(s: string) { - super(s); - } - - removeThinkTags() { - // Handle token streaming at a more granular level - let foundTag = false; - - // Try to remove opening tag, handling partial tokens - foundTag = this.removePrefix('<'); - if (foundTag) { - foundTag = this.removePrefix('t'); - if (foundTag) { - foundTag = this.removePrefix('h'); - if (foundTag) { - foundTag = this.removePrefix('i'); - if (foundTag) { - foundTag = this.removePrefix('n'); - if (foundTag) { - foundTag = this.removePrefix('k'); - if (foundTag) { - foundTag = this.removePrefix('>'); - } - } - } - } - } - } - - return foundTag; - } - - deltaInfo(recentlyAddedTextLen: number) { - // Get the delta and suffix from parent class - const [delta, ignoredSuffix] = super.deltaInfo(recentlyAddedTextLen); - - // Strip any think tags from the delta before returning - return [stripThinkTags(delta), ignoredSuffix] as const; - } -} - export class ChatResponseModel extends Disposable implements IChatResponseModel { private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange = this._onDidChange.event; diff --git a/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts index 24ff8129..0a49d0bf 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatModel.test.ts @@ -3,57 +3,276 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as assert from 'assert'; -import { Response, stripThinkTags } from '../../../common/chatModel'; -import { MarkdownString } from '../../../../../../base/common/htmlContent'; +import assert from 'assert'; +import { timeout } from '../../../../../base/common/async.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { assertSnapshot } from '../../../../../base/test/common/snapshot.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { OffsetRange } from '../../../../../editor/common/core/offsetRange.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { ChatAgentLocation, ChatAgentService, IChatAgentService } from '../../common/chatAgents.js'; +import { ChatModel, ISerializableChatData1, ISerializableChatData2, ISerializableChatData3, normalizeSerializableChatData, Response } from '../../common/chatModel.js'; +import { ChatRequestTextPart } from '../../common/chatParserTypes.js'; +import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; +import { TestExtensionService, TestStorageService } from '../../../../test/common/workbenchTestServices.js'; -suite('ChatModel - Think Tags', () => { - test('handles partial tokens during streaming', () => { - const response = new Response(new MarkdownString('<')); - assert.strictEqual(response.toString(), ''); +suite('ChatModel', () => { + const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); - response.updateContent({ kind: 'markdownContent', content: new MarkdownString('') }); - assert.strictEqual(response.toString(), ''); - - response.updateContent({ kind: 'markdownContent', content: new MarkdownString('test') }); - assert.strictEqual(response.toString(), ''); - - response.updateContent({ kind: 'markdownContent', content: new MarkdownString('test') }); - assert.strictEqual(response.toString(), 'test'); + setup(async () => { + instantiationService = testDisposables.add(new TestInstantiationService()); + instantiationService.stub(IStorageService, testDisposables.add(new TestStorageService())); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IExtensionService, new TestExtensionService()); + instantiationService.stub(IContextKeyService, new MockContextKeyService()); + instantiationService.stub(IChatAgentService, instantiationService.createInstance(ChatAgentService)); }); - test('handles malformed tags', () => { - assert.strictEqual(stripThinkTags('unclosed'), ''); - assert.strictEqual(stripThinkTags('unopened'), 'unopened'); - assert.strictEqual(stripThinkTags('nestedtags'), ''); + test('Waits for initialization', async () => { + const model = testDisposables.add(instantiationService.createInstance(ChatModel, undefined, ChatAgentLocation.Panel)); + + let hasInitialized = false; + model.waitForInitialization().then(() => { + hasInitialized = true; + }); + + await timeout(0); + assert.strictEqual(hasInitialized, false); + + model.startInitialize(); + model.initialize(undefined); + await timeout(0); + assert.strictEqual(hasInitialized, true); }); - test('handles half-nested tags from conversation', () => { - const input = `Okay, the user wants me to create nested tags in my thought process. Let me start by recalling what they asked for. They initially mentioned using nested tags with multiple layers. In my first attempt, I probably just used a single pair of tags without nesting. + test('must call startInitialize before initialize', async () => { + const model = testDisposables.add(instantiationService.createInstance(ChatModel, undefined, ChatAgentLocation.Panel)); -So, I need to make sure each opening tag is properly closed with a tag, and that these tags are nested. For example, starting with one tag, then another inside it, and so on. + let hasInitialized = false; + model.waitForInitialization().then(() => { + hasInitialized = true; + }); -i tried`; - assert.strictEqual(stripThinkTags(input), 'i tried'); + await timeout(0); + assert.strictEqual(hasInitialized, false); + + assert.throws(() => model.initialize(undefined)); + assert.strictEqual(hasInitialized, false); }); - test('preserves text mentions of tags', () => { - const input = 'Let me try with tags and see how it works'; - assert.strictEqual(stripThinkTags(input), 'Let me try with tags and see how it works'); + test('deinitialize/reinitialize', async () => { + const model = testDisposables.add(instantiationService.createInstance(ChatModel, undefined, ChatAgentLocation.Panel)); + + let hasInitialized = false; + model.waitForInitialization().then(() => { + hasInitialized = true; + }); + + model.startInitialize(); + model.initialize(undefined); + await timeout(0); + assert.strictEqual(hasInitialized, true); + + model.deinitialize(); + let hasInitialized2 = false; + model.waitForInitialization().then(() => { + hasInitialized2 = true; + }); + + model.startInitialize(); + model.initialize(undefined); + await timeout(0); + assert.strictEqual(hasInitialized2, true); + }); + + test('cannot initialize twice', async () => { + const model = testDisposables.add(instantiationService.createInstance(ChatModel, undefined, ChatAgentLocation.Panel)); + + model.startInitialize(); + model.initialize(undefined); + assert.throws(() => model.initialize(undefined)); + }); + + test('Initialization fails when model is disposed', async () => { + const model = testDisposables.add(instantiationService.createInstance(ChatModel, undefined, ChatAgentLocation.Panel)); + model.dispose(); + + assert.throws(() => model.initialize(undefined)); + }); + + test('removeRequest', async () => { + const model = testDisposables.add(instantiationService.createInstance(ChatModel, undefined, ChatAgentLocation.Panel)); + + model.startInitialize(); + model.initialize(undefined); + const text = 'hello'; + model.addRequest({ text, parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, text.length, 1, text.length), text)] }, { variables: [] }, 0); + const requests = model.getRequests(); + assert.strictEqual(requests.length, 1); + + model.removeRequest(requests[0].id); + assert.strictEqual(model.getRequests().length, 0); + }); + + test('adoptRequest', async function () { + const model1 = testDisposables.add(instantiationService.createInstance(ChatModel, undefined, ChatAgentLocation.Editor)); + const model2 = testDisposables.add(instantiationService.createInstance(ChatModel, undefined, ChatAgentLocation.Panel)); + + model1.startInitialize(); + model1.initialize(undefined); + + model2.startInitialize(); + model2.initialize(undefined); + + const text = 'hello'; + const request1 = model1.addRequest({ text, parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, text.length, 1, text.length), text)] }, { variables: [] }, 0); + + assert.strictEqual(model1.getRequests().length, 1); + assert.strictEqual(model2.getRequests().length, 0); + assert.ok(request1.session === model1); + assert.ok(request1.response?.session === model1); + + model2.adoptRequest(request1); + + assert.strictEqual(model1.getRequests().length, 0); + assert.strictEqual(model2.getRequests().length, 1); + assert.ok(request1.session === model2); + assert.ok(request1.response?.session === model2); + + model2.acceptResponseProgress(request1, { content: new MarkdownString('Hello'), kind: 'markdownContent' }); + + assert.strictEqual(request1.response.response.toString(), 'Hello'); + }); +}); + +suite('Response', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('mergeable markdown', async () => { + const response = store.add(new Response([])); + response.updateContent({ content: new MarkdownString('markdown1'), kind: 'markdownContent' }); + response.updateContent({ content: new MarkdownString('markdown2'), kind: 'markdownContent' }); + await assertSnapshot(response.value); + + assert.strictEqual(response.toString(), 'markdown1markdown2'); + }); + + test('not mergeable markdown', async () => { + const response = store.add(new Response([])); + const md1 = new MarkdownString('markdown1'); + md1.supportHtml = true; + response.updateContent({ content: md1, kind: 'markdownContent' }); + response.updateContent({ content: new MarkdownString('markdown2'), kind: 'markdownContent' }); + await assertSnapshot(response.value); + }); + + test('inline reference', async () => { + const response = store.add(new Response([])); + response.updateContent({ content: new MarkdownString('text before'), kind: 'markdownContent' }); + response.updateContent({ inlineReference: URI.parse('https://microsoft.com'), kind: 'inlineReference' }); + response.updateContent({ content: new MarkdownString('text after'), kind: 'markdownContent' }); + await assertSnapshot(response.value); + }); +}); + +suite('normalizeSerializableChatData', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('v1', () => { + const v1Data: ISerializableChatData1 = { + creationDate: Date.now(), + initialLocation: undefined, + isImported: false, + requesterAvatarIconUri: undefined, + requesterUsername: 'me', + requests: [], + responderAvatarIconUri: undefined, + responderUsername: 'bot', + sessionId: 'session1', + }; + + const newData = normalizeSerializableChatData(v1Data); + assert.strictEqual(newData.creationDate, v1Data.creationDate); + assert.strictEqual(newData.lastMessageDate, v1Data.creationDate); + assert.strictEqual(newData.version, 3); + assert.ok('customTitle' in newData); + }); + + test('v2', () => { + const v2Data: ISerializableChatData2 = { + version: 2, + creationDate: 100, + lastMessageDate: Date.now(), + initialLocation: undefined, + isImported: false, + requesterAvatarIconUri: undefined, + requesterUsername: 'me', + requests: [], + responderAvatarIconUri: undefined, + responderUsername: 'bot', + sessionId: 'session1', + computedTitle: 'computed title' + }; + + const newData = normalizeSerializableChatData(v2Data); + assert.strictEqual(newData.version, 3); + assert.strictEqual(newData.creationDate, v2Data.creationDate); + assert.strictEqual(newData.lastMessageDate, v2Data.lastMessageDate); + assert.strictEqual(newData.customTitle, v2Data.computedTitle); + }); + + test('old bad data', () => { + const v1Data: ISerializableChatData1 = { + // Testing the scenario where these are missing + sessionId: undefined!, + creationDate: undefined!, + + initialLocation: undefined, + isImported: false, + requesterAvatarIconUri: undefined, + requesterUsername: 'me', + requests: [], + responderAvatarIconUri: undefined, + responderUsername: 'bot', + }; + + const newData = normalizeSerializableChatData(v1Data); + assert.strictEqual(newData.version, 3); + assert.ok(newData.creationDate > 0); + assert.ok(newData.lastMessageDate > 0); + assert.ok(newData.sessionId); + }); + + test('v3 with bug', () => { + const v3Data: ISerializableChatData3 = { + // Test case where old data was wrongly normalized and these fields were missing + creationDate: undefined!, + lastMessageDate: undefined!, + + version: 3, + initialLocation: undefined, + isImported: false, + requesterAvatarIconUri: undefined, + requesterUsername: 'me', + requests: [], + responderAvatarIconUri: undefined, + responderUsername: 'bot', + sessionId: 'session1', + customTitle: 'computed title' + }; + + const newData = normalizeSerializableChatData(v3Data); + assert.strictEqual(newData.version, 3); + assert.ok(newData.creationDate > 0); + assert.ok(newData.lastMessageDate > 0); + assert.ok(newData.sessionId); }); }); From 88ac766f53428368d5f4c59eed5e9e58b811e205 Mon Sep 17 00:00:00 2001 From: Andrew Pareles <43356051+andrewpareles@users.noreply.github.com> Date: Mon, 24 Feb 2025 18:51:56 -0800 Subject: [PATCH 14/18] Update CONTRIBUTING.md --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 64129df3..2b4c1d00 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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/`. From 0b6dd721e9241a515d6b1a0b1d4c080ac7326e49 Mon Sep 17 00:00:00 2001 From: Andrew Pareles <43356051+andrewpareles@users.noreply.github.com> Date: Mon, 24 Feb 2025 18:53:18 -0800 Subject: [PATCH 15/18] Update CONTRIBUTING.md --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2b4c1d00..933af838 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Contributing to Void +# Contributing to Void. ### Welcome! 👋 This is the official guide on how to contribute to Void. We want to make it as easy as possible to contribute, so if you have any questions or comments, reach out via email or discord! From 57c9184f4e9111d085c2fb43fb422659d23ac07b Mon Sep 17 00:00:00 2001 From: Andrew Pareles <43356051+andrewpareles@users.noreply.github.com> Date: Mon, 24 Feb 2025 18:53:42 -0800 Subject: [PATCH 16/18] Update CONTRIBUTING.md --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 933af838..2b4c1d00 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Contributing to Void. +# Contributing to Void ### Welcome! 👋 This is the official guide on how to contribute to Void. We want to make it as easy as possible to contribute, so if you have any questions or comments, reach out via email or discord! From 63b15af3850c62f029736c358cff8b2f2652256d Mon Sep 17 00:00:00 2001 From: Andrew Pareles Date: Mon, 24 Feb 2025 22:37:54 -0800 Subject: [PATCH 17/18] add claude 3.7 init --- .../contrib/void/common/voidSettingsTypes.ts | 3 ++- .../void/electron-main/llmMessage/MODELS.ts | 19 +++++++++++++---- .../llmMessage/preprocessLLMMessages.ts | 21 +++++++++++++++---- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts b/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts index 379a4817..29963ec9 100644 --- a/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts +++ b/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts @@ -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', ], diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/MODELS.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/MODELS.ts index a4ad5487..00cb8831 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/MODELS.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/MODELS.ts @@ -146,10 +146,19 @@ 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, output: 15.00 }, + supportsFIM: false, + supportsSystemMessage: 'separated', + supportsTools: 'anthropic-style', + supportsReasoningOutput: {}, // TODO!!!! + }, 'claude-3-5-sonnet-20241022': { contextWindow: 200_000, maxOutputTokens: 8_192, - cost: { input: 3.00, cache_read: 0.30, cache_write: 3.75, output: 15.00 }, + cost: { input: 3.00, output: 15.00 }, supportsFIM: false, supportsSystemMessage: 'separated', supportsTools: 'anthropic-style', @@ -158,7 +167,7 @@ const anthropicModelOptions = { 'claude-3-5-haiku-20241022': { contextWindow: 200_000, maxOutputTokens: 8_192, - cost: { input: 0.80, cache_read: 0.08, cache_write: 1.00, output: 4.00 }, + cost: { input: 0.80, output: 4.00 }, supportsFIM: false, supportsSystemMessage: 'separated', supportsTools: 'anthropic-style', @@ -167,15 +176,16 @@ const anthropicModelOptions = { 'claude-3-opus-20240229': { contextWindow: 200_000, maxOutputTokens: 4_096, - cost: { input: 15.00, cache_read: 1.50, cache_write: 18.75, output: 75.00 }, + cost: { input: 15.00, output: 75.00 }, supportsFIM: false, supportsSystemMessage: 'separated', supportsTools: 'anthropic-style', supportsReasoningOutput: false, }, 'claude-3-sonnet-20240229': { // no point of using this, but including this for people who put it in - contextWindow: 200_000, cost: { input: 3.00, output: 15.00 }, + contextWindow: 200_000, maxOutputTokens: 4_096, + cost: { input: 3.00, output: 15.00 }, supportsFIM: false, supportsSystemMessage: 'separated', supportsTools: 'anthropic-style', @@ -187,6 +197,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' diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/preprocessLLMMessages.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/preprocessLLMMessages.ts index 32b91d07..755729ce 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/preprocessLLMMessages.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/preprocessLLMMessages.ts @@ -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 { From 8438f808348edddd35409c3b8885543cdd91449f Mon Sep 17 00:00:00 2001 From: Andrew Pareles Date: Tue, 25 Feb 2025 15:22:06 -0800 Subject: [PATCH 18/18] updates --- package-lock.json | 9 +++++---- package.json | 2 +- src/vs/workbench/contrib/void/common/toolsService.ts | 4 +++- .../contrib/void/electron-main/llmMessage/MODELS.ts | 11 +++++------ 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 420ade10..79712d4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -223,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", diff --git a/package.json b/package.json index 9c6a4e88..c47e7207 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/vs/workbench/contrib/void/common/toolsService.ts b/src/vs/workbench/contrib/void/common/toolsService.ts index f27739c0..1872f08f 100644 --- a/src/vs/workbench/contrib/void/common/toolsService.ts +++ b/src/vs/workbench/contrib/void/common/toolsService.ts @@ -68,7 +68,9 @@ export const voidTools = { required: ['query'], }, - // go_to_definition: + // go_to_definition: { + + // }, // go_to_usages: diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/MODELS.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/MODELS.ts index 00cb8831..5c4b3434 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/MODELS.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/MODELS.ts @@ -149,7 +149,7 @@ 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, output: 15.00 }, + cost: { input: 3.00, cache_read: 0.30, cache_write: 3.75, output: 15.00 }, supportsFIM: false, supportsSystemMessage: 'separated', supportsTools: 'anthropic-style', @@ -158,7 +158,7 @@ const anthropicModelOptions = { 'claude-3-5-sonnet-20241022': { contextWindow: 200_000, maxOutputTokens: 8_192, - cost: { input: 3.00, output: 15.00 }, + cost: { input: 3.00, cache_read: 0.30, cache_write: 3.75, output: 15.00 }, supportsFIM: false, supportsSystemMessage: 'separated', supportsTools: 'anthropic-style', @@ -167,7 +167,7 @@ const anthropicModelOptions = { 'claude-3-5-haiku-20241022': { contextWindow: 200_000, maxOutputTokens: 8_192, - cost: { input: 0.80, output: 4.00 }, + cost: { input: 0.80, cache_read: 0.08, cache_write: 1.00, output: 4.00 }, supportsFIM: false, supportsSystemMessage: 'separated', supportsTools: 'anthropic-style', @@ -176,16 +176,15 @@ const anthropicModelOptions = { 'claude-3-opus-20240229': { contextWindow: 200_000, maxOutputTokens: 4_096, - cost: { input: 15.00, output: 75.00 }, + cost: { input: 15.00, cache_read: 1.50, cache_write: 18.75, output: 75.00 }, supportsFIM: false, supportsSystemMessage: 'separated', supportsTools: 'anthropic-style', supportsReasoningOutput: false, }, 'claude-3-sonnet-20240229': { // no point of using this, but including this for people who put it in - contextWindow: 200_000, + contextWindow: 200_000, cost: { input: 3.00, output: 15.00 }, maxOutputTokens: 4_096, - cost: { input: 3.00, output: 15.00 }, supportsFIM: false, supportsSystemMessage: 'separated', supportsTools: 'anthropic-style',