Compare commits

...

50 Commits

Author SHA1 Message Date
Mathew Pareles 93d40be1ac Merge remote-tracking branch 'origin/main' into model-selection 2025-03-01 17:43:18 -08:00
Mathew Pareles cfc39b83bb styles 2025-03-01 17:42:17 -08:00
Mathew Pareles 9c76a35837 outline 2025-03-01 17:42:17 -08:00
Mathew Pareles 4a00bd3e00 Cmd+L selections open better ux 2025-03-01 17:42:17 -08:00
Mathew Pareles 6d6e2fa974 fix annoying refactor 2025-03-01 17:42:17 -08:00
Mathew Pareles 971ce88df2 style 2025-03-01 17:40:29 -08:00
Andrew Pareles 7e34037ffe Merge pull request #288 from eswaldots/main
Fix encryption service not working on Linux
2025-02-28 14:11:52 -08:00
Andrew Pareles aa835d468b misc 2025-02-27 20:13:50 -08:00
Mathew Pareles 661eba3ae9 fix selection state bug 2025-02-27 17:08:49 -08:00
Mathew Pareles de17b3add1 styles 2025-02-27 16:27:34 -08:00
Mathew Pareles 6fa3901ab0 small style 2025-02-27 16:22:13 -08:00
Andrew Pareles d14a2f1fff marketplace URL 2025-02-26 02:22:58 -08:00
Andrew Pareles c1f3b92c51 fix manual reasoning parsing 2025-02-26 02:14:00 -08:00
Andrew Pareles 0de63a088d reasoning UI! 2025-02-26 02:01:04 -08:00
Andrew Pareles 0de1c20551 progress 2025-02-26 02:01:03 -08:00
Mathew Pareles a769eb4db0 Merge branch 'recovered-branch' into model-selection 2025-02-25 19:09:28 -08:00
Andrew Pareles 8438f80834 updates 2025-02-25 15:23:34 -08:00
Andrew Pareles 63b15af385 add claude 3.7 init 2025-02-25 15:23:34 -08:00
Andrew Pareles 57c9184f4e Update CONTRIBUTING.md 2025-02-24 18:53:42 -08:00
Andrew Pareles 0b6dd721e9 Update CONTRIBUTING.md 2025-02-24 18:53:18 -08:00
Andrew Pareles 88ac766f53 Update CONTRIBUTING.md 2025-02-24 18:51:56 -08:00
Andrew Pareles 58466afe72 Merge pull request #287 from jhacksman/devin/1739626558-fix-think-tag-apply
Fix Apply failure with <think> tags (#283)
2025-02-24 14:58:10 -08:00
Andrew Pareles 04efe1d235 added extractReasoningFromText in extractCodeFromResult instead 2025-02-24 14:57:50 -08:00
Andrew Pareles aa9a102b71 Merge remote-tracking branch 'origin/main' into pr/jhacksman/287 2025-02-24 14:55:01 -08:00
Andrew Pareles dc0f9bdada Merge pull request #282 from bjoaquinc/remote-ssh-extension
Added remote-ssh extension to /extensions
2025-02-24 14:52:40 -08:00
Andrew Pareles 75f30bf7cd Merge pull request #294 from voideditor/model-selection
Provider Settings + UX improvements
2025-02-24 06:24:08 -08:00
Andrew Pareles 4a43b628d4 improvements 2025-02-24 06:23:24 -08:00
Andrew Pareles 5c047b78f3 style+FIM prompt 2025-02-24 05:33:45 -08:00
Andrew Pareles 5cbd0a19a4 minor fixes 2025-02-24 04:38:08 -08:00
Andrew Pareles d2fb0fb4ff maxTokens for anthropic 2025-02-24 04:32:53 -08:00
Andrew Pareles 3ae8f75641 remove mistral, finish(?) models! 2025-02-24 04:01:00 -08:00
Andrew Pareles 7206209743 Merge pull request #290 from voideditor/model-selection
Tool use progress
2025-02-19 00:03:01 -08:00
Joaquin Coromina 2c61310077 Merge branch 'voideditor:main' into remote-ssh-extension 2025-02-16 23:32:00 +07:00
Joaquin Coromina a519100852 Added open-remote-ssh to npm install dirs and the output files in eslint ignore 2025-02-16 23:31:21 +07:00
eswaldots a38fc81a58 Fix encryption service dont working on Linux 2025-02-16 15:34:53 +00:00
Devin AI 4c5e444bcb Improve token streaming support with character-by-character matching
Co-Authored-By: Jack Hacksman <slack@hannis.io>
2025-02-16 05:14:40 +00:00
Devin AI 2f55580e67 Improve token streaming support with character-by-character matching
Co-Authored-By: Jack Hacksman <slack@hannis.io>
2025-02-16 05:06:37 +00:00
Devin AI b24cd1ecb7 Improve token streaming support for think tags
Co-Authored-By: Jack Hacksman <slack@hannis.io>
2025-02-16 05:01:57 +00:00
Devin AI a519bd7690 Add streaming support for think tags and test cases
Co-Authored-By: Jack Hacksman <slack@hannis.io>
2025-02-16 04:59:55 +00:00
Andrew Pareles a26816d597 Update src/vs/workbench/contrib/chat/common/chatModel.ts
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-02-15 15:46:39 -08:00
Andrew Pareles ea6e025511 Merge pull request #284 from adrieljss/main
fix ENOENT error spawn on Windows
2025-02-15 15:37:39 -08:00
Devin AI 7849b0a107 Fix Apply failure with <think> tags (#283)
- Add utility to strip <think> 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 <slack@hannis.io>
2025-02-15 13:35:58 +00:00
adrieljss 1839acab1f fix ENOENT error spawn on Windows 2025-02-14 17:59:56 +08:00
Joaquin Coromina 4950de2cb5 Merge branch 'voideditor:main' into remote-ssh-extension 2025-02-14 10:06:42 +07:00
Joaquin Coromina 28bf838b55 Updated void-server endpoiont to make it dynamic 2025-02-14 10:05:30 +07:00
Andrew Pareles 7fbe8d905f Merge pull request #281 from voideditor/model-selection
Latest streaming changes
2025-02-12 18:51:55 -08:00
Joaquin Coromina 79fb8f63a7 Added Open Remote-SSH extension to Void 2025-02-13 01:33:43 +07:00
Andrew Pareles a3e6deb1a8 Merge pull request #278 from bjoaquinc/build-void-server
fix(build): fixes void-reh (remote host) build crashes and updates the remote-host output path to say void rather than vscode
2025-02-11 16:43:41 -08:00
Joaquin Coromina 6f6f43c46e Fixed compilation for void-reh bug by updating package.json in remote and changed destination folder to say void 2025-02-10 19:54:50 +07:00
Andrew Pareles 5fa21be9d2 Merge pull request #273 from voideditor/model-selection
UI improvements
2025-02-06 02:05:18 -08:00
58 changed files with 4502 additions and 758 deletions
+1
View File
@@ -11,6 +11,7 @@
**/extensions/markdown-language-features/notebook-out/**
**/extensions/markdown-math/notebook-out/**
**/extensions/notebook-renderers/renderer-out/index.js
**/extensions/open-remote-ssh/out/extension.js
**/extensions/simple-browser/media/index.js
**/extensions/typescript-language-features/test-workspace/**
**/extensions/typescript-language-features/extension.webpack.config.js
+1 -1
View File
@@ -18,7 +18,7 @@ We highly recommend reading [this](https://github.com/microsoft/vscode/wiki/Sour
We wrote a [guide to working in VSCode].
-->
Most of Void's code lives in the two folders called `void/`.
Most of Void's code lives in the folder `src/vs/workbench/contrib/void/`.
+1
View File
@@ -134,6 +134,7 @@ module.exports.indentationFilter = [
'!extensions/markdown-math/notebook-out/*.js',
'!extensions/ipynb/notebook-out/**',
'!extensions/notebook-renderers/renderer-out/*.js',
'!extensions/open-remote-ssh/out/*.js',
'!extensions/simple-browser/media/*.js',
];
+1
View File
@@ -55,6 +55,7 @@ const compilations = [
'extensions/microsoft-authentication/tsconfig.json',
'extensions/notebook-renderers/tsconfig.json',
'extensions/npm/tsconfig.json',
'extensions/open-remote-ssh/tsconfig.json',
'extensions/php-language-features/tsconfig.json',
'extensions/references-view/tsconfig.json',
'extensions/search-result/tsconfig.json',
+1 -1
View File
@@ -516,7 +516,7 @@ function tweakProductForServerWeb(product) {
['', 'min'].forEach(minified => {
const sourceFolderName = `out-vscode-${type}${dashed(minified)}`;
const destinationFolderName = `vscode-${type}${dashed(platform)}${dashed(arch)}`;
const destinationFolderName = `void-${type}${dashed(platform)}${dashed(arch)}`;
const serverTaskCI = task.define(`vscode-${type}${dashed(platform)}${dashed(arch)}${dashed(minified)}-ci`, task.series(
gulp.task(`node-${platform}-${arch}`),
+1
View File
@@ -36,6 +36,7 @@ const dirs = [
'extensions/microsoft-authentication',
'extensions/notebook-renderers',
'extensions/npm',
'extensions/open-remote-ssh',
'extensions/php-language-features',
'extensions/references-view',
'extensions/search-result',
+74
View File
@@ -0,0 +1,74 @@
## 0.0.48
- Support `%n` in ProxyCommand
- fix: add missing direct @types/ssh2-stream dependency (#177)
- fix Win32 internal error (#178)
## 0.0.47
- Add support for loong64 (#175)
- Add s390x support (#174)
- Support vscodium alpine reh (#142)
## 0.0.46
- Add riscv64 support (#147)
## 0.0.45
- Use windows-x64 server on windows-arm64
## 0.0.44
- Update ssh2 lib
- Properly set extensionHost env variables
## 0.0.43
- Fix parsing multiple include directives
## 0.0.42
- Fix remote label to show port when connecting to a port other than 22
## 0.0.41
- Take into account parsed port from ssh destination. Fixes (#110)
## 0.0.40
- Update ssh-config package
## 0.0.39
- output error messages when downloading vscode server (#39)
- Add PreferredAuthentications support (#97)
## 0.0.38
- Enable remote support for ppc64le (#93)
## 0.0.37
- Default to Current OS User in Connection String if No User Provided (#91)
- Add support for (unofficial) DragonFly reh (#86)
## 0.0.36
- Make wget support continue download (#85)
## 0.0.35
- Fixes hardcoded agentsock for windows breaks pageant compatibility (#81)
## 0.0.34
- Add remote.SSH.connectTimeout setting
- adding %r username replacement to proxycommand (#77)
## 0.0.33
- feat: support %r user substitution in proxycommand
## 0.0.32
- feat: use serverDownloadUrlTemplate from product.json (#59)
## 0.0.31
- feat: support glob patterns in SSH include directives
## 0.0.30
- feat: support file patterns in SSH include directives
+48
View File
@@ -0,0 +1,48 @@
# Open Remote - SSH
## SSH Host Requirements
You can connect to a running SSH server on the following platforms.
**Supported**:
- x86_64 Debian 8+, Ubuntu 16.04+, CentOS / RHEL 7+ Linux.
- ARMv7l (AArch32) Raspbian Stretch/9+ (32-bit).
- ARMv8l (AArch64) Ubuntu 18.04+ (64-bit).
- macOS 10.14+ (Mojave)
- Windows 10+
- FreeBSD 13 (Requires manual remote-extension-host installation)
- DragonFlyBSD (Requires manual remote-extension-host installation)
## Requirements
**Activation**
Enable the extension in your `argv.json`
```json
{
...
"enable-proposed-api": [
...,
"jeanp413.open-remote-ssh",
]
...
}
```
which you can open by running the `Preferences: Configure Runtime Arguments` command.
The file is located in `~/.vscode-oss/argv.json`.
**Alpine linux**
When running on alpine linux, the packages `libstdc++` and `bash` are necessary and can be installed via
running
```bash
sudo apk add bash libstdc++
```
## SSH configuration file
[OpenSSH](https://www.openssh.com/) supports using a [configuration file](https://linuxize.com/post/using-the-ssh-config-file/) to store all your different SSH connections. To use an SSH config file, run the `Remote-SSH: Open SSH Configuration File...` command.
@@ -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'
}
});
@@ -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/,
})
]
});
+370
View File
@@ -0,0 +1,370 @@
{
"name": "open-remote-ssh",
"version": "0.0.48",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-remote-ssh",
"version": "0.0.48",
"dependencies": {
"@jeanp413/ssh-config": "^4.3.1",
"glob": "^9.3.1",
"simple-socks": "git+https://github.com/jeanp413/simple-socks#main",
"socks": "^2.5.0",
"ssh2": "git+https://github.com/jeanp413/ssh2#master"
},
"devDependencies": {
"@types/ssh2": "^0.5.52",
"@types/ssh2-streams": "0.1.12"
},
"engines": {
"vscode": "^1.70.2"
}
},
"node_modules/@babel/runtime-corejs3": {
"version": "7.26.0",
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.26.0.tgz",
"integrity": "sha512-YXHu5lN8kJCb1LOb9PgV6pvak43X2h4HvRApcN5SdWeaItQOzfn1hgP6jasD6KWQyJDBxrVmA9o9OivlnNJK/w==",
"license": "MIT",
"dependencies": {
"core-js-pure": "^3.30.2",
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@jeanp413/ssh-config": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@jeanp413/ssh-config/-/ssh-config-4.3.1.tgz",
"integrity": "sha512-x0EaWRdjs5sPDNmYr11wVB1GdwWQgRekc7SbueuO5FK7YZUav98qZKtZZU5iSDKyxJkooCs3rgVizB1wIWrF7g==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.10.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz",
"integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
}
},
"node_modules/@types/ssh2": {
"version": "0.5.52",
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.52.tgz",
"integrity": "sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/ssh2-streams": "*"
}
},
"node_modules/@types/ssh2-streams": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@types/ssh2-streams/-/ssh2-streams-0.1.12.tgz",
"integrity": "sha512-Sy8tpEmCce4Tq0oSOYdfqaBpA3hDM8SoxoFh5vzFsu2oL+znzGz8oVWW7xb4K920yYMUY+PIG31qZnFMfPWNCg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"license": "BSD-3-Clause",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/binary": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz",
"integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==",
"license": "MIT",
"dependencies": {
"buffers": "~0.1.1",
"chainsaw": "~0.1.0"
},
"engines": {
"node": "*"
}
},
"node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/buffers": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz",
"integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==",
"engines": {
"node": ">=0.2.0"
}
},
"node_modules/buildcheck": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz",
"integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==",
"optional": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/chainsaw": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz",
"integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==",
"license": "MIT/X11",
"dependencies": {
"traverse": ">=0.3.0 <0.4"
},
"engines": {
"node": "*"
}
},
"node_modules/core-js-pure": {
"version": "3.40.0",
"resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.40.0.tgz",
"integrity": "sha512-AtDzVIgRrmRKQai62yuSIN5vNiQjcJakJb4fbhVw3ehxx7Lohphvw9SGNWKhLFqSxC4ilD0g/L1huAYFQU3Q6A==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cpu-features": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"buildcheck": "~0.0.6",
"nan": "^2.19.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC"
},
"node_modules/glob": {
"version": "9.3.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz",
"integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"minimatch": "^8.0.2",
"minipass": "^4.2.4",
"path-scurry": "^1.6.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/ip-address": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz",
"integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==",
"license": "MIT",
"dependencies": {
"jsbn": "1.1.0",
"sprintf-js": "^1.1.3"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/jsbn": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
"integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.4.tgz",
"integrity": "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "4.2.8",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz",
"integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==",
"license": "ISC",
"engines": {
"node": ">=8"
}
},
"node_modules/nan": {
"version": "2.22.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.22.0.tgz",
"integrity": "sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==",
"license": "MIT",
"optional": true
},
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-scurry/node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/simple-socks": {
"version": "2.2.2",
"resolved": "git+ssh://git@github.com/jeanp413/simple-socks.git#2ac739301a82d6baff04804ed494436a026acb60",
"license": "MIT",
"dependencies": {
"@babel/runtime-corejs3": "^7.16.8",
"binary": "^0.3.0"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz",
"integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==",
"license": "MIT",
"dependencies": {
"ip-address": "^9.0.5",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/sprintf-js": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
"license": "BSD-3-Clause"
},
"node_modules/ssh2": {
"version": "1.14.0",
"resolved": "git+ssh://git@github.com/jeanp413/ssh2.git#a169f627213aa663e0aa2fd2f0ef5c8931890c26",
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.9",
"nan": "^2.17.0"
}
},
"node_modules/traverse": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz",
"integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==",
"license": "MIT/X11",
"engines": {
"node": "*"
}
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense"
},
"node_modules/undici-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"dev": true,
"license": "MIT"
}
}
}
+351
View File
@@ -0,0 +1,351 @@
{
"name": "open-remote-ssh",
"displayName": "Open Remote - SSH",
"publisher": "voideditor",
"description": "Use any remote machine with a SSH server as your development environment.",
"version": "0.0.48",
"icon": "resources/icon.png",
"engines": {
"vscode": "^1.70.2"
},
"extensionKind": [
"ui"
],
"enabledApiProposals": [
"resolvers",
"contribViewsRemote"
],
"keywords": [
"remote development",
"remote",
"ssh"
],
"api": "none",
"activationEvents": [
"onCommand:openremotessh.openEmptyWindow",
"onCommand:openremotessh.openEmptyWindowInCurrentWindow",
"onCommand:openremotessh.openConfigFile",
"onCommand:openremotessh.showLog",
"onResolveRemoteAuthority:ssh-remote",
"onView:sshHosts"
],
"main": "./out/extension.js",
"contributes": {
"configuration": {
"title": "Remote - SSH",
"properties": {
"remote.SSH.configFile": {
"type": "string",
"description": "The absolute file path to a custom SSH config file.",
"default": "",
"scope": "application"
},
"remote.SSH.connectTimeout": {
"type": "number",
"description": "Specifies the timeout in seconds used for the SSH command that connects to the remote.",
"default": 60,
"scope": "application",
"minimum": 1
},
"remote.SSH.defaultExtensions": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of extensions that should be installed automatically on all SSH hosts.",
"scope": "application"
},
"remote.SSH.enableDynamicForwarding": {
"type": "boolean",
"description": "Whether to use SSH dynamic forwarding to allow setting up new port tunnels over an existing SSH connection.",
"scope": "application",
"default": true
},
"remote.SSH.enableAgentForwarding": {
"type": "boolean",
"markdownDescription": "Enable fixing the remote environment so that the SSH config option `ForwardAgent` will take effect as expected from VS Code's remote extension host.",
"scope": "application",
"default": true
},
"remote.SSH.serverDownloadUrlTemplate": {
"type": "string",
"description": "The URL from where the vscode server will be downloaded. You can use the following variables and they will be replaced dynamically:\n- ${quality}: vscode server quality, e.g. stable or insiders\n- ${version}: vscode server version, e.g. 1.69.0\n- ${commit}: vscode server release commit\n- ${arch}: vscode server arch, e.g. x64, armhf, arm64\n- ${release}: release number",
"scope": "application",
"default": "https://github.com/voideditor/${NAME_OF_REPO}/releases/download/${version}.${release}/void-server-${os}-${arch}-${version}.${release}.tar.gz"
},
"remote.SSH.remotePlatform": {
"type": "object",
"description": "A map of the remote hostname to the platform for that remote. Valid values: linux, macos, windows.",
"scope": "application",
"default": {},
"additionalProperties": {
"type": "string",
"enum": [
"linux",
"macos",
"windows"
]
}
},
"remote.SSH.remoteServerListenOnSocket": {
"type": "boolean",
"description": "When true, the remote vscode server will listen on a socket path instead of opening a port. Only valid for Linux and macOS remotes. Requires `AllowStreamLocalForwarding` to be enabled for the SSH server.",
"default": false
},
"remote.SSH.experimental.serverBinaryName": {
"type": "string",
"description": "**Experimental:** The name of the server binary, use this **only if** you are using a client without a corresponding server release",
"scope": "application",
"default": ""
}
}
},
"views": {
"remote": [
{
"id": "sshHosts",
"name": "SSH Targets",
"group": "targets@1",
"remoteName": "ssh-remote"
}
]
},
"commands": [
{
"command": "openremotessh.openEmptyWindow",
"title": "Connect to Host...",
"category": "Remote-SSH"
},
{
"command": "openremotessh.openEmptyWindowInCurrentWindow",
"title": "Connect Current Window to Host...",
"category": "Remote-SSH"
},
{
"command": "openremotessh.openConfigFile",
"title": "Open SSH Configuration File...",
"category": "Remote-SSH"
},
{
"command": "openremotessh.showLog",
"title": "Show Log",
"category": "Remote-SSH"
},
{
"command": "openremotessh.explorer.emptyWindowInNewWindow",
"title": "Connect to Host in New Window",
"icon": "$(empty-window)"
},
{
"command": "openremotessh.explorer.emptyWindowInCurrentWindow",
"title": "Connect to Host in Current Window"
},
{
"command": "openremotessh.explorer.reopenFolderInCurrentWindow",
"title": "Open on SSH Host in Current Window"
},
{
"command": "openremotessh.explorer.reopenFolderInNewWindow",
"title": "Open on SSH Host in New Window",
"icon": "$(folder-opened)"
},
{
"command": "openremotessh.explorer.deleteFolderHistoryItem",
"title": "Remove From Recent List",
"icon": "$(x)"
},
{
"command": "openremotessh.explorer.refresh",
"title": "Refresh",
"icon": "$(refresh)"
},
{
"command": "openremotessh.explorer.configure",
"title": "Configure",
"icon": "$(gear)"
},
{
"command": "openremotessh.explorer.add",
"title": "Add New",
"icon": "$(plus)"
}
],
"resourceLabelFormatters": [
{
"scheme": "vscode-remote",
"authority": "ssh-remote+*",
"formatting": {
"label": "${path}",
"separator": "/",
"tildify": true,
"workspaceSuffix": "SSH"
}
}
],
"menus": {
"statusBar/remoteIndicator": [
{
"command": "openremotessh.openEmptyWindow",
"when": "remoteName =~ /^ssh-remote$/ && remoteConnectionState == connected",
"group": "remote_20_ssh_1general@1"
},
{
"command": "openremotessh.openEmptyWindowInCurrentWindow",
"when": "remoteName =~ /^ssh-remote$/ && remoteConnectionState == connected",
"group": "remote_20_ssh_1general@2"
},
{
"command": "openremotessh.openConfigFile",
"when": "remoteName =~ /^ssh-remote$/ && remoteConnectionState == connected",
"group": "remote_20_ssh_1general@3"
},
{
"command": "openremotessh.showLog",
"when": "remoteName =~ /^ssh-remote$/ && remoteConnectionState == connected",
"group": "remote_20_ssh_1general@4"
},
{
"command": "openremotessh.openEmptyWindow",
"when": "remoteConnectionState == disconnected",
"group": "remote_20_ssh_3local@1"
},
{
"command": "openremotessh.openEmptyWindowInCurrentWindow",
"when": "remoteConnectionState == disconnected",
"group": "remote_20_ssh_3local@2"
},
{
"command": "openremotessh.openConfigFile",
"when": "remoteConnectionState == disconnected",
"group": "remote_20_ssh_3local@3"
},
{
"command": "openremotessh.openEmptyWindow",
"when": "!remoteName && !virtualWorkspace",
"group": "remote_20_ssh_3local@5"
},
{
"command": "openremotessh.openEmptyWindowInCurrentWindow",
"when": "!remoteName && !virtualWorkspace",
"group": "remote_20_ssh_3local@6"
},
{
"command": "openremotessh.openConfigFile",
"when": "!remoteName && !virtualWorkspace",
"group": "remote_20_ssh_3local@7"
}
],
"commandPalette": [
{
"command": "openremotessh.explorer.refresh",
"when": "false"
},
{
"command": "openremotessh.explorer.configure",
"when": "false"
},
{
"command": "openremotessh.explorer.add",
"when": "false"
},
{
"command": "openremotessh.explorer.emptyWindowInNewWindow",
"when": "false"
},
{
"command": "openremotessh.explorer.emptyWindowInCurrentWindow",
"when": "false"
},
{
"command": "openremotessh.explorer.reopenFolderInCurrentWindow",
"when": "false"
},
{
"command": "openremotessh.explorer.reopenFolderInNewWindow",
"when": "false"
},
{
"command": "openremotessh.explorer.deleteFolderHistoryItem",
"when": "false"
}
],
"view/title": [
{
"command": "openremotessh.explorer.add",
"when": "view == sshHosts",
"group": "navigation"
},
{
"command": "openremotessh.explorer.configure",
"when": "view == sshHosts",
"group": "navigation"
},
{
"command": "openremotessh.explorer.refresh",
"when": "view == sshHosts",
"group": "navigation"
}
],
"view/item/context": [
{
"command": "openremotessh.explorer.emptyWindowInNewWindow",
"when": "viewItem =~ /^openremotessh.explorer.host$/",
"group": "inline@1"
},
{
"command": "openremotessh.explorer.emptyWindowInNewWindow",
"when": "viewItem =~ /^openremotessh.explorer.host$/",
"group": "navigation@2"
},
{
"command": "openremotessh.explorer.emptyWindowInCurrentWindow",
"when": "viewItem =~ /^openremotessh.explorer.host$/",
"group": "navigation@1"
},
{
"command": "openremotessh.explorer.reopenFolderInNewWindow",
"when": "viewItem == openremotessh.explorer.folder",
"group": "inline@1"
},
{
"command": "openremotessh.explorer.reopenFolderInNewWindow",
"when": "viewItem == openremotessh.explorer.folder",
"group": "navigation@2"
},
{
"command": "openremotessh.explorer.reopenFolderInCurrentWindow",
"when": "viewItem == openremotessh.explorer.folder",
"group": "navigation@1"
},
{
"command": "openremotessh.explorer.deleteFolderHistoryItem",
"when": "viewItem =~ /^openremotessh.explorer.folder/",
"group": "navigation@3"
},
{
"command": "openremotessh.explorer.deleteFolderHistoryItem",
"when": "viewItem =~ /^openremotessh.explorer.folder/",
"group": "inline@2"
}
]
}
},
"scripts": {
"vscode:prepublish": "npm run compile",
"compile": "gulp compile-extension:open-remote-ssh",
"compile-web": "npx webpack-cli --config extension-browser.webpack.config --mode none",
"watch": "gulp watch-extension:open-remote-ssh",
"watch-web": "npx webpack-cli --config extension-browser.webpack.config --mode none --watch --info-verbosity verbose"
},
"devDependencies": {
"@types/ssh2": "^0.5.52",
"@types/ssh2-streams": "0.1.12"
},
"dependencies": {
"glob": "^9.3.1",
"simple-socks": "git+https://github.com/jeanp413/simple-socks#main",
"socks": "^2.5.0",
"@jeanp413/ssh-config": "^4.3.1",
"ssh2": "git+https://github.com/jeanp413/ssh2#master"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,464 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as cp from 'child_process';
import * as fs from 'fs';
import * as net from 'net';
import * as stream from 'stream';
import { SocksClient, SocksClientOptions } from 'socks';
import * as vscode from 'vscode';
import * as ssh2 from 'ssh2';
import type { ParsedKey } from 'ssh2-streams';
import Log from './common/logger';
import SSHDestination from './ssh/sshDestination';
import SSHConnection, { SSHTunnelConfig } from './ssh/sshConnection';
import SSHConfiguration from './ssh/sshConfig';
import { gatherIdentityFiles } from './ssh/identityFiles';
import { untildify, exists as fileExists } from './common/files';
import { findRandomPort } from './common/ports';
import { disposeAll } from './common/disposable';
import { installCodeServer, ServerInstallError } from './serverSetup';
import { isWindows } from './common/platform';
import * as os from 'os';
const PASSWORD_RETRY_COUNT = 3;
const PASSPHRASE_RETRY_COUNT = 3;
export const REMOTE_SSH_AUTHORITY = 'ssh-remote';
export function getRemoteAuthority(host: string) {
return `${REMOTE_SSH_AUTHORITY}+${host}`;
}
class TunnelInfo implements vscode.Disposable {
constructor(
readonly localPort: number,
readonly remotePortOrSocketPath: number | string,
private disposables: vscode.Disposable[]
) {
}
dispose() {
disposeAll(this.disposables);
}
}
interface SSHKey {
filename: string;
parsedKey: ParsedKey;
fingerprint: string;
agentSupport?: boolean;
isPrivate?: boolean;
}
export class RemoteSSHResolver implements vscode.RemoteAuthorityResolver, vscode.Disposable {
private proxyConnections: SSHConnection[] = [];
private sshConnection: SSHConnection | undefined;
private sshAgentSock: string | undefined;
private proxyCommandProcess: cp.ChildProcessWithoutNullStreams | undefined;
private socksTunnel: SSHTunnelConfig | undefined;
private tunnels: TunnelInfo[] = [];
private labelFormatterDisposable: vscode.Disposable | undefined;
constructor(
readonly context: vscode.ExtensionContext,
readonly logger: Log
) {
}
resolve(authority: string, context: vscode.RemoteAuthorityResolverContext): Thenable<vscode.ResolverResult> {
const [type, dest] = authority.split('+');
if (type !== REMOTE_SSH_AUTHORITY) {
throw new Error(`Invalid authority type for SSH resolver: ${type}`);
}
this.logger.info(`Resolving ssh remote authority '${authority}' (attemp #${context.resolveAttempt})`);
const sshDest = SSHDestination.parseEncoded(dest);
// It looks like default values are not loaded yet when resolving a remote,
// so let's hardcode the default values here
const remoteSSHconfig = vscode.workspace.getConfiguration('remote.SSH');
const enableDynamicForwarding = remoteSSHconfig.get<boolean>('enableDynamicForwarding', true)!;
const enableAgentForwarding = remoteSSHconfig.get<boolean>('enableAgentForwarding', true)!;
const serverDownloadUrlTemplate = remoteSSHconfig.get<string>('serverDownloadUrlTemplate');
const defaultExtensions = remoteSSHconfig.get<string[]>('defaultExtensions', []);
const remotePlatformMap = remoteSSHconfig.get<Record<string, string>>('remotePlatform', {});
const remoteServerListenOnSocket = remoteSSHconfig.get<boolean>('remoteServerListenOnSocket', false)!;
const connectTimeout = remoteSSHconfig.get<number>('connectTimeout', 60)!;
return vscode.window.withProgress({
title: `Setting up SSH Host ${sshDest.hostname}`,
location: vscode.ProgressLocation.Notification,
cancellable: false
}, async () => {
try {
const sshconfig = await SSHConfiguration.loadFromFS();
const sshHostConfig = sshconfig.getHostConfiguration(sshDest.hostname);
const sshHostName = sshHostConfig['HostName'] ? sshHostConfig['HostName'].replace('%h', sshDest.hostname) : sshDest.hostname;
const sshUser = sshHostConfig['User'] || sshDest.user || os.userInfo().username || ''; // https://github.com/openssh/openssh-portable/blob/5ec5504f1d328d5bfa64280cd617c3efec4f78f3/sshconnect.c#L1561-L1562
const sshPort = sshHostConfig['Port'] ? parseInt(sshHostConfig['Port'], 10) : (sshDest.port || 22);
this.sshAgentSock = sshHostConfig['IdentityAgent'] || process.env['SSH_AUTH_SOCK'] || (isWindows ? '\\\\.\\pipe\\openssh-ssh-agent' : undefined);
this.sshAgentSock = this.sshAgentSock ? untildify(this.sshAgentSock) : undefined;
const agentForward = enableAgentForwarding && (sshHostConfig['ForwardAgent'] || 'no').toLowerCase() === 'yes';
const agent = agentForward && this.sshAgentSock ? new ssh2.OpenSSHAgent(this.sshAgentSock) : undefined;
const preferredAuthentications = sshHostConfig['PreferredAuthentications'] ? sshHostConfig['PreferredAuthentications'].split(',').map(s => s.trim()) : ['publickey', 'password', 'keyboard-interactive'];
const identityFiles: string[] = (sshHostConfig['IdentityFile'] as unknown as string[]) || [];
const identitiesOnly = (sshHostConfig['IdentitiesOnly'] || 'no').toLowerCase() === 'yes';
const identityKeys = await gatherIdentityFiles(identityFiles, this.sshAgentSock, identitiesOnly, this.logger);
// Create proxy jump connections if any
let proxyStream: ssh2.ClientChannel | stream.Duplex | undefined;
if (sshHostConfig['ProxyJump']) {
const proxyJumps = sshHostConfig['ProxyJump'].split(',').filter(i => !!i.trim())
.map(i => {
const proxy = SSHDestination.parse(i);
const proxyHostConfig = sshconfig.getHostConfiguration(proxy.hostname);
return [proxy, proxyHostConfig] as [SSHDestination, Record<string, string>];
});
for (let i = 0; i < proxyJumps.length; i++) {
const [proxy, proxyHostConfig] = proxyJumps[i];
const proxyHostName = proxyHostConfig['HostName'] || proxy.hostname;
const proxyUser = proxyHostConfig['User'] || proxy.user || sshUser;
const proxyPort = proxyHostConfig['Port'] ? parseInt(proxyHostConfig['Port'], 10) : (proxy.port || sshPort);
const proxyAgentForward = enableAgentForwarding && (proxyHostConfig['ForwardAgent'] || 'no').toLowerCase() === 'yes';
const proxyAgent = proxyAgentForward && this.sshAgentSock ? new ssh2.OpenSSHAgent(this.sshAgentSock) : undefined;
const proxyIdentityFiles: string[] = (proxyHostConfig['IdentityFile'] as unknown as string[]) || [];
const proxyIdentitiesOnly = (proxyHostConfig['IdentitiesOnly'] || 'no').toLowerCase() === 'yes';
const proxyIdentityKeys = await gatherIdentityFiles(proxyIdentityFiles, this.sshAgentSock, proxyIdentitiesOnly, this.logger);
const proxyAuthHandler = this.getSSHAuthHandler(proxyUser, proxyHostName, proxyIdentityKeys, preferredAuthentications);
const proxyConnection = new SSHConnection({
host: !proxyStream ? proxyHostName : undefined,
port: !proxyStream ? proxyPort : undefined,
sock: proxyStream,
username: proxyUser,
readyTimeout: connectTimeout * 1000,
strictVendor: false,
agentForward: proxyAgentForward,
agent: proxyAgent,
authHandler: (arg0, arg1, arg2) => (proxyAuthHandler(arg0, arg1, arg2), undefined)
});
this.proxyConnections.push(proxyConnection);
const nextProxyJump = i < proxyJumps.length - 1 ? proxyJumps[i + 1] : undefined;
const destIP = nextProxyJump ? (nextProxyJump[1]['HostName'] || nextProxyJump[0].hostname) : sshHostName;
const destPort = nextProxyJump ? ((nextProxyJump[1]['Port'] && parseInt(nextProxyJump[1]['Port'], 10)) || nextProxyJump[0].port || 22) : sshPort;
proxyStream = await proxyConnection.forwardOut('127.0.0.1', 0, destIP, destPort);
}
} else if (sshHostConfig['ProxyCommand']) {
let proxyArgs = (sshHostConfig['ProxyCommand'] as unknown as string[])
.map((arg) => arg.replace('%h', sshHostName).replace('%n', sshDest.hostname).replace('%p', sshPort.toString()).replace('%r', sshUser));
let proxyCommand = proxyArgs.shift()!;
let options = {};
if (isWindows && /\.(bat|cmd)$/.test(proxyCommand)) {
proxyCommand = `"${proxyCommand}"`;
proxyArgs = proxyArgs.map((arg) => arg.includes(' ') ? `"${arg}"` : arg);
options = { shell: true, windowsHide: true, windowsVerbatimArguments: true };
}
this.logger.trace(`Spawning ProxyCommand: ${proxyCommand} ${proxyArgs.join(' ')}`);
const child = cp.spawn(proxyCommand, proxyArgs, options);
proxyStream = stream.Duplex.from({ readable: child.stdout, writable: child.stdin });
this.proxyCommandProcess = child;
}
// Create final shh connection
const sshAuthHandler = this.getSSHAuthHandler(sshUser, sshHostName, identityKeys, preferredAuthentications);
this.sshConnection = new SSHConnection({
host: !proxyStream ? sshHostName : undefined,
port: !proxyStream ? sshPort : undefined,
sock: proxyStream,
username: sshUser,
readyTimeout: connectTimeout * 1000,
strictVendor: false,
agentForward,
agent,
authHandler: (arg0, arg1, arg2) => (sshAuthHandler(arg0, arg1, arg2), undefined),
});
await this.sshConnection.connect();
const envVariables: Record<string, string | null> = {};
if (agentForward) {
envVariables['SSH_AUTH_SOCK'] = null;
}
const installResult = await installCodeServer(this.sshConnection, serverDownloadUrlTemplate, defaultExtensions, Object.keys(envVariables), remotePlatformMap[sshDest.hostname], remoteServerListenOnSocket, this.logger);
for (const key of Object.keys(envVariables)) {
if (installResult[key] !== undefined) {
envVariables[key] = installResult[key];
}
}
// Update terminal env variables
this.context.environmentVariableCollection.persistent = false;
for (const [key, value] of Object.entries(envVariables)) {
if (value) {
this.context.environmentVariableCollection.replace(key, value);
}
}
if (enableDynamicForwarding) {
const socksPort = await findRandomPort();
this.socksTunnel = await this.sshConnection!.addTunnel({
name: `ssh_tunnel_socks_${socksPort}`,
localPort: socksPort,
socks: true
});
}
const tunnelConfig = await this.openTunnel(0, installResult.listeningOn);
this.tunnels.push(tunnelConfig);
// Enable ports view
vscode.commands.executeCommand('setContext', 'forwardedPortsViewEnabled', true);
this.labelFormatterDisposable?.dispose();
this.labelFormatterDisposable = vscode.workspace.registerResourceLabelFormatter({
scheme: 'vscode-remote',
authority: `${REMOTE_SSH_AUTHORITY}+*`,
formatting: {
label: '${path}',
separator: '/',
tildify: true,
workspaceSuffix: `SSH: ${sshDest.hostname}` + (sshDest.port && sshDest.port !== 22 ? `:${sshDest.port}` : '')
}
});
const resolvedResult: vscode.ResolverResult = new vscode.ResolvedAuthority('127.0.0.1', tunnelConfig.localPort, installResult.connectionToken);
resolvedResult.extensionHostEnv = envVariables;
return resolvedResult;
} catch (e: unknown) {
this.logger.error(`Error resolving authority`, e);
// Initial connection
if (context.resolveAttempt === 1) {
this.logger.show();
const closeRemote = 'Close Remote';
const retry = 'Retry';
const result = await vscode.window.showErrorMessage(`Could not establish connection to "${sshDest.hostname}"`, { modal: true }, closeRemote, retry);
if (result === closeRemote) {
await vscode.commands.executeCommand('workbench.action.remote.close');
} else if (result === retry) {
await vscode.commands.executeCommand('workbench.action.reloadWindow');
}
}
if (e instanceof ServerInstallError || !(e instanceof Error)) {
throw vscode.RemoteAuthorityResolverError.NotAvailable(e instanceof Error ? e.message : String(e));
} else {
throw vscode.RemoteAuthorityResolverError.TemporarilyNotAvailable(e.message);
}
}
});
}
private async openTunnel(localPort: number, remotePortOrSocketPath: number | string) {
localPort = localPort > 0 ? localPort : await findRandomPort();
const disposables: vscode.Disposable[] = [];
const remotePort = typeof remotePortOrSocketPath === 'number' ? remotePortOrSocketPath : undefined;
const remoteSocketPath = typeof remotePortOrSocketPath === 'string' ? remotePortOrSocketPath : undefined;
if (this.socksTunnel && remotePort) {
const forwardingServer = await new Promise<net.Server>((resolve, reject) => {
this.logger.trace(`Creating forwarding server ${localPort}(local) => ${this.socksTunnel!.localPort!}(socks) => ${remotePort}(remote)`);
const socksOptions: SocksClientOptions = {
proxy: {
host: '127.0.0.1',
port: this.socksTunnel!.localPort!,
type: 5
},
command: 'connect',
destination: {
host: '127.0.0.1',
port: remotePort
}
};
const server: net.Server = net.createServer()
.on('error', reject)
.on('connection', async (socket: net.Socket) => {
try {
const socksConn = await SocksClient.createConnection(socksOptions);
socket.pipe(socksConn.socket);
socksConn.socket.pipe(socket);
} catch (error) {
this.logger.error(`Error while creating SOCKS connection`, error);
}
})
.on('listening', () => resolve(server))
.listen(localPort);
});
disposables.push({
dispose: () => forwardingServer.close(() => {
this.logger.trace(`SOCKS forwading server closed`);
}),
});
} else {
this.logger.trace(`Opening tunnel ${localPort}(local) => ${remotePortOrSocketPath}(remote)`);
const tunnelConfig = await this.sshConnection!.addTunnel({
name: `ssh_tunnel_${localPort}_${remotePortOrSocketPath}`,
remoteAddr: '127.0.0.1',
remotePort,
remoteSocketPath,
localPort
});
disposables.push({
dispose: () => {
this.sshConnection?.closeTunnel(tunnelConfig.name);
this.logger.trace(`Tunnel ${tunnelConfig.name} closed`);
}
});
}
return new TunnelInfo(localPort, remotePortOrSocketPath, disposables);
}
private getSSHAuthHandler(sshUser: string, sshHostName: string, identityKeys: SSHKey[], preferredAuthentications: string[]) {
let passwordRetryCount = PASSWORD_RETRY_COUNT;
let keyboardRetryCount = PASSWORD_RETRY_COUNT;
identityKeys = identityKeys.slice();
return async (methodsLeft: string[] | null, _partialSuccess: boolean | null, callback: (nextAuth: ssh2.AuthHandlerResult) => void) => {
if (methodsLeft === null) {
this.logger.info(`Trying no-auth authentication`);
return callback({
type: 'none',
username: sshUser,
});
}
if (methodsLeft.includes('publickey') && identityKeys.length && preferredAuthentications.includes('publickey')) {
const identityKey = identityKeys.shift()!;
this.logger.info(`Trying publickey authentication: ${identityKey.filename} ${identityKey.parsedKey.type} SHA256:${identityKey.fingerprint}`);
if (identityKey.agentSupport) {
return callback({
type: 'agent',
username: sshUser,
agent: new class extends ssh2.OpenSSHAgent {
// Only return the current key
override getIdentities(callback: (err: Error | undefined, publicKeys?: ParsedKey[]) => void): void {
callback(undefined, [identityKey.parsedKey]);
}
}(this.sshAgentSock!)
});
}
if (identityKey.isPrivate) {
return callback({
type: 'publickey',
username: sshUser,
key: identityKey.parsedKey
});
}
if (!await fileExists(identityKey.filename)) {
// Try next identity file
return callback(null as any);
}
const keyBuffer = await fs.promises.readFile(identityKey.filename);
let result = ssh2.utils.parseKey(keyBuffer); // First try without passphrase
if (result instanceof Error && result.message === 'Encrypted private OpenSSH key detected, but no passphrase given') {
let passphraseRetryCount = PASSPHRASE_RETRY_COUNT;
while (result instanceof Error && passphraseRetryCount > 0) {
const passphrase = await vscode.window.showInputBox({
title: `Enter passphrase for ${identityKey.filename}`,
password: true,
ignoreFocusOut: true
});
if (!passphrase) {
break;
}
result = ssh2.utils.parseKey(keyBuffer, passphrase);
passphraseRetryCount--;
}
}
if (!result || result instanceof Error) {
// Try next identity file
return callback(null as any);
}
const key = Array.isArray(result) ? result[0] : result;
return callback({
type: 'publickey',
username: sshUser,
key
});
}
if (methodsLeft.includes('password') && passwordRetryCount > 0 && preferredAuthentications.includes('password')) {
if (passwordRetryCount === PASSWORD_RETRY_COUNT) {
this.logger.info(`Trying password authentication`);
}
const password = await vscode.window.showInputBox({
title: `Enter password for ${sshUser}@${sshHostName}`,
password: true,
ignoreFocusOut: true
});
passwordRetryCount--;
return callback(password
? {
type: 'password',
username: sshUser,
password
}
: false);
}
if (methodsLeft.includes('keyboard-interactive') && keyboardRetryCount > 0 && preferredAuthentications.includes('keyboard-interactive')) {
if (keyboardRetryCount === PASSWORD_RETRY_COUNT) {
this.logger.info(`Trying keyboard-interactive authentication`);
}
return callback({
type: 'keyboard-interactive',
username: sshUser,
prompt: async (_name, _instructions, _instructionsLang, prompts, finish) => {
const responses: string[] = [];
for (const prompt of prompts) {
const response = await vscode.window.showInputBox({
title: `(${sshUser}@${sshHostName}) ${prompt.prompt}`,
password: !prompt.echo,
ignoreFocusOut: true
});
if (response === undefined) {
keyboardRetryCount = 0;
break;
}
responses.push(response);
}
keyboardRetryCount--;
finish(responses);
}
});
}
callback(false);
};
}
dispose() {
disposeAll(this.tunnels);
// If there's proxy connections then just close the parent connection
if (this.proxyConnections.length) {
this.proxyConnections[0].close();
} else {
this.sshConnection?.close();
}
this.proxyCommandProcess?.kill();
this.labelFormatterDisposable?.dispose();
}
}
@@ -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));
}
@@ -0,0 +1,41 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
export function disposeAll(disposables: vscode.Disposable[]): void {
while (disposables.length) {
const item = disposables.pop();
if (item) {
item.dispose();
}
}
}
export abstract class Disposable {
private _isDisposed = false;
protected _disposables: vscode.Disposable[] = [];
public dispose(): any {
if (this._isDisposed) {
return;
}
this._isDisposed = true;
disposeAll(this._disposables);
}
protected _register<T extends vscode.Disposable>(value: T): T {
if (this._isDisposed) {
value.dispose();
} else {
this._disposables.push(value);
}
return value;
}
protected get isDisposed(): boolean {
return this._isDisposed;
}
}
@@ -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, '/');
}
@@ -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;
}
@@ -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';
@@ -0,0 +1,133 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as net from 'net';
/**
* Finds a random unused port assigned by the operating system. Will reject in case no free port can be found.
*/
export function findRandomPort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer({ pauseOnConnect: true });
server.on('error', reject);
server.on('listening', () => {
const port = (server.address() as net.AddressInfo).port;
server.close(() => resolve(port));
});
server.listen(0, '127.0.0.1');
});
}
/**
* Given a start point and a max number of retries, will find a port that
* is openable. Will return 0 in case no free port can be found.
*/
export function findFreePort(startPort: number, giveUpAfter: number, timeout: number, stride = 1): Promise<number> {
let done = false;
return new Promise(resolve => {
const timeoutHandle = setTimeout(() => {
if (!done) {
done = true;
return resolve(0);
}
}, timeout);
doFindFreePort(startPort, giveUpAfter, stride, (port) => {
if (!done) {
done = true;
clearTimeout(timeoutHandle);
return resolve(port);
}
});
});
}
function doFindFreePort(startPort: number, giveUpAfter: number, stride: number, clb: (port: number) => void): void {
if (giveUpAfter === 0) {
return clb(0);
}
const client = new net.Socket();
// If we can connect to the port it means the port is already taken so we continue searching
client.once('connect', () => {
dispose(client);
return doFindFreePort(startPort + stride, giveUpAfter - 1, stride, clb);
});
client.once('data', () => {
// this listener is required since node.js 8.x
});
client.once('error', (err: Error & { code?: string }) => {
dispose(client);
// If we receive any non ECONNREFUSED error, it means the port is used but we cannot connect
if (err.code !== 'ECONNREFUSED') {
return doFindFreePort(startPort + stride, giveUpAfter - 1, stride, clb);
}
// Otherwise it means the port is free to use!
return clb(startPort);
});
client.connect(startPort, '127.0.0.1');
}
/**
* Uses listen instead of connect. Is faster, but if there is another listener on 0.0.0.0 then this will take 127.0.0.1 from that listener.
*/
export function findFreePortFaster(startPort: number, giveUpAfter: number, timeout: number): Promise<number> {
let resolved = false;
let timeoutHandle: NodeJS.Timeout | undefined = undefined;
let countTried = 1;
const server = net.createServer({ pauseOnConnect: true });
function doResolve(port: number, resolve: (port: number) => void) {
if (!resolved) {
resolved = true;
server.removeAllListeners();
server.close();
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
resolve(port);
}
}
return new Promise<number>(resolve => {
timeoutHandle = setTimeout(() => {
doResolve(0, resolve);
}, timeout);
server.on('listening', () => {
doResolve(startPort, resolve);
});
server.on('error', err => {
if (err && ((<any>err).code === 'EADDRINUSE' || (<any>err).code === 'EACCES') && (countTried < giveUpAfter)) {
startPort++;
countTried++;
server.listen(startPort, '127.0.0.1');
} else {
doResolve(0, resolve);
}
});
server.on('close', () => {
doResolve(0, resolve);
});
server.listen(startPort, '127.0.0.1');
});
}
function dispose(socket: net.Socket): void {
try {
socket.removeAllListeners('connect');
socket.removeAllListeners('error');
socket.end();
socket.destroy();
socket.unref();
} catch (error) {
console.error(error); // otherwise this error would get lost in the callback chain
}
}
@@ -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() {
}
@@ -0,0 +1,109 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import * as path from 'path';
import SSHConfiguration, { getSSHConfigPath } from './ssh/sshConfig';
import { RemoteLocationHistory } from './remoteLocationHistory';
import { Disposable } from './common/disposable';
import { addNewHost, openRemoteSSHLocationWindow, openRemoteSSHWindow, openSSHConfigFile } from './commands';
import SSHDestination from './ssh/sshDestination';
class HostItem {
constructor(
public hostname: string,
public locations: string[]
) {
}
}
class HostLocationItem {
constructor(
public path: string,
public hostname: string
) {
}
}
type DataTreeItem = HostItem | HostLocationItem;
export class HostTreeDataProvider extends Disposable implements vscode.TreeDataProvider<DataTreeItem> {
private readonly _onDidChangeTreeData = this._register(new vscode.EventEmitter<DataTreeItem | DataTreeItem[] | void>());
public readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
constructor(
private locationHistory: RemoteLocationHistory
) {
super();
this._register(vscode.commands.registerCommand('openremotessh.explorer.add', () => addNewHost()));
this._register(vscode.commands.registerCommand('openremotessh.explorer.configure', () => openSSHConfigFile()));
this._register(vscode.commands.registerCommand('openremotessh.explorer.refresh', () => this.refresh()));
this._register(vscode.commands.registerCommand('openremotessh.explorer.emptyWindowInNewWindow', e => this.openRemoteSSHWindow(e, false)));
this._register(vscode.commands.registerCommand('openremotessh.explorer.emptyWindowInCurrentWindow', e => this.openRemoteSSHWindow(e, true)));
this._register(vscode.commands.registerCommand('openremotessh.explorer.reopenFolderInNewWindow', e => this.openRemoteSSHLocationWindow(e, false)));
this._register(vscode.commands.registerCommand('openremotessh.explorer.reopenFolderInCurrentWindow', e => this.openRemoteSSHLocationWindow(e, true)));
this._register(vscode.commands.registerCommand('openremotessh.explorer.deleteFolderHistoryItem', e => this.deleteHostLocation(e)));
this._register(vscode.workspace.onDidChangeConfiguration(e => {
if (e.affectsConfiguration('remote.SSH.configFile')) {
this.refresh();
}
}));
this._register(vscode.workspace.onDidSaveTextDocument(e => {
if (e.uri.fsPath === getSSHConfigPath()) {
this.refresh();
}
}));
}
getTreeItem(element: DataTreeItem): vscode.TreeItem {
if (element instanceof HostLocationItem) {
const label = path.posix.basename(element.path).replace(/\.code-workspace$/, ' (Workspace)');
const treeItem = new vscode.TreeItem(label);
treeItem.description = path.posix.dirname(element.path);
treeItem.iconPath = new vscode.ThemeIcon('folder');
treeItem.contextValue = 'openremotessh.explorer.folder';
return treeItem;
}
const treeItem = new vscode.TreeItem(element.hostname);
treeItem.collapsibleState = element.locations.length ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None;
treeItem.iconPath = new vscode.ThemeIcon('vm');
treeItem.contextValue = 'openremotessh.explorer.host';
return treeItem;
}
async getChildren(element?: HostItem): Promise<DataTreeItem[]> {
if (!element) {
const sshConfigFile = await SSHConfiguration.loadFromFS();
const hosts = sshConfigFile.getAllConfiguredHosts();
return hosts.map(hostname => new HostItem(hostname, this.locationHistory.getHistory(hostname)));
}
if (element instanceof HostItem) {
return element.locations.map(location => new HostLocationItem(location, element.hostname));
}
return [];
}
private refresh() {
this._onDidChangeTreeData.fire();
}
private async deleteHostLocation(element: HostLocationItem) {
await this.locationHistory.removeLocation(element.hostname, element.path);
this.refresh();
}
private async openRemoteSSHWindow(element: HostItem, reuseWindow: boolean) {
const sshDest = new SSHDestination(element.hostname);
openRemoteSSHWindow(sshDest.toEncodedString(), reuseWindow);
}
private async openRemoteSSHLocationWindow(element: HostLocationItem, reuseWindow: boolean) {
const sshDest = new SSHDestination(element.hostname);
openRemoteSSHLocationWindow(sshDest.toEncodedString(), element.path, reuseWindow);
}
}
@@ -0,0 +1,58 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { REMOTE_SSH_AUTHORITY } from './authResolver';
import SSHDestination from './ssh/sshDestination';
export class RemoteLocationHistory {
private static STORAGE_KEY = 'remoteLocationHistory_v0';
private remoteLocationHistory: Record<string, string[]> = {};
constructor(private context: vscode.ExtensionContext) {
// context.globalState.update(RemoteLocationHistory.STORAGE_KEY, undefined);
this.remoteLocationHistory = context.globalState.get(RemoteLocationHistory.STORAGE_KEY) || {};
}
getHistory(host: string): string[] {
return this.remoteLocationHistory[host] || [];
}
async addLocation(host: string, path: string) {
const hostLocations = this.remoteLocationHistory[host] || [];
if (!hostLocations.includes(path)) {
hostLocations.unshift(path);
this.remoteLocationHistory[host] = hostLocations;
await this.context.globalState.update(RemoteLocationHistory.STORAGE_KEY, this.remoteLocationHistory);
}
}
async removeLocation(host: string, path: string) {
let hostLocations = this.remoteLocationHistory[host] || [];
hostLocations = hostLocations.filter(l => l !== path);
this.remoteLocationHistory[host] = hostLocations;
await this.context.globalState.update(RemoteLocationHistory.STORAGE_KEY, this.remoteLocationHistory);
}
}
export function getRemoteWorkspaceLocationData(): [string, string] | undefined {
let location = vscode.workspace.workspaceFile;
if (location && location.scheme === 'vscode-remote' && location.authority.startsWith(REMOTE_SSH_AUTHORITY) && location.path.endsWith('.code-workspace')) {
const [, host] = location.authority.split('+');
const sshDest = SSHDestination.parseEncoded(host);
return [sshDest.hostname, location.path];
}
location = vscode.workspace.workspaceFolders?.[0].uri;
if (location && location.scheme === 'vscode-remote' && location.authority.startsWith(REMOTE_SSH_AUTHORITY)) {
const [, host] = location.authority.split('+');
const sshDest = SSHDestination.parseEncoded(host);
return [sshDest.hostname, location.path];
}
return undefined;
}
@@ -0,0 +1,43 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
let vscodeProductJson: any;
async function getVSCodeProductJson() {
if (!vscodeProductJson) {
const productJsonStr = await fs.promises.readFile(path.join(vscode.env.appRoot, 'product.json'), 'utf8');
vscodeProductJson = JSON.parse(productJsonStr);
}
return vscodeProductJson;
}
export interface IServerConfig {
version: string;
commit: string;
quality: string;
release?: string; // void-like specific
serverApplicationName: string;
serverDataFolderName: string;
serverDownloadUrlTemplate?: string; // void-like specific
}
export async function getVSCodeServerConfig(): Promise<IServerConfig> {
const productJson = await getVSCodeProductJson();
const customServerBinaryName = vscode.workspace.getConfiguration('remote.SSH.experimental').get<string>('serverBinaryName', '');
return {
version: vscode.version.replace('-insider', ''),
commit: productJson.commit,
quality: productJson.quality,
release: productJson.release,
serverApplicationName: customServerBinaryName || productJson.serverApplicationName,
serverDataFolderName: productJson.serverDataFolderName,
serverDownloadUrlTemplate: productJson.serverDownloadUrlTemplate
};
}
@@ -0,0 +1,626 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as crypto from 'crypto';
import Log from './common/logger';
import { getVSCodeServerConfig } from './serverConfig';
import SSHConnection from './ssh/sshConnection';
export interface ServerInstallOptions {
id: string;
quality: string;
commit: string;
version: string;
release?: string; // void specific
extensionIds: string[];
envVariables: string[];
useSocketPath: boolean;
serverApplicationName: string;
serverDataFolderName: string;
serverDownloadUrlTemplate: string;
}
export interface ServerInstallResult {
exitCode: number;
listeningOn: number | string;
connectionToken: string;
logFile: string;
osReleaseId: string;
arch: string;
platform: string;
tmpDir: string;
[key: string]: any;
}
export class ServerInstallError extends Error {
constructor(message: string) {
super(message);
}
}
const DEFAULT_DOWNLOAD_URL_TEMPLATE = 'https://github.com/voideditor/void-updates-server/releases/download/test/void-server-${os}-${arch}.tar.gz';
export async function installCodeServer(conn: SSHConnection, serverDownloadUrlTemplate: string | undefined, extensionIds: string[], envVariables: string[], platform: string | undefined, useSocketPath: boolean, logger: Log): Promise<ServerInstallResult> {
let shell = 'powershell';
// detect platform and shell for windows
if (!platform || platform === 'windows') {
const result = await conn.exec('uname -s');
if (result.stdout) {
if (result.stdout.includes('windows32')) {
platform = 'windows';
} else if (result.stdout.includes('MINGW64')) {
platform = 'windows';
shell = 'bash';
}
} else if (result.stderr) {
if (result.stderr.includes('FullyQualifiedErrorId : CommandNotFoundException')) {
platform = 'windows';
}
if (result.stderr.includes('is not recognized as an internal or external command')) {
platform = 'windows';
shell = 'cmd';
}
}
if (platform) {
logger.trace(`Detected platform: ${platform}, ${shell}`);
}
}
const scriptId = crypto.randomBytes(12).toString('hex');
const vscodeServerConfig = await getVSCodeServerConfig();
const installOptions: ServerInstallOptions = {
id: scriptId,
version: vscodeServerConfig.version,
commit: vscodeServerConfig.commit,
quality: vscodeServerConfig.quality,
release: vscodeServerConfig.release,
extensionIds,
envVariables,
useSocketPath,
serverApplicationName: vscodeServerConfig.serverApplicationName,
serverDataFolderName: vscodeServerConfig.serverDataFolderName,
serverDownloadUrlTemplate: serverDownloadUrlTemplate ?? vscodeServerConfig.serverDownloadUrlTemplate ?? DEFAULT_DOWNLOAD_URL_TEMPLATE,
};
let commandOutput: { stdout: string; stderr: string };
if (platform === 'windows') {
const installServerScript = generatePowerShellInstallScript(installOptions);
logger.trace('Server install command:', installServerScript);
const installDir = `$HOME\\${vscodeServerConfig.serverDataFolderName}\\install`;
const installScript = `${installDir}\\${vscodeServerConfig.commit}.ps1`;
const endRegex = new RegExp(`${scriptId}: end`);
// investigate if it's possible to use `-EncodedCommand` flag
// https://devblogs.microsoft.com/powershell/invoking-powershell-with-complex-expressions-using-scriptblocks/
let command = '';
if (shell === 'powershell') {
command = `md -Force ${installDir}; echo @'\n${installServerScript}\n'@ | Set-Content ${installScript}; powershell -ExecutionPolicy ByPass -File "${installScript}"`;
} else if (shell === 'bash') {
command = `mkdir -p ${installDir.replace(/\\/g, '/')} && echo '\n${installServerScript.replace(/'/g, '\'"\'"\'')}\n' > ${installScript.replace(/\\/g, '/')} && powershell -ExecutionPolicy ByPass -File "${installScript}"`;
} else if (shell === 'cmd') {
const script = installServerScript.trim()
// remove comments
.replace(/^#.*$/gm, '')
// remove empty lines
.replace(/\n{2,}/gm, '\n')
// remove leading spaces
.replace(/^\s*/gm, '')
// escape double quotes (from powershell/cmd)
.replace(/"/g, '"""')
// escape single quotes (from cmd)
.replace(/'/g, `''`)
// escape redirect (from cmd)
.replace(/>/g, `^>`)
// escape new lines (from powershell/cmd)
.replace(/\n/g, '\'`n\'');
command = `powershell "md -Force ${installDir}" && powershell "echo '${script}'" > ${installScript.replace('$HOME', '%USERPROFILE%')} && powershell -ExecutionPolicy ByPass -File "${installScript.replace('$HOME', '%USERPROFILE%')}"`;
logger.trace('Command length (8191 max):', command.length);
if (command.length > 8191) {
throw new ServerInstallError(`Command line too long`);
}
} else {
throw new ServerInstallError(`Not supported shell: ${shell}`);
}
commandOutput = await conn.execPartial(command, (stdout: string) => endRegex.test(stdout));
} else {
const installServerScript = generateBashInstallScript(installOptions);
logger.trace('Server install command:', installServerScript);
// Fish shell does not support heredoc so let's workaround it using -c option,
// also replace single quotes (') within the script with ('\'') as there's no quoting within single quotes, see https://unix.stackexchange.com/a/24676
commandOutput = await conn.exec(`bash -c '${installServerScript.replace(/'/g, `'\\''`)}'`);
}
if (commandOutput.stderr) {
logger.trace('Server install command stderr:', commandOutput.stderr);
}
logger.trace('Server install command stdout:', commandOutput.stdout);
const resultMap = parseServerInstallOutput(commandOutput.stdout, scriptId);
if (!resultMap) {
throw new ServerInstallError(`Failed parsing install script output`);
}
const exitCode = parseInt(resultMap.exitCode, 10);
if (exitCode !== 0) {
throw new ServerInstallError(`Couldn't install vscode server on remote server, install script returned non-zero exit status`);
}
const listeningOn = resultMap.listeningOn.match(/^\d+$/)
? parseInt(resultMap.listeningOn, 10)
: resultMap.listeningOn;
const remoteEnvVars = Object.fromEntries(Object.entries(resultMap).filter(([key,]) => envVariables.includes(key)));
return {
exitCode,
listeningOn,
connectionToken: resultMap.connectionToken,
logFile: resultMap.logFile,
osReleaseId: resultMap.osReleaseId,
arch: resultMap.arch,
platform: resultMap.platform,
tmpDir: resultMap.tmpDir,
...remoteEnvVars
};
}
function parseServerInstallOutput(str: string, scriptId: string): { [k: string]: string } | undefined {
const startResultStr = `${scriptId}: start`;
const endResultStr = `${scriptId}: end`;
const startResultIdx = str.indexOf(startResultStr);
if (startResultIdx < 0) {
return undefined;
}
const endResultIdx = str.indexOf(endResultStr, startResultIdx + startResultStr.length);
if (endResultIdx < 0) {
return undefined;
}
const installResult = str.substring(startResultIdx + startResultStr.length, endResultIdx);
const resultMap: { [k: string]: string } = {};
const resultArr = installResult.split(/\r?\n/);
for (const line of resultArr) {
const [key, value] = line.split('==');
resultMap[key] = value;
}
return resultMap;
}
function generateBashInstallScript({ id, quality, version, commit, release, extensionIds, envVariables, useSocketPath, serverApplicationName, serverDataFolderName, serverDownloadUrlTemplate }: ServerInstallOptions) {
const extensions = extensionIds.map(id => '--install-extension ' + id).join(' ');
return `
# Server installation script
TMP_DIR="\${XDG_RUNTIME_DIR:-"/tmp"}"
DISTRO_VERSION="${version}"
DISTRO_COMMIT="${commit}"
DISTRO_QUALITY="${quality}"
DISTRO_VOID_RELEASE="${release ?? ''}"
SERVER_APP_NAME="${serverApplicationName}"
SERVER_INITIAL_EXTENSIONS="${extensions}"
SERVER_LISTEN_FLAG="${useSocketPath ? `--socket-path="$TMP_DIR/vscode-server-sock-${crypto.randomUUID()}"` : '--port=0'}"
SERVER_DATA_DIR="$HOME/${serverDataFolderName}"
SERVER_DIR="$SERVER_DATA_DIR/bin/$DISTRO_COMMIT"
SERVER_SCRIPT="$SERVER_DIR/bin/$SERVER_APP_NAME"
SERVER_LOGFILE="$SERVER_DATA_DIR/.$DISTRO_COMMIT.log"
SERVER_PIDFILE="$SERVER_DATA_DIR/.$DISTRO_COMMIT.pid"
SERVER_TOKENFILE="$SERVER_DATA_DIR/.$DISTRO_COMMIT.token"
SERVER_ARCH=
SERVER_CONNECTION_TOKEN=
SERVER_DOWNLOAD_URL=
LISTENING_ON=
OS_RELEASE_ID=
ARCH=
PLATFORM=
# Mimic output from logs of remote-ssh extension
print_install_results_and_exit() {
echo "${id}: start"
echo "exitCode==$1=="
echo "listeningOn==$LISTENING_ON=="
echo "connectionToken==$SERVER_CONNECTION_TOKEN=="
echo "logFile==$SERVER_LOGFILE=="
echo "osReleaseId==$OS_RELEASE_ID=="
echo "arch==$ARCH=="
echo "platform==$PLATFORM=="
echo "tmpDir==$TMP_DIR=="
${envVariables.map(envVar => `echo "${envVar}==$${envVar}=="`).join('\n')}
echo "${id}: end"
exit 0
}
# Check if platform is supported
KERNEL="$(uname -s)"
case $KERNEL in
Darwin)
PLATFORM="darwin"
;;
Linux)
PLATFORM="linux"
;;
FreeBSD)
PLATFORM="freebsd"
;;
DragonFly)
PLATFORM="dragonfly"
;;
*)
echo "Error platform not supported: $KERNEL"
print_install_results_and_exit 1
;;
esac
# Check machine architecture
ARCH="$(uname -m)"
case $ARCH in
x86_64 | amd64)
SERVER_ARCH="x64"
;;
armv7l | armv8l)
SERVER_ARCH="armhf"
;;
arm64 | aarch64)
SERVER_ARCH="arm64"
;;
ppc64le)
SERVER_ARCH="ppc64le"
;;
riscv64)
SERVER_ARCH="riscv64"
;;
loongarch64)
SERVER_ARCH="loong64"
;;
s390x)
SERVER_ARCH="s390x"
;;
*)
echo "Error architecture not supported: $ARCH"
print_install_results_and_exit 1
;;
esac
# https://www.freedesktop.org/software/systemd/man/os-release.html
OS_RELEASE_ID="$(grep -i '^ID=' /etc/os-release 2>/dev/null | sed 's/^ID=//gi' | sed 's/"//g')"
if [[ -z $OS_RELEASE_ID ]]; then
OS_RELEASE_ID="$(grep -i '^ID=' /usr/lib/os-release 2>/dev/null | sed 's/^ID=//gi' | sed 's/"//g')"
if [[ -z $OS_RELEASE_ID ]]; then
OS_RELEASE_ID="unknown"
fi
fi
# Create installation folder
if [[ ! -d $SERVER_DIR ]]; then
mkdir -p $SERVER_DIR
if (( $? > 0 )); then
echo "Error creating server install directory"
print_install_results_and_exit 1
fi
fi
# adjust platform for void download, if needed
if [[ $OS_RELEASE_ID = alpine ]]; then
PLATFORM=$OS_RELEASE_ID
fi
SERVER_DOWNLOAD_URL="$(echo "${serverDownloadUrlTemplate.replace(/\$\{/g, '\\${')}" | sed "s/\\\${quality}/$DISTRO_QUALITY/g" | sed "s/\\\${version}/$DISTRO_VERSION/g" | sed "s/\\\${commit}/$DISTRO_COMMIT/g" | sed "s/\\\${os}/$PLATFORM/g" | sed "s/\\\${arch}/$SERVER_ARCH/g" | sed "s/\\\${release}/$DISTRO_VOID_RELEASE/g")"
# Check if server script is already installed
if [[ ! -f $SERVER_SCRIPT ]]; then
case "$PLATFORM" in
darwin | linux | alpine )
;;
*)
echo "Error '$PLATFORM' needs manual installation of remote extension host"
print_install_results_and_exit 1
;;
esac
pushd $SERVER_DIR > /dev/null
if [[ ! -z $(which wget) ]]; then
wget --tries=3 --timeout=10 --continue --no-verbose -O vscode-server.tar.gz $SERVER_DOWNLOAD_URL
elif [[ ! -z $(which curl) ]]; then
curl --retry 3 --connect-timeout 10 --location --show-error --silent --output vscode-server.tar.gz $SERVER_DOWNLOAD_URL
else
echo "Error no tool to download server binary"
print_install_results_and_exit 1
fi
if (( $? > 0 )); then
echo "Error downloading server from $SERVER_DOWNLOAD_URL"
print_install_results_and_exit 1
fi
tar -xf vscode-server.tar.gz --strip-components 1
if (( $? > 0 )); then
echo "Error while extracting server contents"
print_install_results_and_exit 1
fi
if [[ ! -f $SERVER_SCRIPT ]]; then
echo "Error server contents are corrupted"
print_install_results_and_exit 1
fi
rm -f vscode-server.tar.gz
popd > /dev/null
else
echo "Server script already installed in $SERVER_SCRIPT"
fi
# Try to find if server is already running
if [[ -f $SERVER_PIDFILE ]]; then
SERVER_PID="$(cat $SERVER_PIDFILE)"
SERVER_RUNNING_PROCESS="$(ps -o pid,args -p $SERVER_PID | grep $SERVER_SCRIPT)"
else
SERVER_RUNNING_PROCESS="$(ps -o pid,args -A | grep $SERVER_SCRIPT | grep -v grep)"
fi
if [[ -z $SERVER_RUNNING_PROCESS ]]; then
if [[ -f $SERVER_LOGFILE ]]; then
rm $SERVER_LOGFILE
fi
if [[ -f $SERVER_TOKENFILE ]]; then
rm $SERVER_TOKENFILE
fi
touch $SERVER_TOKENFILE
chmod 600 $SERVER_TOKENFILE
SERVER_CONNECTION_TOKEN="${crypto.randomUUID()}"
echo $SERVER_CONNECTION_TOKEN > $SERVER_TOKENFILE
$SERVER_SCRIPT --start-server --host=127.0.0.1 $SERVER_LISTEN_FLAG $SERVER_INITIAL_EXTENSIONS --connection-token-file $SERVER_TOKENFILE --telemetry-level off --enable-remote-auto-shutdown --accept-server-license-terms &> $SERVER_LOGFILE &
echo $! > $SERVER_PIDFILE
else
echo "Server script is already running $SERVER_SCRIPT"
fi
if [[ -f $SERVER_TOKENFILE ]]; then
SERVER_CONNECTION_TOKEN="$(cat $SERVER_TOKENFILE)"
else
echo "Error server token file not found $SERVER_TOKENFILE"
print_install_results_and_exit 1
fi
if [[ -f $SERVER_LOGFILE ]]; then
for i in {1..5}; do
LISTENING_ON="$(cat $SERVER_LOGFILE | grep -E 'Extension host agent listening on .+' | sed 's/Extension host agent listening on //')"
if [[ -n $LISTENING_ON ]]; then
break
fi
sleep 0.5
done
if [[ -z $LISTENING_ON ]]; then
echo "Error server did not start successfully"
print_install_results_and_exit 1
fi
else
echo "Error server log file not found $SERVER_LOGFILE"
print_install_results_and_exit 1
fi
# Finish server setup
print_install_results_and_exit 0
`;
}
function generatePowerShellInstallScript({ id, quality, version, commit, release, extensionIds, envVariables, useSocketPath, serverApplicationName, serverDataFolderName, serverDownloadUrlTemplate }: ServerInstallOptions) {
const extensions = extensionIds.map(id => '--install-extension ' + id).join(' ');
const downloadUrl = serverDownloadUrlTemplate
.replace(/\$\{quality\}/g, quality)
.replace(/\$\{version\}/g, version)
.replace(/\$\{commit\}/g, commit)
.replace(/\$\{os\}/g, 'win32')
.replace(/\$\{arch\}/g, 'x64')
.replace(/\$\{release\}/g, release ?? '');
return `
# Server installation script
$TMP_DIR="$env:TEMP\\$([System.IO.Path]::GetRandomFileName())"
$ProgressPreference = "SilentlyContinue"
$DISTRO_VERSION="${version}"
$DISTRO_COMMIT="${commit}"
$DISTRO_QUALITY="${quality}"
$DISTRO_VOID_RELEASE="${release ?? ''}"
$SERVER_APP_NAME="${serverApplicationName}"
$SERVER_INITIAL_EXTENSIONS="${extensions}"
$SERVER_LISTEN_FLAG="${useSocketPath ? `--socket-path="$TMP_DIR/vscode-server-sock-${crypto.randomUUID()}"` : '--port=0'}"
$SERVER_DATA_DIR="$(Resolve-Path ~)\\${serverDataFolderName}"
$SERVER_DIR="$SERVER_DATA_DIR\\bin\\$DISTRO_COMMIT"
$SERVER_SCRIPT="$SERVER_DIR\\bin\\$SERVER_APP_NAME.cmd"
$SERVER_LOGFILE="$SERVER_DATA_DIR\\.$DISTRO_COMMIT.log"
$SERVER_PIDFILE="$SERVER_DATA_DIR\\.$DISTRO_COMMIT.pid"
$SERVER_TOKENFILE="$SERVER_DATA_DIR\\.$DISTRO_COMMIT.token"
$SERVER_ARCH=
$SERVER_CONNECTION_TOKEN=
$SERVER_DOWNLOAD_URL=
$LISTENING_ON=
$OS_RELEASE_ID=
$ARCH=
$PLATFORM="win32"
function printInstallResults($code) {
"${id}: start"
"exitCode==$code=="
"listeningOn==$LISTENING_ON=="
"connectionToken==$SERVER_CONNECTION_TOKEN=="
"logFile==$SERVER_LOGFILE=="
"osReleaseId==$OS_RELEASE_ID=="
"arch==$ARCH=="
"platform==$PLATFORM=="
"tmpDir==$TMP_DIR=="
${envVariables.map(envVar => `"${envVar}==$${envVar}=="`).join('\n')}
"${id}: end"
}
# Check machine architecture
$ARCH=$env:PROCESSOR_ARCHITECTURE
# Use x64 version for ARM64, as it's not yet available.
if(($ARCH -eq "AMD64") -or ($ARCH -eq "IA64") -or ($ARCH -eq "ARM64")) {
$SERVER_ARCH="x64"
}
else {
"Error architecture not supported: $ARCH"
printInstallResults 1
exit 0
}
# Create installation folder
if(!(Test-Path $SERVER_DIR)) {
try {
ni -it d $SERVER_DIR -f -ea si
} catch {
"Error creating server install directory - $($_.ToString())"
exit 1
}
if(!(Test-Path $SERVER_DIR)) {
"Error creating server install directory"
exit 1
}
}
cd $SERVER_DIR
# Check if server script is already installed
if(!(Test-Path $SERVER_SCRIPT)) {
del vscode-server.tar.gz
$REQUEST_ARGUMENTS = @{
Uri="${downloadUrl}"
TimeoutSec=20
OutFile="vscode-server.tar.gz"
UseBasicParsing=$True
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-RestMethod @REQUEST_ARGUMENTS
if(Test-Path "vscode-server.tar.gz") {
tar -xf vscode-server.tar.gz --strip-components 1
del vscode-server.tar.gz
}
if(!(Test-Path $SERVER_SCRIPT)) {
"Error while installing the server binary"
exit 1
}
}
else {
"Server script already installed in $SERVER_SCRIPT"
}
# Try to find if server is already running
if(Get-Process node -ErrorAction SilentlyContinue | Where-Object Path -Like "$SERVER_DIR\\*") {
echo "Server script is already running $SERVER_SCRIPT"
}
else {
if(Test-Path $SERVER_LOGFILE) {
del $SERVER_LOGFILE
}
if(Test-Path $SERVER_PIDFILE) {
del $SERVER_PIDFILE
}
if(Test-Path $SERVER_TOKENFILE) {
del $SERVER_TOKENFILE
}
$SERVER_CONNECTION_TOKEN="${crypto.randomUUID()}"
[System.IO.File]::WriteAllLines($SERVER_TOKENFILE, $SERVER_CONNECTION_TOKEN)
$SCRIPT_ARGUMENTS="--start-server --host=127.0.0.1 $SERVER_LISTEN_FLAG $SERVER_INITIAL_EXTENSIONS --connection-token-file $SERVER_TOKENFILE --telemetry-level off --enable-remote-auto-shutdown --accept-server-license-terms *> '$SERVER_LOGFILE'"
$START_ARGUMENTS = @{
FilePath = "powershell.exe"
WindowStyle = "hidden"
ArgumentList = @(
"-ExecutionPolicy", "Unrestricted", "-NoLogo", "-NoProfile", "-NonInteractive", "-c", "$SERVER_SCRIPT $SCRIPT_ARGUMENTS"
)
PassThru = $True
}
$SERVER_ID = (start @START_ARGUMENTS).ID
if($SERVER_ID) {
[System.IO.File]::WriteAllLines($SERVER_PIDFILE, $SERVER_ID)
}
}
if(Test-Path $SERVER_TOKENFILE) {
$SERVER_CONNECTION_TOKEN="$(cat $SERVER_TOKENFILE)"
}
else {
"Error server token file not found $SERVER_TOKENFILE"
printInstallResults 1
exit 0
}
sleep -Milliseconds 500
$SELECT_ARGUMENTS = @{
Path = $SERVER_LOGFILE
Pattern = "Extension host agent listening on (\\d+)"
}
for($I = 1; $I -le 5; $I++) {
if(Test-Path $SERVER_LOGFILE) {
$GROUPS = (Select-String @SELECT_ARGUMENTS).Matches.Groups
if($GROUPS) {
$LISTENING_ON = $GROUPS[1].Value
break
}
}
sleep -Milliseconds 500
}
if(!(Test-Path $SERVER_LOGFILE)) {
"Error server log file not found $SERVER_LOGFILE"
printInstallResults 1
exit 0
}
# Finish server setup
printInstallResults 0
if($SERVER_ID) {
while($True) {
if(!(gps -Id $SERVER_ID)) {
"server died, exit"
exit 0
}
sleep 30
}
}
`;
}
@@ -0,0 +1,46 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as os from 'os';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { exists as folderExists } from '../common/files';
const PATH_SSH_USER_DIR = path.join(os.homedir(), '.ssh');
const KNOW_HOST_FILE = path.join(PATH_SSH_USER_DIR, 'known_hosts');
const HASH_MAGIC = '|1|';
const HASH_DELIM = '|';
export async function checkNewHostInHostkeys(host: string): Promise<boolean> {
const fileContent = await fs.promises.readFile(KNOW_HOST_FILE, { encoding: 'utf8' });
const lines = fileContent.split(/\r?\n/);
for (let line of lines) {
line = line.trim();
if (!line.startsWith(HASH_MAGIC)) {
continue;
}
const [hostEncripted_] = line.split(' ');
const [salt_, hostHash_] = hostEncripted_.substring(HASH_MAGIC.length).split(HASH_DELIM);
const hostHash = crypto.createHmac('sha1', Buffer.from(salt_, 'base64')).update(host).digest();
if (hostHash.toString('base64') === hostHash_) {
return false;
}
}
return true;
}
export async function addHostToHostFile(host: string, hostKey: Buffer, type: string): Promise<void> {
if (!folderExists(PATH_SSH_USER_DIR)) {
await fs.promises.mkdir(PATH_SSH_USER_DIR, 0o700);
}
const salt = crypto.randomBytes(20);
const hostHash = crypto.createHmac('sha1', salt).update(host).digest();
const entry = `${HASH_MAGIC}${salt.toString('base64')}${HASH_DELIM}${hostHash.toString('base64')} ${type} ${hostKey.toString('base64')}\n`;
await fs.promises.appendFile(KNOW_HOST_FILE, entry);
}
@@ -0,0 +1,120 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as crypto from 'crypto';
import type { ParsedKey } from 'ssh2-streams';
import * as ssh2 from 'ssh2';
import { untildify, exists as fileExists } from '../common/files';
import Log from '../common/logger';
const homeDir = os.homedir();
const PATH_SSH_CLIENT_ID_DSA = path.join(homeDir, '.ssh', '/id_dsa');
const PATH_SSH_CLIENT_ID_ECDSA = path.join(homeDir, '.ssh', '/id_ecdsa');
const PATH_SSH_CLIENT_ID_RSA = path.join(homeDir, '.ssh', '/id_rsa');
const PATH_SSH_CLIENT_ID_ED25519 = path.join(homeDir, '.ssh', '/id_ed25519');
const PATH_SSH_CLIENT_ID_XMSS = path.join(homeDir, '.ssh', '/id_xmss');
const PATH_SSH_CLIENT_ID_ECDSA_SK = path.join(homeDir, '.ssh', '/id_ecdsa_sk');
const PATH_SSH_CLIENT_ID_ED25519_SK = path.join(homeDir, '.ssh', '/id_ed25519_sk');
const DEFAULT_IDENTITY_FILES: string[] = [
PATH_SSH_CLIENT_ID_RSA,
PATH_SSH_CLIENT_ID_ECDSA,
PATH_SSH_CLIENT_ID_ECDSA_SK,
PATH_SSH_CLIENT_ID_ED25519,
PATH_SSH_CLIENT_ID_ED25519_SK,
PATH_SSH_CLIENT_ID_XMSS,
PATH_SSH_CLIENT_ID_DSA,
];
export interface SSHKey {
filename: string;
parsedKey: ParsedKey;
fingerprint: string;
agentSupport?: boolean;
isPrivate?: boolean;
}
// From https://github.com/openssh/openssh-portable/blob/acb2059febaddd71ee06c2ebf63dcf211d9ab9f2/sshconnect2.c#L1689-L1690
export async function gatherIdentityFiles(identityFiles: string[], sshAgentSock: string | undefined, identitiesOnly: boolean, logger: Log) {
identityFiles = identityFiles.map(untildify).map(i => i.replace(/\.pub$/, ''));
if (identityFiles.length === 0) {
identityFiles.push(...DEFAULT_IDENTITY_FILES);
}
const identityFileContentsResult = await Promise.allSettled(identityFiles.map(async keyPath => {
keyPath = await fileExists(keyPath + '.pub') ? keyPath + '.pub' : keyPath;
return fs.promises.readFile(keyPath);
}));
const fileKeys: SSHKey[] = identityFileContentsResult.map((result, i) => {
if (result.status === 'rejected') {
return undefined;
}
const parsedResult = ssh2.utils.parseKey(result.value);
if (parsedResult instanceof Error || !parsedResult) {
logger.error(`Error while parsing SSH public key ${identityFiles[i]}:`, parsedResult);
return undefined;
}
const parsedKey = Array.isArray(parsedResult) ? parsedResult[0] : parsedResult;
const fingerprint = crypto.createHash('sha256').update(parsedKey.getPublicSSH()).digest('base64');
return {
filename: identityFiles[i],
parsedKey,
fingerprint
};
}).filter(<T>(v: T | undefined): v is T => !!v);
let sshAgentParsedKeys: ParsedKey[] = [];
try {
if (!sshAgentSock) {
throw new Error(`SSH_AUTH_SOCK environment variable not defined`);
}
sshAgentParsedKeys = await new Promise<ParsedKey[]>((resolve, reject) => {
const sshAgent = new ssh2.OpenSSHAgent(sshAgentSock);
sshAgent.getIdentities((err, publicKeys) => {
if (err) {
reject(err);
} else {
resolve(publicKeys || []);
}
});
});
} catch (e) {
logger.error(`Couldn't get identities from OpenSSH agent`, e);
}
const sshAgentKeys: SSHKey[] = sshAgentParsedKeys.map(parsedKey => {
const fingerprint = crypto.createHash('sha256').update(parsedKey.getPublicSSH()).digest('base64');
return {
filename: parsedKey.comment,
parsedKey,
fingerprint,
agentSupport: true
};
});
const agentKeys: SSHKey[] = [];
const preferredIdentityKeys: SSHKey[] = [];
for (const agentKey of sshAgentKeys) {
const foundIdx = fileKeys.findIndex(k => agentKey.parsedKey.type === k.parsedKey.type && agentKey.fingerprint === k.fingerprint);
if (foundIdx >= 0) {
preferredIdentityKeys.push({ ...fileKeys[foundIdx], agentSupport: true });
fileKeys.splice(foundIdx, 1);
} else if (!identitiesOnly) {
agentKeys.push(agentKey);
}
}
preferredIdentityKeys.push(...agentKeys);
preferredIdentityKeys.push(...fileKeys);
logger.trace(`Identity keys:`, preferredIdentityKeys.length ? preferredIdentityKeys.map(k => `${k.filename} ${k.parsedKey.type} SHA256:${k.fingerprint}`).join('\n') : 'None');
return preferredIdentityKeys;
}
@@ -0,0 +1,129 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as os from 'os';
import * as fs from 'fs';
import * as path from 'path';
import SSHConfig, { Directive, Line, Section } from '@jeanp413/ssh-config';
import * as vscode from 'vscode';
import { exists as fileExists, normalizeToSlash, untildify } from '../common/files';
import { isWindows } from '../common/platform';
import { glob } from 'glob';
const systemSSHConfig = isWindows ? path.resolve(process.env.ALLUSERSPROFILE || 'C:\\ProgramData', 'ssh\\ssh_config') : '/etc/ssh/ssh_config';
const defaultSSHConfigPath = path.resolve(os.homedir(), '.ssh/config');
export function getSSHConfigPath() {
const sshConfigPath = vscode.workspace.getConfiguration('remote.SSH').get<string>('configFile');
return sshConfigPath ? untildify(sshConfigPath) : defaultSSHConfigPath;
}
function isDirective(line: Line): line is Directive {
return line.type === SSHConfig.DIRECTIVE;
}
function isHostSection(line: Line): line is Section {
return isDirective(line) && line.param === 'Host' && !!line.value && !!(line as Section).config;
}
function isIncludeDirective(line: Line): line is Section {
return isDirective(line) && line.param === 'Include' && !!line.value;
}
const SSH_CONFIG_PROPERTIES: Record<string, string> = {
'host': 'Host',
'hostname': 'HostName',
'user': 'User',
'port': 'Port',
'identityagent': 'IdentityAgent',
'identitiesonly': 'IdentitiesOnly',
'identityfile': 'IdentityFile',
'forwardagent': 'ForwardAgent',
'preferredauthentications': 'PreferredAuthentications',
'proxyjump': 'ProxyJump',
'proxycommand': 'ProxyCommand',
'include': 'Include',
};
function normalizeProp(prop: Directive) {
prop.param = SSH_CONFIG_PROPERTIES[prop.param.toLowerCase()] || prop.param;
}
function normalizeSSHConfig(config: SSHConfig) {
for (const line of config) {
if (isDirective(line)) {
normalizeProp(line);
}
if (isHostSection(line)) {
normalizeSSHConfig(line.config);
}
}
return config;
}
async function parseSSHConfigFromFile(filePath: string, userConfig: boolean) {
let content = '';
if (await fileExists(filePath)) {
content = (await fs.promises.readFile(filePath, 'utf8')).trim();
}
const config = normalizeSSHConfig(SSHConfig.parse(content));
const includedConfigs: [number, SSHConfig[]][] = [];
for (let i = 0; i < config.length; i++) {
const line = config[i];
if (isIncludeDirective(line)) {
const values = (line.value as string).split(',').map(s => s.trim());
const configs: SSHConfig[] = [];
for (const value of values) {
const includePaths = await glob(normalizeToSlash(untildify(value)), {
absolute: true,
cwd: normalizeToSlash(path.dirname(userConfig ? defaultSSHConfigPath : systemSSHConfig))
});
for (const p of includePaths) {
configs.push(await parseSSHConfigFromFile(p, userConfig));
}
}
includedConfigs.push([i, configs]);
}
}
for (const [idx, includeConfigs] of includedConfigs.reverse()) {
config.splice(idx, 1, ...includeConfigs.flat());
}
return config;
}
export default class SSHConfiguration {
static async loadFromFS(): Promise<SSHConfiguration> {
const config = await parseSSHConfigFromFile(getSSHConfigPath(), true);
config.push(...await parseSSHConfigFromFile(systemSSHConfig, false));
return new SSHConfiguration(config);
}
constructor(private sshConfig: SSHConfig) {
}
getAllConfiguredHosts(): string[] {
const hosts = new Set<string>();
for (const line of this.sshConfig) {
if (isHostSection(line)) {
const value = Array.isArray(line.value) ? line.value[0] : line.value;
const isPattern = /^!/.test(value) || /[?*]/.test(value);
if (!isPattern) {
hosts.add(value);
}
}
}
return [...hosts.keys()];
}
getHostConfiguration(host: string): Record<string, string> {
// Only a few directives return an array
// https://github.com/jeanp413/ssh-config/blob/8d187bb8f1d83a51ff2b1d127e6b6269d24092b5/src/ssh-config.ts#L9C1-L9C118
return this.sshConfig.compute(host) as Record<string, string>;
}
}
@@ -0,0 +1,367 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { EventEmitter } from 'events';
import * as net from 'net';
import * as fs from 'fs';
import * as stream from 'stream';
import { Client, ClientChannel, ClientErrorExtensions, ExecOptions, ShellOptions, ConnectConfig } from 'ssh2';
import { Server } from 'net';
import { SocksConnectionInfo, createServer as createSocksServer } from 'simple-socks';
export interface SSHConnectConfig extends ConnectConfig {
/** Optional Unique ID attached to ssh connection. */
uniqueId?: string;
/** Automatic retry to connect, after disconnect. Default true */
reconnect?: boolean;
/** Number of reconnect retry, after disconnect. Default 10 */
reconnectTries?: number;
/** Delay after which reconnect should be done. Default 5000ms */
reconnectDelay?: number;
/** Path to private key */
identity?: string | Buffer;
}
export interface SSHTunnelConfig {
/** Remote Address to connect */
remoteAddr?: string;
/** Local port to bind to. By default, it will bind to a random port, if not passed */
localPort?: number;
/** Remote Port to connect */
remotePort?: number;
/** Remote socket path to connect */
remoteSocketPath?: string;
socks?: boolean;
/** Unique name */
name?: string;
}
const defaultOptions: Partial<SSHConnectConfig> = {
reconnect: false,
port: 22,
reconnectTries: 3,
reconnectDelay: 5000
};
const SSHConstants = {
'CHANNEL': {
SSH: 'ssh',
TUNNEL: 'tunnel',
X11: 'x11'
},
'STATUS': {
BEFORECONNECT: 'beforeconnect',
CONNECT: 'connect',
BEFOREDISCONNECT: 'beforedisconnect',
DISCONNECT: 'disconnect'
}
};
export default class SSHConnection extends EventEmitter {
public config: SSHConnectConfig;
private activeTunnels: { [index: string]: SSHTunnelConfig & { server: Server } } = {};
private __$connectPromise: Promise<SSHConnection> | null = null;
private __retries: number = 0;
private __err: Error & ClientErrorExtensions & { code?: string } | null = null;
private sshConnection: Client | null = null;
constructor(options: SSHConnectConfig) {
super();
this.config = Object.assign({}, defaultOptions, options);
this.config.uniqueId = this.config.uniqueId || `${this.config.username}@${this.config.host}`;
}
/**
* Emit message on this channel
*/
override emit(channel: string, status: string, payload?: any): boolean {
super.emit(channel, status, this, payload);
return super.emit(`${channel}:${status}`, this, payload);
}
/**
* Get shell socket
*/
shell(options: ShellOptions = {}): Promise<ClientChannel> {
return this.connect().then(() => {
return new Promise<ClientChannel>((resolve, reject) => {
this.sshConnection!.shell(options, (err, stream) => err ? reject(err) : resolve(stream));
});
});
}
/**
* Exec a command
*/
exec(cmd: string, params?: Array<string>, options: ExecOptions = {}): Promise<{ stdout: string; stderr: string }> {
cmd += (Array.isArray(params) ? (' ' + params.join(' ')) : '');
return this.connect().then(() => {
return new Promise((resolve, reject) => {
this.sshConnection!.exec(cmd, options, (err, stream) => {
if (err) {
return reject(err);
}
let stdout = '';
let stderr = '';
stream.on('close', function () {
return resolve({ stdout, stderr });
}).on('data', function (data: Buffer | string) {
stdout += data.toString();
}).stderr.on('data', function (data: Buffer | string) {
stderr += data.toString();
});
});
});
});
}
/**
* Exec a command
*/
execPartial(cmd: string, tester: (stdout: string, stderr: string) => boolean, params?: Array<string>, options: ExecOptions = {}): Promise<{ stdout: string; stderr: string }> {
cmd += (Array.isArray(params) ? (' ' + params.join(' ')) : '');
return this.connect().then(() => {
return new Promise((resolve, reject) => {
this.sshConnection!.exec(cmd, options, (err, stream) => {
if (err) {
return reject(err);
}
let stdout = '';
let stderr = '';
let resolved = false;
stream.on('close', function () {
if (!resolved) {
return resolve({ stdout, stderr });
}
}).on('data', function (data: Buffer | string) {
stdout += data.toString();
if (tester(stdout, stderr)) {
resolved = true;
return resolve({ stdout, stderr });
}
}).stderr.on('data', function (data: Buffer | string) {
stderr += data.toString();
if (tester(stdout, stderr)) {
resolved = true;
return resolve({ stdout, stderr });
}
});
});
});
});
}
/**
* Forward out
*/
forwardOut(srcIP: string, srcPort: number, destIP: string, destPort: number): Promise<ClientChannel> {
return this.connect().then(() => {
return new Promise((resolve, reject) => {
this.sshConnection!.forwardOut(srcIP, srcPort, destIP, destPort, (err, stream) => {
if (err) {
return reject(err);
}
resolve(stream);
});
});
});
}
/**
* Get a Socks Port
*/
getSocksPort(localPort: number): Promise<number> {
return this.addTunnel({ name: '__socksServer', socks: true, localPort: localPort }).then((tunnel) => {
return tunnel.localPort!;
});
}
/**
* Close SSH Connection
*/
close(): Promise<void> {
this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.BEFOREDISCONNECT);
return this.closeTunnel().then(() => {
if (this.sshConnection) {
this.sshConnection.end();
this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.DISCONNECT);
}
});
}
/**
* Connect the SSH Connection
*/
connect(c?: SSHConnectConfig): Promise<SSHConnection> {
this.config = Object.assign(this.config, c);
++this.__retries;
if (this.__$connectPromise) {
return this.__$connectPromise;
}
this.__$connectPromise = new Promise((resolve, reject) => {
this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.BEFORECONNECT);
if (!this.config || typeof this.config === 'function' || !(this.config.host || this.config.sock) || !this.config.username) {
reject(`Invalid SSH connection configuration host/username can't be empty`);
this.__$connectPromise = null;
return;
}
if (this.config.identity) {
if (fs.existsSync(this.config.identity)) {
this.config.privateKey = fs.readFileSync(this.config.identity);
}
delete this.config.identity;
}
//Start ssh server connection
this.sshConnection = new Client();
this.sshConnection.on('ready', (err: Error & ClientErrorExtensions) => {
if (err) {
this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.DISCONNECT, { err: err });
this.__$connectPromise = null;
return reject(err);
}
this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.CONNECT);
this.__retries = 0;
this.__err = null;
resolve(this);
}).on('error', (err) => {
this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.DISCONNECT, { err: err });
this.__err = err;
}).on('close', () => {
this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.DISCONNECT, { err: this.__err });
if (this.config.reconnect && this.__retries <= this.config.reconnectTries! && this.__err && this.__err.level !== 'client-authentication' && this.__err.code !== 'ENOTFOUND') {
setTimeout(() => {
this.__$connectPromise = null;
resolve(this.connect());
}, this.config.reconnectDelay);
} else {
reject(this.__err);
}
}).connect(this.config);
});
return this.__$connectPromise;
}
/**
* Get existing tunnel by name
*/
getTunnel(name: string) {
return this.activeTunnels[name];
}
/**
* Add new tunnel if not exist
*/
addTunnel(SSHTunnelConfig: SSHTunnelConfig): Promise<SSHTunnelConfig & { server: Server }> {
SSHTunnelConfig.name = SSHTunnelConfig.name || `${SSHTunnelConfig.remoteAddr}@${SSHTunnelConfig.remotePort || SSHTunnelConfig.remoteSocketPath}`;
this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.BEFORECONNECT, { SSHTunnelConfig: SSHTunnelConfig });
if (this.getTunnel(SSHTunnelConfig.name)) {
this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.CONNECT, { SSHTunnelConfig: SSHTunnelConfig });
return Promise.resolve(this.getTunnel(SSHTunnelConfig.name));
} else {
return new Promise((resolve, reject) => {
let server: net.Server;
if (SSHTunnelConfig.socks) {
server = createSocksServer({
connectionFilter: (destination: SocksConnectionInfo, origin: SocksConnectionInfo, callback: (err?: any, dest?: stream.Duplex) => void) => {
this.connect().then(() => {
this.sshConnection!.forwardOut(
origin.address,
origin.port,
destination.address,
destination.port,
(err, stream) => {
if (err) {
this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.DISCONNECT, { SSHTunnelConfig: SSHTunnelConfig, err: err });
return callback(err);
}
return callback(null, stream);
});
});
}
}).on('proxyError', (err: any) => {
this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.DISCONNECT, { SSHTunnelConfig: SSHTunnelConfig, err: err });
});
} else {
server = net.createServer()
.on('connection', (socket) => {
this.connect().then(() => {
if (SSHTunnelConfig.remotePort) {
this.sshConnection!.forwardOut('127.0.0.1', 0, SSHTunnelConfig.remoteAddr!, SSHTunnelConfig.remotePort!, (err, stream) => {
if (err) {
this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.DISCONNECT, { SSHTunnelConfig: SSHTunnelConfig, err: err });
return;
}
stream.pipe(socket);
socket.pipe(stream);
});
} else {
this.sshConnection!.openssh_forwardOutStreamLocal(SSHTunnelConfig.remoteSocketPath!, (err, stream) => {
if (err) {
this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.DISCONNECT, { SSHTunnelConfig: SSHTunnelConfig, err: err });
return;
}
stream.pipe(socket);
socket.pipe(stream);
});
}
});
});
}
SSHTunnelConfig.localPort = SSHTunnelConfig.localPort || 0;
server.on('listening', () => {
SSHTunnelConfig.localPort = (server.address() as net.AddressInfo).port;
this.activeTunnels[SSHTunnelConfig.name!] = Object.assign({}, { server }, SSHTunnelConfig);
this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.CONNECT, { SSHTunnelConfig: SSHTunnelConfig });
resolve(this.activeTunnels[SSHTunnelConfig.name!]);
}).on('error', (err: any) => {
this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.DISCONNECT, { SSHTunnelConfig: SSHTunnelConfig, err: err });
server.close();
reject(err);
delete this.activeTunnels[SSHTunnelConfig.name!];
}).listen(SSHTunnelConfig.localPort);
});
}
}
/**
* Close the tunnel
*/
closeTunnel(name?: string): Promise<void> {
if (name && this.activeTunnels[name]) {
return new Promise((resolve) => {
const tunnel = this.activeTunnels[name];
this.emit(
SSHConstants.CHANNEL.TUNNEL,
SSHConstants.STATUS.BEFOREDISCONNECT,
{ SSHTunnelConfig: tunnel }
);
tunnel.server.close(() => {
this.emit(
SSHConstants.CHANNEL.TUNNEL,
SSHConstants.STATUS.DISCONNECT,
{ SSHTunnelConfig: this.activeTunnels[name] }
);
delete this.activeTunnels[name];
resolve();
});
});
} else if (!name) {
const tunnels = Object.keys(this.activeTunnels).map((key) => this.closeTunnel(key));
return Promise.all(tunnels).then(() => { });
}
return Promise.resolve();
}
}
@@ -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()}`);
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./out",
},
"include": [
"src/**/*",
"../../src/vscode-dts/vscode.d.ts",
"../../src/vscode-dts/vscode.proposed.resolvers.d.ts",
"../../src/vscode-dts/vscode.proposed.contribViewsRemote.d.ts",
]
}
+17 -15
View File
@@ -10,7 +10,7 @@
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.32.1",
"@anthropic-ai/sdk": "^0.37.0",
"@floating-ui/react": "^0.27.3",
"@google/generative-ai": "^0.21.0",
"@microsoft/1ds-core-js": "^3.2.13",
@@ -42,6 +42,7 @@
"@xterm/headless": "^5.6.0-beta.64",
"@xterm/xterm": "^5.6.0-beta.64",
"ajv": "^8.17.1",
"cross-spawn": "^7.0.6",
"diff": "^7.0.0",
"groq-sdk": "^0.9.0",
"http-proxy-agent": "^7.0.0",
@@ -56,7 +57,7 @@
"node-pty": "1.1.0-beta21",
"ollama": "^0.5.11",
"open": "^8.4.2",
"openai": "^4.76.1",
"openai": "^4.85.4",
"posthog-node": "^4.3.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -222,9 +223,10 @@
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.32.1",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.32.1.tgz",
"integrity": "sha512-U9JwTrDvdQ9iWuABVsMLj8nJVwAyQz6QXvgLsVhryhCEPkLsbcP/MXxm+jYcAwLoV8ESbaTTjnD4kuAFa+Hyjg==",
"version": "0.37.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.37.0.tgz",
"integrity": "sha512-tHjX2YbkUBwEgg0JZU3EFSSAQPoK4qQR/NFYa8Vtzd5UAyXzZksCw2In69Rml4R/TyHPBfRYaLK35XiOe33pjw==",
"license": "MIT",
"dependencies": {
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
@@ -7116,7 +7118,7 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
@@ -14249,8 +14251,7 @@
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
},
"node_modules/isobject": {
"version": "3.0.1",
@@ -17079,9 +17080,10 @@
}
},
"node_modules/openai": {
"version": "4.77.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-4.77.0.tgz",
"integrity": "sha512-WWacavtns/7pCUkOWvQIjyOfcdr9X+9n9Vvb0zFeKVDAqwCMDHB+iSr24SVaBAhplvSG6JrRXFpcNM9gWhOGIw==",
"version": "4.85.4",
"resolved": "https://registry.npmjs.org/openai/-/openai-4.85.4.tgz",
"integrity": "sha512-Nki51PBSu+Aryo7WKbdXvfm0X/iKkQS2fq3O0Uqb/O3b4exOZFid2te1BZ52bbO5UwxQZ5eeHJDCTqtrJLPw0w==",
"license": "Apache-2.0",
"dependencies": {
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
@@ -17095,9 +17097,13 @@
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.23.8"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
@@ -17536,7 +17542,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"engines": {
"node": ">=8"
}
@@ -20241,7 +20246,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"dependencies": {
"shebang-regex": "^3.0.0"
},
@@ -20253,7 +20257,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"engines": {
"node": ">=8"
}
@@ -23733,7 +23736,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"dependencies": {
"isexe": "^2.0.0"
},
+3 -2
View File
@@ -78,7 +78,7 @@
"update-build-ts-version": "npm install typescript@next && tsc -p ./build/tsconfig.build.json"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.32.1",
"@anthropic-ai/sdk": "^0.37.0",
"@floating-ui/react": "^0.27.3",
"@google/generative-ai": "^0.21.0",
"@microsoft/1ds-core-js": "^3.2.13",
@@ -110,6 +110,7 @@
"@xterm/headless": "^5.6.0-beta.64",
"@xterm/xterm": "^5.6.0-beta.64",
"ajv": "^8.17.1",
"cross-spawn": "^7.0.6",
"diff": "^7.0.0",
"groq-sdk": "^0.9.0",
"http-proxy-agent": "^7.0.0",
@@ -124,7 +125,7 @@
"node-pty": "1.1.0-beta21",
"ollama": "^0.5.11",
"open": "^8.4.2",
"openai": "^4.76.1",
"openai": "^4.85.4",
"posthog-node": "^4.3.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+2 -2
View File
@@ -31,8 +31,8 @@
"nodejsRepository": "https://nodejs.org",
"urlProtocol": "void-editor",
"extensionsGallery": {
"serviceUrl": "https://open-vsx.org/vscode/gallery",
"itemUrl": "https://open-vsx.org/vscode/item"
"serviceUrl": "https://marketplace.visualstudio.com/_apis/public/gallery",
"itemUrl": "https://marketplace.visualstudio.com/items"
},
"builtInExtensions": []
}
+6
View File
@@ -38,6 +38,7 @@
"native-watchdog": "^1.4.1",
"node-pty": "1.1.0-beta21",
"tas-client-umd": "0.2.0",
"tslib": "^2.8.1",
"vscode-oniguruma": "1.7.0",
"vscode-regexpp": "^3.1.0",
"vscode-textmate": "9.1.0",
@@ -1035,6 +1036,11 @@
"node": ">=8.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+1
View File
@@ -33,6 +33,7 @@
"native-watchdog": "^1.4.1",
"node-pty": "1.1.0-beta21",
"tas-client-umd": "0.2.0",
"tslib": "^2.8.1",
"vscode-oniguruma": "1.7.0",
"vscode-regexpp": "^3.1.0",
"vscode-textmate": "9.1.0",
@@ -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...');
@@ -3,185 +3,106 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { Emitter, Event } from '../../../../base/common/event.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
// import { URI } from '../../../../base/common/uri.js';
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
// import { IToolService, ToolService } from '../common/toolsService.js';
// 1. search(ai)
// - tool use to find all possible changes
// - if search only: is this file related to the search?
// - if search + replace: should I modify this file?
// 2. replace(ai)
// - what changes to make?
// 3. postprocess errors
// -fastapply changes simultaneously
// -iterate on syntax errors (all files can be changed from a syntax error, not just the one with the error)
// private async _searchUsingAI({ searchClause }: { searchClause: string }) {
export type ChatMessageLocation = {
threadId: string;
messageIdx: number;
}
// // const relevantURIs: URI[] = []
// // const gatherPrompt = `\
// // asdasdas
// // `
// // const filterPrompt = `\
// // Is this file relevant?
// // `
export type SearchAndReplaceBlock = {
search: string;
replace: string;
}
// // // optimizations (DO THESE LATER!!!!!!)
// // // if tool includes a uri in uriSet, skip it obviously
// // let uriSet = new Set<URI>()
// // // gather
// // let messages = []
// // while (true) {
// // const result = await new Promise((res, rej) => {
// // sendLLMMessage({
// // messages,
// // tools: ['search'],
// // onFinalMessage: ({ result: r, }) => {
// // res(r)
// // },
// // onError: (error) => {
// // rej(error)
// // }
// // })
// // })
// service that manages state
export type ApplyState = {
[applyBoxId: string]: {
searchAndReplaceBlocks: SearchAndReplaceBlock;
}
}
// // messages.push({ role: 'tool', content: turnToString(result) })
// the purpose of this service is to generate search and replace blocks for a given codeblock `codeblockId` and on a file `fileName` and version `fileVersion`
// // sendLLMMessage({
// // messages: { 'Output ': result },
// // onFinalMessage: (r) => {
// // // output is file1\nfile2\nfile3\n...
// // }
// // })
export interface IFastApplyService {
readonly _serviceBrand: undefined;
// // uriSet.add(...)
// // }
// readonly state: ApplyState; // readonly to the user
// setState(newState: Partial<ApplyState>): void;
// onDidChangeState: Event<void>;
}
// // // writes
// // if (!replaceClause) return
export const IVoidFastApplyService = createDecorator<IFastApplyService>('voidFastApplyService');
class VoidFastApplyService extends Disposable implements IFastApplyService {
_serviceBrand: undefined;
// static readonly ID = 'voidFastApplyService';
private readonly _onDidChangeState = new Emitter<void>();
readonly onDidChangeState: Event<void> = this._onDidChangeState.event;
// state
// state: ApplyState
constructor(
// @IToolService private readonly toolService: ToolService
) {
super()
// initial state
// this.state = { currentUri: undefined }
}
setState(newState: Partial<ApplyState>) {
// this.state = { ...this.state, ...newState }
this._onDidChangeState.fire()
}
aiSearch(searchStr: string) {
}
aiReplace(searchStr: string, replaceStr: string) {
}
// 1. search(ai)
// - tool use to find all possible changes
// - if search only: is this file related to the search?
// - if search + replace: should I modify this file?
// 2. replace(ai)
// - what changes to make?
// 3. postprocess errors
// -fastapply changes simultaneously
// -iterate on syntax errors (all files can be changed from a syntax error, not just the one with the error)
// private async _searchUsingAI({ searchClause }: { searchClause: string }) {
// // const relevantURIs: URI[] = []
// // const gatherPrompt = `\
// // asdasdas
// // `
// // const filterPrompt = `\
// // Is this file relevant?
// // `
// // // optimizations (DO THESE LATER!!!!!!)
// // // if tool includes a uri in uriSet, skip it obviously
// // let uriSet = new Set<URI>()
// // // gather
// // let messages = []
// // while (true) {
// // const result = await new Promise((res, rej) => {
// // sendLLMMessage({
// // messages,
// // tools: ['search'],
// // onFinalMessage: ({ result: r, }) => {
// // res(r)
// // },
// // onError: (error) => {
// // rej(error)
// // }
// // })
// // })
// // messages.push({ role: 'tool', content: turnToString(result) })
// // sendLLMMessage({
// // messages: { 'Output ': result },
// // onFinalMessage: (r) => {
// // // output is file1\nfile2\nfile3\n...
// // }
// // })
// // uriSet.add(...)
// // }
// // // writes
// // if (!replaceClause) return
// // for (const uri of uriSet) {
// // // in future, batch these
// // applyWorkflow({ uri, applyStr: replaceClause })
// // }
// // for (const uri of uriSet) {
// // // in future, batch these
// // applyWorkflow({ uri, applyStr: replaceClause })
// // }
// // while (true) {
// // const result = new Promise((res, rej) => {
// // sendLLMMessage({
// // messages,
// // tools: ['search'],
// // onResult: (r) => {
// // res(r)
// // }
// // })
// // })
// // while (true) {
// // const result = new Promise((res, rej) => {
// // sendLLMMessage({
// // messages,
// // tools: ['search'],
// // onResult: (r) => {
// // res(r)
// // }
// // })
// // })
// // messages.push(result)
// // messages.push(result)
// // }
// // }
// }
// }
// private async _replaceUsingAI({ searchClause, replaceClause, relevantURIs }: { searchClause: string, replaceClause: string, relevantURIs: URI[] }) {
// private async _replaceUsingAI({ searchClause, replaceClause, relevantURIs }: { searchClause: string, replaceClause: string, relevantURIs: URI[] }) {
// for (const uri of relevantURIs) {
// for (const uri of relevantURIs) {
// uri
// uri
// }
// }
// // should I change this file?
// // if so what changes to make?
// // should I change this file?
// // if so what changes to make?
// // fast apply the changes
// }
// // fast apply the changes
// }
}
registerSingleton(IVoidFastApplyService, VoidFastApplyService, InstantiationType.Eager);
@@ -1400,7 +1400,7 @@ class EditCodeService extends Disposable implements IEditCodeService {
const latestStreamInfoMutable: StreamLocationMutable = { line: diffZone.startLine, addedSplitYet: false, col: 1, originalCodeStartLine: 1 }
// state used in onText:
let fullText = ''
let fullTextSoFar = '' // so far (INCLUDING ignored suffix)
let prevIgnoredSuffix = ''
streamRequestIdRef.current = this._llmMessageService.sendLLMMessage({
@@ -1408,12 +1408,13 @@ class EditCodeService extends Disposable implements IEditCodeService {
useProviderFor: opts.from === 'ClickApply' ? 'Apply' : 'Ctrl+K',
logging: { loggingName: `startApplying - ${from}` },
messages,
onText: ({ newText: newText_ }) => {
onText: ({ fullText: fullText_ }) => {
const newText_ = fullText_.substring(fullTextSoFar.length, Infinity)
const newText = prevIgnoredSuffix + newText_ // add the previously ignored suffix because it's no longer the suffix!
fullText += prevIgnoredSuffix + newText // full text, including ```, etc
fullTextSoFar += newText // full text, including ```, etc
const [croppedText, deltaCroppedText, croppedSuffix] = extractText(fullText, newText.length)
const [croppedText, deltaCroppedText, croppedSuffix] = extractText(fullTextSoFar, newText.length)
const { endLineInLlmTextSoFar } = this._writeStreamedDiffZoneLLMText(uri, originalCode, croppedText, deltaCroppedText, latestStreamInfoMutable)
diffZone._streamState.line = (diffZone.startLine - 1) + endLineInLlmTextSoFar // change coordinate systems from originalCode to full file
@@ -3,6 +3,7 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { OnText } from '../../common/llmMessageTypes.js'
import { DIVIDER, FINAL, ORIGINAL } from '../prompt/prompts.js'
class SurroundingsRemover {
@@ -172,7 +173,7 @@ export type ExtractedSearchReplaceBlock = {
const endsWithAnyPrefixOf = (str: string, anyPrefix: string) => {
// for each prefix
for (let i = anyPrefix.length; i >= 0; i--) {
for (let i = anyPrefix.length; i >= 1; i--) { // i >= 1 because must not be empty string
const prefix = anyPrefix.slice(0, i)
if (str.endsWith(prefix)) return prefix
}
@@ -240,3 +241,119 @@ export const extractSearchReplaceBlocks = (str: string) => {
})
}
}
// could simplify this - this assumes we can never add a tag without committing it to the user's screen, but that's not true
export const extractReasoningOnTextWrapper = (onText: OnText, thinkTags: [string, string]): OnText => {
let latestAddIdx = 0 // exclusive index in fullText_
let foundTag1 = false
let foundTag2 = false
let fullTextSoFar = ''
let fullReasoningSoFar = ''
let onText_ = onText
onText = (params) => {
onText_(params)
}
const newOnText: OnText = ({ fullText: fullText_ }) => {
// until found the first think tag, keep adding to fullText
if (!foundTag1) {
const endsWithTag1 = endsWithAnyPrefixOf(fullText_, thinkTags[0])
if (endsWithTag1) {
// console.log('endswith1', { fullTextSoFar, fullReasoningSoFar, fullText_ })
// wait until we get the full tag or know more
return
}
// if found the first tag
const tag1Index = fullText_.indexOf(thinkTags[0])
if (tag1Index !== -1) {
// console.log('tag1Index !==1', { tag1Index, fullTextSoFar, fullReasoningSoFar, thinkTags, fullText_ })
foundTag1 = true
// Add text before the tag to fullTextSoFar
fullTextSoFar += fullText_.substring(0, tag1Index)
// Update latestAddIdx to after the first tag
latestAddIdx = tag1Index + thinkTags[0].length
onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar })
return
}
// console.log('adding to text A', { fullTextSoFar, fullReasoningSoFar })
// add the text to fullText
fullTextSoFar = fullText_
latestAddIdx = fullText_.length
onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar })
return
}
// at this point, we found <tag1>
// until found the second think tag, keep adding to fullReasoning
if (!foundTag2) {
const endsWithTag2 = endsWithAnyPrefixOf(fullText_, thinkTags[1])
if (endsWithTag2) {
// console.log('endsWith2', { fullTextSoFar, fullReasoningSoFar })
// wait until we get the full tag or know more
return
}
// if found the second tag
const tag2Index = fullText_.indexOf(thinkTags[1], latestAddIdx)
if (tag2Index !== -1) {
// console.log('tag2Index !== -1', { fullTextSoFar, fullReasoningSoFar })
foundTag2 = true
// Add everything between first and second tag to reasoning
fullReasoningSoFar += fullText_.substring(latestAddIdx, tag2Index)
// Update latestAddIdx to after the second tag
latestAddIdx = tag2Index + thinkTags[1].length
onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar })
return
}
// add the text to fullReasoning (content after first tag but before second tag)
// console.log('adding to text B', { fullTextSoFar, fullReasoningSoFar })
// If we have more text than we've processed, add it to reasoning
if (fullText_.length > latestAddIdx) {
fullReasoningSoFar += fullText_.substring(latestAddIdx)
latestAddIdx = fullText_.length
}
onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar })
return
}
// at this point, we found <tag2> - content after the second tag is normal text
// console.log('adding to text C', { fullTextSoFar, fullReasoningSoFar })
// Add any new text after the closing tag to fullTextSoFar
if (fullText_.length > latestAddIdx) {
fullTextSoFar += fullText_.substring(latestAddIdx)
latestAddIdx = fullText_.length
}
onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar })
}
return newOnText
}
export const extractReasoningOnFinalMessage = (fullText_: string, thinkTags: [string, string]): { fullText: string, fullReasoning: string } => {
const tag1Idx = fullText_.indexOf(thinkTags[0])
const tag2Idx = fullText_.indexOf(thinkTags[1])
if (tag1Idx === -1) return { fullText: fullText_, fullReasoning: '' } // never started reasoning
if (tag2Idx === -1) return { fullText: '', fullReasoning: fullText_ } // never stopped reasoning
const fullReasoning = fullText_.substring(tag1Idx + thinkTags[0].length, tag2Idx)
const fullText = fullText_.substring(0, tag1Idx) + fullText_.substring(tag2Idx + thinkTags[1].length, Infinity)
return { fullText, fullReasoning }
}
@@ -6,7 +6,7 @@
import { URI } from '../../../../../base/common/uri.js';
import { filenameToVscodeLanguage } from '../helpers/detectLanguage.js';
import { CodeSelection, StagingSelectionItem, FileSelection } from '../chatThreadService.js';
import { CodeSelection, StagingSelectionItem, FileSelection } from '../../common/chatThreadService.js';
import { IModelService } from '../../../../../editor/common/services/model.js';
import { os } from '../helpers/systemInfo.js';
import { IVoidFileService } from '../../common/voidFileService.js';
@@ -299,7 +299,7 @@ For example, if the user is asking you to "make this variable a better name", ma
export const aiRegex_computeReplacementsForFile_userMessage = async ({ searchClause, replaceClause, fileURI, voidFileService }: { searchClause: string, replaceClause: string, fileURI: URI, modelService: IModelService, voidFileService: IVoidFileService }) => {
// we may want to do this in batches
const fileSelection: FileSelection = { type: 'File', fileURI, selectionStr: null, range: null }
const fileSelection: FileSelection = { type: 'File', fileURI, selectionStr: null, range: null, state: { isOpened: false } }
const file = await stringifyFileSelections([fileSelection], voidFileService)
@@ -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';
@@ -6,10 +6,14 @@
import React, { JSX } from 'react'
import { marked, MarkedToken, Token } from 'marked'
import { BlockCode } from './BlockCode.js'
import { ChatMessageLocation, } from '../../../aiRegexService.js'
import { nameToVscodeLanguage } from '../../../helpers/detectLanguage.js'
import { ApplyBlockHoverButtons } from './ApplyBlockHoverButtons.js'
export type ChatMessageLocation = {
threadId: string;
messageIdx: number;
}
type ApplyBoxLocation = ChatMessageLocation & { tokenIdx: string }
@@ -33,7 +37,7 @@ export const CodeSpan = ({ children, className }: { children: React.ReactNode, c
</code>
}
const RenderToken = ({ token, nested, noSpace, chatMessageLocation, tokenIdx }: { token: Token | string, nested?: boolean, noSpace?: boolean, chatMessageLocation?: ChatMessageLocation, tokenIdx: string }): JSX.Element => {
const RenderToken = ({ token, nested, noSpace, chatMessageLocationForApply, tokenIdx }: { token: Token | string, nested?: boolean, noSpace?: boolean, chatMessageLocationForApply?: ChatMessageLocation, tokenIdx: string }): JSX.Element => {
// deal with built-in tokens first (assume marked token)
@@ -45,9 +49,9 @@ const RenderToken = ({ token, nested, noSpace, chatMessageLocation, tokenIdx }:
if (t.type === "code") {
const applyBoxId = chatMessageLocation ? getApplyBoxId({
threadId: chatMessageLocation.threadId,
messageIdx: chatMessageLocation.messageIdx,
const applyBoxId = chatMessageLocationForApply ? getApplyBoxId({
threadId: chatMessageLocationForApply.threadId,
messageIdx: chatMessageLocationForApply.messageIdx,
tokenIdx: tokenIdx,
}) : null
@@ -131,7 +135,7 @@ const RenderToken = ({ token, nested, noSpace, chatMessageLocation, tokenIdx }:
<input type="checkbox" checked={item.checked} readOnly className="mr-2 form-checkbox" />
)}
<span className="ml-1">
<ChatMarkdownRender chatMessageLocation={chatMessageLocation} string={item.text} nested={true} />
<ChatMarkdownRender chatMessageLocationForApply={chatMessageLocationForApply} string={item.text} nested={true} />
</span>
</li>
))}
@@ -243,12 +247,12 @@ const RenderToken = ({ token, nested, noSpace, chatMessageLocation, tokenIdx }:
)
}
export const ChatMarkdownRender = ({ string, nested = false, noSpace, chatMessageLocation }: { string: string, nested?: boolean, noSpace?: boolean, chatMessageLocation?: ChatMessageLocation }) => {
export const ChatMarkdownRender = ({ string, nested = false, noSpace, chatMessageLocationForApply }: { string: string, nested?: boolean, noSpace?: boolean, chatMessageLocationForApply?: ChatMessageLocation }) => {
const tokens = marked.lexer(string); // https://marked.js.org/using_pro#renderer
return (
<>
{tokens.map((token, index) => (
<RenderToken key={index} token={token} nested={nested} noSpace={noSpace} chatMessageLocation={chatMessageLocation} tokenIdx={index + ''} />
<RenderToken key={index} token={token} nested={nested} noSpace={noSpace} chatMessageLocationForApply={chatMessageLocationForApply} tokenIdx={index + ''} />
))}
</>
)
@@ -7,10 +7,10 @@ import React, { ButtonHTMLAttributes, FormEvent, FormHTMLAttributes, Fragment, K
import { useAccessor, useSidebarState, useChatThreadsState, useChatThreadsStreamState, useUriState, useSettingsState } from '../util/services.js';
import { ChatMessage, StagingSelectionItem, ToolMessage } from '../../../chatThreadService.js';
import { ChatMessage, StagingSelectionItem, ToolMessage } from '../../../../common/chatThreadService.js';
import { BlockCode } from '../markdown/BlockCode.js';
import { ChatMarkdownRender } from '../markdown/ChatMarkdownRender.js';
import { ChatMarkdownRender, ChatMessageLocation } from '../markdown/ChatMarkdownRender.js';
import { URI } from '../../../../../../../base/common/uri.js';
import { IDisposable } from '../../../../../../../base/common/lifecycle.js';
import { ErrorDisplay } from './ErrorDisplay.js';
@@ -24,7 +24,7 @@ import { VOID_OPEN_SETTINGS_ACTION_ID } from '../../../voidSettingsPane.js';
import { ChevronRight, Pencil, X } from 'lucide-react';
import { FeatureName, isFeatureNameDisabled } from '../../../../../../../workbench/contrib/void/common/voidSettingsTypes.js';
import { WarningBox } from '../void-settings-tsx/WarningBox.js';
import { ChatMessageLocation } from '../../../aiRegexService.js';
import { ToolCallReturnType, ToolName } from '../../../../common/toolsService.js';
@@ -139,6 +139,9 @@ export const IconLoading = ({ className = '' }: { className?: string }) => {
}
const getChatBubbleId = (threadId: string, messageIdx: number) => `${threadId}-${messageIdx}`;
interface VoidChatAreaProps {
// Required
children: React.ReactNode; // This will be the input component
@@ -195,6 +198,7 @@ export const VoidChatArea: React.FC<VoidChatAreaProps> = ({
rounded-md
bg-vscode-input-bg
outline-1 outline-void-border-3 focus-within:outline-void-border-1 hover:outline-void-border-1
max-h-[80vh] overflow-y-auto
${className}
`}
onClick={(e) => {
@@ -373,12 +377,6 @@ export const SelectedFiles = (
| { type: 'staging', selections: StagingSelectionItem[]; setSelections: ((newSelections: StagingSelectionItem[]) => void), showProspectiveSelections?: boolean }
) => {
// index -> isOpened
const [selectionIsOpened, setSelectionIsOpened] = useState<(boolean)[]>(selections?.map(() => false) ?? [])
// state for tracking hover on clear all button
const [isClearHovered, setIsClearHovered] = useState(false)
const accessor = useAccessor()
const commandService = accessor.get('ICommandService')
@@ -406,6 +404,7 @@ export const SelectedFiles = (
fileURI: uri,
selectionStr: null,
range: null,
state: { isOpened: false },
}))
}
@@ -416,11 +415,11 @@ export const SelectedFiles = (
}
return (
<div className='flex items-center flex-wrap text-left relative gap-1'>
<div className='flex items-center flex-wrap text-left relative gap-x-0.5 gap-y-1'>
{allSelections.map((selection, i) => {
const isThisSelectionOpened = !!(selection.selectionStr && selectionIsOpened[i])
const isThisSelectionOpened = (!!selection.selectionStr && selection.state.isOpened) //!!(selection.selectionStr && selectionIsOpened[i])
const isThisSelectionAFile = selection.selectionStr === null
const isThisSelectionProspective = i > selections.length - 1
@@ -442,12 +441,18 @@ export const SelectedFiles = (
select-none
${isThisSelectionProspective ? 'bg-void-bg-1 text-void-fg-3 opacity-80' : 'bg-void-bg-3 hover:brightness-95 text-void-fg-1'}
text-xs text-nowrap
border rounded-sm ${isClearHovered && !isThisSelectionProspective ? 'border-void-border-1' : 'border-void-border-2'} hover:border-void-border-1
border rounded-sm ${isThisSelectionProspective
? 'border-void-border-2'
: isThisSelectionOpened
? 'border-void-border-1 ring-1 ring-[#007FD4]'
: 'border-void-border-1'
}
hover:border-void-border-1
transition-all duration-150
`}
onClick={() => {
if (type !== 'staging') return; // (never)
if (isThisSelectionProspective) { // add prospective selection to selections
if (type !== 'staging') return; // (never)
setSelections([...selections, selection])
} else if (isThisSelectionAFile) { // open files
commandService.executeCommand('vscode.open', selection.fileURI, {
@@ -455,11 +460,22 @@ export const SelectedFiles = (
// preserveFocus: false,
});
} else { // show text
setSelectionIsOpened(s => {
const newS = [...s]
newS[i] = !newS[i]
return newS
});
const selection = selections[i]
const newSelection = { ...selection, state: { isOpened: !selection.state.isOpened } }
const newSelections = [
...selections.slice(0, i),
newSelection,
...selections.slice(i + 1)
]
setSelections(newSelections)
// setSelectionIsOpened(s => {
// const newS = [...s]
// newS[i] = !newS[i]
// return newS
// });
}
}}
>
@@ -475,7 +491,6 @@ export const SelectedFiles = (
e.stopPropagation(); // don't open/close selection
if (type !== 'staging') return;
setSelections([...selections.slice(0, i), ...selections.slice(i + 1)])
setSelectionIsOpened(o => [...o.slice(0, i), ...o.slice(i + 1)])
}}
size={10}
/>
@@ -486,7 +501,10 @@ export const SelectedFiles = (
{/* code box */}
{isThisSelectionOpened ?
<div
className='w-full px-1 rounded-sm border-vscode-editor-border'
className={`
w-full px-1 rounded-sm border-vscode-editor-border
${isThisSelectionOpened ? 'ring-1 ring-[#007FD4]' : ''}
`}
onClick={(e) => {
e.stopPropagation(); // don't focus input box
}}
@@ -696,9 +714,14 @@ const toolResultToComponent: ToolResultToComponent = {
type ChatBubbleMode = 'display' | 'edit'
const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatMessage, messageIdx?: number, isLoading?: boolean, }) => {
const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatMessage, messageIdx: number, isLoading?: boolean, }) => {
const role = chatMessage.role
// Only show reasoning dropdown when there's actual content
const reasoningStr = (chatMessage.role === 'assistant' && chatMessage.reasoning?.trim()) || null
const hasReasoning = !!reasoningStr
const [isReasoningOpen, setIsReasoningOpen] = useState(false)
const accessor = useAccessor()
const chatThreadsService = accessor.get('IChatThreadService')
@@ -733,7 +756,6 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatM
const shouldInitialize = _justEnabledEdit.current || _mustInitialize.current
if (canInitialize && shouldInitialize) {
setStagingSelections(chatMessage.selections || [])
if (textAreaFnsRef.current)
textAreaFnsRef.current.setValue(chatMessage.displayContent || '')
@@ -842,10 +864,42 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatM
const chatMessageLocation: ChatMessageLocation = {
threadId: thread.id,
messageIdx: messageIdx!,
messageIdx: messageIdx,
}
chatbubbleContents = <ChatMarkdownRender string={chatMessage.displayContent ?? ''} chatMessageLocation={chatMessageLocation} />
const reasoningDropdown = hasReasoning ? (
<div className="mx-4 select-none mt-2">
<div className="border border-void-border-3 rounded px-1 py-0.5 bg-void-bg-tool">
<div
className="flex items-center min-h-[24px] cursor-pointer hover:brightness-125 transition-all duration-150"
onClick={() => setIsReasoningOpen(!isReasoningOpen)}
>
<ChevronRight
className={`text-void-fg-3 mr-0.5 h-5 w-5 flex-shrink-0 transition-transform duration-100 ease-[cubic-bezier(0.4,0,0.2,1)] ${isReasoningOpen ? 'rotate-90' : ''}`}
/>
<div className="flex items-center flex-wrap gap-x-2 gap-y-0.5">
<span className="text-void-fg-3">Reasoning</span>
<span className="text-void-fg-4 text-xs italic">Model's step-by-step thinking</span>
</div>
</div>
<div
className={`mt-1 overflow-hidden transition-all duration-200 ease-in-out ${isReasoningOpen ? 'max-h-[500px] opacity-100' : 'max-h-0 opacity-0'}`}
>
<div className="text-void-fg-2 p-2 bg-void-bg-1 rounded">
<ChatMarkdownRender string={reasoningStr} chatMessageLocationForApply={chatMessageLocation} />
</div>
</div>
</div>
</div>
) : null
chatbubbleContents = (<>
{/* Reasoning dropdown (conditional) */}
{reasoningDropdown}
{/* Main content */}
<ChatMarkdownRender string={chatMessage.content ?? ''} chatMessageLocationForApply={chatMessageLocation} />
</>)
}
else if (role === 'tool') {
@@ -939,14 +993,15 @@ export const SidebarChat = () => {
const currentThread = chatThreadsService.getCurrentThread()
const previousMessages = currentThread?.messages ?? []
const selections = chatThreadsService.getCurrentThread().state.stagingSelections
const setSelections = (s: StagingSelectionItem[]) => { chatThreadsService.setCurrentThreadStagingSelections(s) }
const selections = currentThread.state.stagingSelections
const setSelections = (s: StagingSelectionItem[]) => { chatThreadsService.setCurrentThreadState({ stagingSelections: s }) }
// stream state
const currThreadStreamState = useChatThreadsStreamState(chatThreadsState.currentThreadId)
const isStreaming = !!currThreadStreamState?.streamingToken
const latestError = currThreadStreamState?.error
const messageSoFar = currThreadStreamState?.messageSoFar
const reasoningSoFar = currThreadStreamState?.reasoningSoFar
// ----- SIDEBAR CHAT state (local) -----
@@ -995,13 +1050,27 @@ export const SidebarChat = () => {
}, [isHistoryOpen, currentThread.id])
const prevMessagesHTML = useMemo(() => {
const pastMessagesHTML = useMemo(() => {
return previousMessages.map((message, i) =>
<ChatBubble key={i} chatMessage={message} messageIdx={i} />
<ChatBubble key={getChatBubbleId(currentThread.id, i)} chatMessage={message} messageIdx={i} />
)
}, [previousMessages])
const streamingChatIdx = pastMessagesHTML.length
const currStreamingMessageHTML = !!(reasoningSoFar || messageSoFar || isStreaming) ?
<ChatBubble key={getChatBubbleId(currentThread.id, streamingChatIdx)}
messageIdx={streamingChatIdx} chatMessage={{
role: 'assistant',
content: messageSoFar ?? null,
reasoning: reasoningSoFar ?? null,
}}
isLoading={isStreaming}
/> : null
const allMessagesHTML = [...pastMessagesHTML, currStreamingMessageHTML]
const threadSelector = <div ref={historyRef}
className={`w-full h-auto ${isHistoryOpen ? '' : 'hidden'} ring-2 ring-widget-shadow ring-inset z-10`}
>
@@ -1019,20 +1088,17 @@ export const SidebarChat = () => {
overflow-x-hidden
overflow-y-auto
py-4
${prevMessagesHTML.length === 0 && !messageSoFar ? 'hidden' : ''}
${pastMessagesHTML.length === 0 && !messageSoFar ? 'hidden' : ''}
`}
style={{ maxHeight: sidebarDimensions.height - historyDimensions.height - chatAreaDimensions.height - 36 }} // the height of the previousMessages is determined by all other heights
>
{/* previous messages */}
{prevMessagesHTML}
{/* message stream */}
<ChatBubble chatMessage={{ role: 'assistant', content: messageSoFar ?? '', displayContent: messageSoFar || null }} isLoading={isStreaming} />
{allMessagesHTML}
{/* error message */}
{latestError === undefined ? null :
<div className='px-2'>
<div className='px-2 my-1'>
<ErrorDisplay
message={latestError.message}
fullError={latestError.fullError}
@@ -1062,7 +1128,7 @@ export const SidebarChat = () => {
isStreaming={isStreaming}
isDisabled={isDisabled}
showSelections={true}
showProspectiveSelections={prevMessagesHTML.length === 0}
showProspectiveSelections={pastMessagesHTML.length === 0}
selections={selections}
setSelections={setSelections}
onClickAnywhere={() => { textAreaRef.current?.focus() }}
@@ -688,7 +688,7 @@ export const VoidCodeEditor = ({ initValue, language, maxHeight, showScrollbars
verticalScrollbarSize: 0,
horizontal: 'auto',
horizontalScrollbarSize: 8,
// ignoreHorizontalScrollbarInContentHeight: true,
ignoreHorizontalScrollbarInContentHeight: true,
},
},
@@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------*/
import React, { useState, useEffect, useCallback } from 'react'
import { ThreadStreamState, ThreadsState } from '../../../chatThreadService.js'
import { ThreadStreamState,IChatThreadService, ThreadsState } from '../../../../common/chatThreadService.js'
import { RefreshableProviderName, SettingsOfProvider } from '../../../../../../../workbench/contrib/void/common/voidSettingsTypes.js'
import { IDisposable } from '../../../../../../../base/common/lifecycle.js'
import { VoidSidebarState } from '../../../sidebarStateService.js'
@@ -29,7 +29,6 @@ import { IEditCodeService, URIStreamState } from '../../../editCodeService.js';
import { IVoidUriStateService } from '../../../voidUriStateService.js';
import { IQuickEditStateService } from '../../../quickEditStateService.js';
import { ISidebarStateService } from '../../../sidebarStateService.js';
import { IChatThreadService } from '../../../chatThreadService.js';
import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'
import { ICodeEditorService } from '../../../../../../../editor/browser/services/codeEditorService.js'
import { ICommandService } from '../../../../../../../platform/commands/common/commands.js'
@@ -5,7 +5,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { InputBox } from '../../../../../../../base/browser/ui/inputbox/inputBox.js'
import { ProviderName, SettingName, displayInfoOfSettingName, providerNames, VoidModelInfo, globalSettingNames, customSettingNamesOfProvider, RefreshableProviderName, refreshableProviderNames, displayInfoOfProviderName, defaultProviderSettings, nonlocalProviderNames, localProviderNames, GlobalSettingName, featureNames, displayInfoOfFeatureName, isProviderNameDisabled } from '../../../../common/voidSettingsTypes.js'
import { ProviderName, SettingName, displayInfoOfSettingName, providerNames, VoidModelInfo, globalSettingNames, customSettingNamesOfProvider, RefreshableProviderName, refreshableProviderNames, displayInfoOfProviderName, defaultProviderSettings, nonlocalProviderNames, localProviderNames, GlobalSettingName, featureNames, displayInfoOfFeatureName, isProviderNameDisabled, FeatureName } from '../../../../common/voidSettingsTypes.js'
import ErrorBoundary from '../sidebar-tsx/ErrorBoundary.js'
import { VoidButton, VoidCheckBox, VoidCustomDropdownBox, VoidInputBox, VoidInputBox2, VoidSwitch } from '../util/inputs.js'
import { useAccessor, useIsDark, useRefreshModelListener, useRefreshModelState, useSettingsState } from '../util/services.js'
@@ -368,15 +368,15 @@ export const AutoRefreshToggle = () => {
// right now this is just `enabled_autoRefreshModels`
const enabled = voidSettingsState.globalSettings[settingName]
return <SubtleButton
onClick={() => {
voidSettingsService.setGlobalSetting(settingName, !enabled)
metricsService.capture('Click', { action: 'Autorefresh Toggle', settingName, enabled: !enabled })
}}
text={`Automatically detect local providers and models (${refreshableProviderNames.map(providerName => displayInfoOfProviderName(providerName).title).join(', ')}).`}
icon={enabled ? <Check className='stroke-green-500 size-3' /> : <X className='stroke-red-500 size-3' />}
disabled={false}
/>
return <SubtleButton
onClick={() => {
voidSettingsService.setGlobalSetting(settingName, !enabled)
metricsService.capture('Click', { action: 'Autorefresh Toggle', settingName, enabled: !enabled })
}}
text={`Automatically detect local providers and models (${refreshableProviderNames.map(providerName => displayInfoOfProviderName(providerName).title).join(', ')}).`}
icon={enabled ? <Check className='stroke-green-500 size-3' /> : <X className='stroke-red-500 size-3' />}
disabled={false}
/>
}
@@ -401,7 +401,7 @@ export const FeaturesTab = () => {
<ErrorBoundary>
<AutoRefreshToggle />
<RefreshableModels />
<div className='py-2'/>
<div className='py-2' />
<ModelDump />
<AddModelMenuFull />
</ErrorBoundary>
@@ -415,7 +415,7 @@ export const FeaturesTab = () => {
<div className='pl-4 opacity-50'>
<span className={`text-sm mb-2`}><ChatMarkdownRender noSpace string={`1. Download [Ollama](https://ollama.com/download).`} /></span>
<span className={`text-sm mb-2`}><ChatMarkdownRender noSpace string={`2. Open your terminal.`} /></span>
<span className={`text-sm mb-2 select-text`}><ChatMarkdownRender noSpace string={`3. Run \`ollama run llama3.1\`. This installs Meta's llama3.1 model which is best for chat and inline edits. Requires 5GB of memory.`} /></span>
<span className={`text-sm mb-2 select-text`}><ChatMarkdownRender noSpace string={`3. Run \`ollama run llama3.1:8b\`. This installs Meta's llama3.1 model which is best for chat and inline edits. Requires 5GB of memory.`} /></span>
<span className={`text-sm mb-2 select-text`}><ChatMarkdownRender noSpace string={`4. Run \`ollama run qwen2.5-coder:1.5b\`. This installs a faster autocomplete model. Requires 1GB of memory.`} /></span>
<span className={`text-sm mb-2`}><ChatMarkdownRender noSpace string={`Void automatically detects locally running models and enables them.`} /></span>
{/* TODO we should create UI for downloading models without user going into terminal */}
@@ -437,12 +437,13 @@ export const FeaturesTab = () => {
<h2 className={`text-3xl mb-2 mt-12`}>Feature Options</h2>
<ErrorBoundary>
{featureNames.map(featureName =>
<div key={featureName}
className='mb-2'
>
<h4 className={`text-void-fg-3`}>{displayInfoOfFeatureName(featureName)}</h4>
<ModelDropdown featureName={featureName} />
</div>
(['Ctrl+L', 'Ctrl+K'] as FeatureName[]).includes(featureName) ? null :
<div key={featureName}
className='mb-2'
>
<h4 className={`text-void-fg-3`}>{displayInfoOfFeatureName(featureName)}</h4>
<ModelDropdown featureName={featureName} />
</div>
)}
</ErrorBoundary>
@@ -7,8 +7,8 @@ import { Emitter, Event } from '../../../../base/common/event.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { ILLMMessageService } from '../common/llmMessageService.js';
import { ServiceSendLLMMessageParams } from '../common/llmMessageTypes.js';
// import { ILLMMessageService } from '../common/llmMessageService.js';
// import { ServiceSendLLMMessageParams } from '../common/llmMessageTypes.js';
@@ -24,22 +24,22 @@ class SearchReplaceService extends Disposable implements ISearchReplaceService {
readonly onDidChangeState: Event<void> = this._onDidChangeState.event;
constructor(
@ILLMMessageService private readonly llmMessageService: ILLMMessageService,
// @ILLMMessageService private readonly llmMessageService: ILLMMessageService,
) {
super()
}
send(params: Omit<ServiceSendLLMMessageParams, 'onText'> & { onText: (p: { newText: string, fullText: string }) => { retry: boolean } }) {
this.llmMessageService.sendLLMMessage({
...params as ServiceSendLLMMessageParams,
onText: (p) => {
const { retry } = params.onText(p)
if (retry) {
// send(params: ServiceSendLLMMessageParams & { onText: (p: { newText: string, fullText: string }) => { retry: boolean } }) {
// this.llmMessageService.sendLLMMessage({
// ...params as ServiceSendLLMMessageParams,
// onText: (p) => {
// const { retry } = params.onText(p)
// if (retry) {
}
}
})
}
// }
// }
// })
// }
}
@@ -11,7 +11,7 @@ import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js
import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js';
import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
import { StagingSelectionItem, IChatThreadService } from './chatThreadService.js';
import { StagingSelectionItem, IChatThreadService } from '../common/chatThreadService.js';
import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';
import { IRange } from '../../../../editor/common/core/range.js';
@@ -124,11 +124,13 @@ registerAction2(class extends Action2 {
fileURI: model.uri,
selectionStr: null,
range: null,
state: { isOpened: false, }
} : {
type: 'Selection',
fileURI: model.uri,
selectionStr: selectionStr,
range: selectionRange,
state: { isOpened: true, }
}
// update the staging selections
@@ -141,13 +143,16 @@ registerAction2(class extends Action2 {
let setSelections = (s: StagingSelectionItem[]) => { }
if (focusedMessageIdx === undefined) {
selections = chatThreadService.getCurrentThreadStagingSelections()
setSelections = (s: StagingSelectionItem[]) => chatThreadService.setCurrentThreadStagingSelections(s)
selections = chatThreadService.getCurrentThreadState().stagingSelections
setSelections = (s: StagingSelectionItem[]) => chatThreadService.setCurrentThreadState({ stagingSelections: s })
} else {
selections = chatThreadService.getCurrentMessageState(focusedMessageIdx).stagingSelections
setSelections = (s) => chatThreadService.setCurrentMessageState(focusedMessageIdx, { stagingSelections: s })
}
// close all selections besides the new one
selections = selections.map(s => ({ ...s, state: { ...s.state, isOpened: false } }))
// if matches with existing selection, overwrite (since text may change)
const matchingStagingEltIdx = findMatchingStagingIndex(selections, selection)
if (matchingStagingEltIdx !== undefined && matchingStagingEltIdx !== -1) {
@@ -15,8 +15,6 @@ import './sidebarStateService.js'
// register quick edit (Ctrl+K)
import './quickEditActions.js'
// register Thread History
import './chatThreadService.js'
// register Autocomplete
import './autocompleteService.js'
@@ -56,3 +54,7 @@ import '../common/voidUpdateService.js'
// tools
import '../common/toolsService.js'
// register Thread History
import '../common/chatThreadService.js'
@@ -11,12 +11,12 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo
import { URI } from '../../../../base/common/uri.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import { IRange } from '../../../../editor/common/core/range.js';
import { ILLMMessageService } from '../common/llmMessageService.js';
import { chat_userMessageContent, chat_systemMessage, chat_userMessageContentWithAllFilesToo as chat_userMessageContentWithAllFiles, chat_selectionsString } from './prompt/prompts.js';
import { InternalToolInfo, IToolsService, ToolCallReturnType, ToolFns, ToolName, voidTools } from '../common/toolsService.js';
import { toLLMChatMessage } from '../common/llmMessageTypes.js';
import { ILLMMessageService } from './llmMessageService.js';
import { chat_userMessageContent, chat_systemMessage, chat_userMessageContentWithAllFilesToo as chat_userMessageContentWithAllFiles, chat_selectionsString } from '../browser/prompt/prompts.js';
import { InternalToolInfo, IToolsService, ToolCallReturnType, ToolFns, ToolName, voidTools } from './toolsService.js';
import { toLLMChatMessage } from './llmMessageTypes.js';
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
import { IVoidFileService } from '../common/voidFileService.js';
import { IVoidFileService } from './voidFileService.js';
import { generateUuid } from '../../../../base/common/uuid.js';
@@ -36,6 +36,9 @@ export type CodeSelection = {
fileURI: URI;
selectionStr: string;
range: IRange;
state: {
isOpened: boolean;
};
}
export type FileSelection = {
@@ -43,6 +46,9 @@ export type FileSelection = {
fileURI: URI;
selectionStr: null;
range: null;
state: {
isOpened: boolean;
};
}
export type StagingSelectionItem = CodeSelection | FileSelection
@@ -60,11 +66,7 @@ export type ToolMessage<T extends ToolName> = {
// WARNING: changing this format is a big deal!!!!!! need to migrate old format to new format on users' computers so people don't get errors.
export type ChatMessage =
| {
role: 'system';
content: string;
displayContent?: undefined;
} | {
{
role: 'user';
content: string | null; // content displayed to the LLM on future calls - allowed to be '', will be replaced with (empty)
displayContent: string | null; // content displayed to user - allowed to be '', will be ignored
@@ -76,7 +78,7 @@ export type ChatMessage =
} | {
role: 'assistant';
content: string | null; // content received from LLM - allowed to be '', will be replaced with (empty)
displayContent: string | null; // content displayed to user (this is the same as content for now) - allowed to be '', will be ignored
reasoning: string | null; // reasoning from the LLM, used for step-by-step thinking
}
| ToolMessage<ToolName>
@@ -98,14 +100,18 @@ export type ChatThreads = {
state: {
stagingSelections: StagingSelectionItem[];
focusedMessageIdx: number | undefined; // index of the message that is being edited (undefined if none)
isCheckedOfSelectionId: { [selectionId: string]: boolean };
isCheckedOfSelectionId: { [selectionId: string]: boolean }; // TODO
}
};
}
type ThreadType = ChatThreads[string]
const defaultThreadState: ThreadType['state'] = { stagingSelections: [], focusedMessageIdx: undefined, isCheckedOfSelectionId: {} }
const defaultThreadState: ThreadType['state'] = {
stagingSelections: [],
focusedMessageIdx: undefined,
isCheckedOfSelectionId: {}
}
export type ThreadsState = {
allThreads: ChatThreads;
@@ -116,6 +122,7 @@ export type ThreadStreamState = {
[threadId: string]: undefined | {
error?: { message: string, fullError: Error | null, };
messageSoFar?: string;
reasoningSoFar?: string;
streamingToken?: string;
}
}
@@ -128,19 +135,12 @@ const newThreadObject = () => {
createdAt: now,
lastModified: now,
messages: [],
state: {
stagingSelections: [],
focusedMessageIdx: undefined,
isCheckedOfSelectionId: {}
},
state: defaultThreadState,
} satisfies ChatThreads[string]
}
const THREAD_VERSION_KEY = 'void.chatThreadVersion'
const LATEST_THREAD_VERSION = 'v2'
const THREAD_STORAGE_KEY = 'void.chatThreadStorage'
export const THREAD_STORAGE_KEY = 'void.chatThreadStorage'
type ChatMode = 'agent' | 'chat'
@@ -166,8 +166,8 @@ export interface IChatThreadService {
// exposed getters/setters
getCurrentMessageState: (messageIdx: number) => UserMessageState
setCurrentMessageState: (messageIdx: number, newState: Partial<UserMessageState>) => void
getCurrentThreadStagingSelections: () => StagingSelectionItem[]
setCurrentThreadStagingSelections: (stagingSelections: StagingSelectionItem[]) => void
getCurrentThreadState: () => ThreadType['state']
setCurrentThreadState: (newState: Partial<ThreadType['state']>) => void
// call to edit a message
@@ -203,18 +203,11 @@ class ChatThreadService extends Disposable implements IChatThreadService {
@IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService,
) {
super()
this.state = { allThreads: {}, currentThreadId: null as unknown as string } // default state
const oldVersionNum = this._storageService.get(THREAD_VERSION_KEY, StorageScope.APPLICATION)
const readThreads = this._readAllThreads() || {}
const readThreads = this._readAllThreads()
const updatedThreads = this._updatedThreadsToVersion(readThreads, oldVersionNum)
if (updatedThreads !== null) {
this._storeAllThreads(updatedThreads)
}
const allThreads = updatedThreads ?? readThreads
const allThreads = readThreads
this.state = {
allThreads: allThreads,
currentThreadId: null as unknown as string, // gets set in startNewThread()
@@ -222,9 +215,6 @@ class ChatThreadService extends Disposable implements IChatThreadService {
// always be in a thread
this.openNewThread()
this._storageService.store(THREAD_VERSION_KEY, LATEST_THREAD_VERSION, StorageScope.APPLICATION, StorageTarget.USER)
}
// !!! this is important for properly restoring URIs from storage
@@ -237,10 +227,10 @@ class ChatThreadService extends Disposable implements IChatThreadService {
});
}
private _readAllThreads(): ChatThreads {
private _readAllThreads(): ChatThreads | null {
const threadsStr = this._storageService.get(THREAD_STORAGE_KEY, StorageScope.APPLICATION);
if (!threadsStr) {
return {};
return null
}
return this._convertThreadDataFromStorage(threadsStr);
}
@@ -256,48 +246,6 @@ class ChatThreadService extends Disposable implements IChatThreadService {
}
// returns if should update
private _updatedThreadsToVersion(oldThreadsObject: any, oldVersion: string | undefined): ChatThreads | null {
if (!oldVersion) {
// unknown, just reset chat?
return null
}
/** v1 -> v2
- threads.state.currentStagingSelections: CodeStagingSelection[] | null;
+ thread[threadIdx].state
+ message.state
+ chatMessage.staging: StagingInfo | null
*/
else if (oldVersion === 'v1') {
const threads = oldThreadsObject as Omit<ChatThreads, 'staging' | 'focusedMessageIdx'>
// update the threads
for (const thread of Object.values(threads)) {
if (!thread.state) {
thread.state = defaultThreadState
}
for (const chatMessage of Object.values(thread.messages)) {
if (chatMessage.role === 'user' && !chatMessage.state) {
chatMessage.state = defaultMessageState
}
}
}
// push the update
return threads
}
else if (oldVersion === 'v2') {
return null
}
// up to date
return null
}
// this should be the only place this.state = ... appears besides constructor
private _setState(state: Partial<ThreadsState>, affectsCurrent: boolean) {
this.state = {
@@ -330,10 +278,10 @@ class ChatThreadService extends Disposable implements IChatThreadService {
// ---------- streaming ----------
private _finishStreamingTextMessage = (threadId: string, content: string, error?: { message: string, fullError: Error | null }) => {
private _finishStreamingTextMessage = (threadId: string, options: { content: string, reasoning?: string }, error?: { message: string, fullError: Error | null }) => {
// add assistant's message to chat history, and clear selection
this._addMessageToThread(threadId, { role: 'assistant', content, displayContent: content || null })
this._setStreamState(threadId, { messageSoFar: undefined, streamingToken: undefined, error })
this._addMessageToThread(threadId, { role: 'assistant', content: options.content, reasoning: options.reasoning || null })
this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined, error })
}
@@ -348,8 +296,8 @@ class ChatThreadService extends Disposable implements IChatThreadService {
}
// get prev and curr selections before clearing the message
const prevSelns = this._getSelectionsUpToMessageIdx(messageIdx)
const currSelns = thread.messages[messageIdx].selections || []
const prevSelns = this._getSelectionsUpToMessageIdx(messageIdx) // selections for previous messages
const currSelns = thread.messages[messageIdx].state.stagingSelections || [] // staging selections for the edited message
// clear messages up to the index
const slicedMessages = thread.messages.slice(0, messageIdx)
@@ -431,17 +379,17 @@ class ChatThreadService extends Disposable implements IChatThreadService {
tools: tools,
onText: ({ fullText }) => {
this._setStreamState(threadId, { messageSoFar: fullText })
onText: ({ fullText, fullReasoning }) => {
this._setStreamState(threadId, { messageSoFar: fullText, reasoningSoFar: fullReasoning })
},
onFinalMessage: async ({ fullText, toolCalls }) => {
onFinalMessage: async ({ fullText, toolCalls, fullReasoning }) => {
if ((toolCalls?.length ?? 0) === 0) {
this._finishStreamingTextMessage(threadId, fullText)
this._finishStreamingTextMessage(threadId, { content: fullText, reasoning: fullReasoning })
}
else {
this._addMessageToThread(threadId, { role: 'assistant', content: fullText, displayContent: fullText })
this._setStreamState(threadId, { messageSoFar: undefined }) // clear streaming message
this._addMessageToThread(threadId, { role: 'assistant', content: fullText, reasoning: fullReasoning || null })
this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined }) // clear streaming message
for (const tool of toolCalls ?? []) {
const toolName = tool.name as ToolName
@@ -475,7 +423,9 @@ class ChatThreadService extends Disposable implements IChatThreadService {
res_()
},
onError: (error) => {
this._finishStreamingTextMessage(threadId, this.streamState[threadId]?.messageSoFar ?? '', error)
const messageSoFar = this.streamState[threadId]?.messageSoFar ?? ''
const reasoningSoFar = this.streamState[threadId]?.reasoningSoFar ?? ''
this._finishStreamingTextMessage(threadId, { content: messageSoFar, reasoning: reasoningSoFar }, error)
res_()
},
})
@@ -493,7 +443,9 @@ class ChatThreadService extends Disposable implements IChatThreadService {
cancelStreaming(threadId: string) {
const llmCancelToken = this.streamState[threadId]?.streamingToken
if (llmCancelToken !== undefined) this._llmMessageService.abort(llmCancelToken)
this._finishStreamingTextMessage(threadId, this.streamState[threadId]?.messageSoFar ?? '')
const messageSoFar = this.streamState[threadId]?.messageSoFar ?? ''
const reasoningSoFar = this.streamState[threadId]?.reasoningSoFar ?? ''
this._finishStreamingTextMessage(threadId, { content: messageSoFar, reasoning: reasoningSoFar })
}
dismissStreamError(threadId: string): void {
@@ -506,7 +458,8 @@ class ChatThreadService extends Disposable implements IChatThreadService {
getCurrentThread(): ChatThreads[string] {
const state = this.state
return state.allThreads[state.currentThreadId]
const thread = state.allThreads[state.currentThreadId]
return thread
}
getFocusedMessageIdx() {
@@ -643,12 +596,13 @@ class ChatThreadService extends Disposable implements IChatThreadService {
}
getCurrentThreadStagingSelections = () => {
return this.getCurrentThread().state.stagingSelections
getCurrentThreadState = () => {
const currentThread = this.getCurrentThread()
return currentThread.state
}
setCurrentThreadStagingSelections = (stagingSelections: StagingSelectionItem[]) => {
this._setCurrentThreadState({ stagingSelections })
setCurrentThreadState = (newState: Partial<ThreadType['state']>) => {
this._setCurrentThreadState(newState)
}
// gets `staging` and `setStaging` of the currently focused element, given the index of the currently selected message (or undefined if no message is selected)
@@ -3,7 +3,7 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { ChatMessage } from '../browser/chatThreadService.js'
import { ChatMessage } from './chatThreadService.js'
import { InternalToolInfo, ToolName } from './toolsService.js'
import { FeatureName, ProviderName, SettingsOfProvider } from './voidSettingsTypes.js'
@@ -45,14 +45,14 @@ export type ToolCallType = {
}
export type OnText = (p: { newText: string, fullText: string; newReasoning: string; fullReasoning: string }) => void
export type OnFinalMessage = (p: { fullText: string, toolCalls?: ToolCallType[] }) => void // id is tool_use_id
export type OnText = (p: { fullText: string; fullReasoning: string }) => void
export type OnFinalMessage = (p: { fullText: string, toolCalls?: ToolCallType[], fullReasoning?: string }) => void // id is tool_use_id
export type OnError = (p: { message: string, fullError: Error | null }) => void
export type AbortRef = { current: (() => void) | null }
export const toLLMChatMessage = (c: ChatMessage): LLMChatMessage => {
if (c.role === 'system' || c.role === 'user') {
if (c.role === 'user') {
return { role: c.role, content: c.content || '(empty message)' }
}
else if (c.role === 'assistant')
@@ -55,7 +55,7 @@ export const voidTools = {
query: { type: 'string', description: undefined },
...paginationHelper.param,
},
required: ['query']
required: ['query'],
},
search: {
@@ -68,7 +68,9 @@ export const voidTools = {
required: ['query'],
},
// go_to_definition:
// go_to_definition: {
// },
// go_to_usages:
@@ -307,6 +309,8 @@ export class ToolsService implements IToolsService {
return { queryStr, uris, hasNextPage }
},
search: async (s: string) => {
console.log('search')
const o = validateJSON(s)
@@ -11,7 +11,7 @@ import { registerSingleton, InstantiationType } from '../../../../platform/insta
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
import { IMetricsService } from './metricsService.js';
import { defaultSettingsOfProvider, FeatureName, ProviderName, ModelSelectionOfFeature, SettingsOfProvider, SettingName, providerNames, ModelSelection, modelSelectionsEqual, featureNames, VoidModelInfo, GlobalSettings, GlobalSettingName, defaultGlobalSettings, defaultProviderSettings, developerInfoOfModelName, modelInfoOfAutodetectedModelNames } from './voidSettingsTypes.js';
import { defaultSettingsOfProvider, FeatureName, ProviderName, ModelSelectionOfFeature, SettingsOfProvider, SettingName, providerNames, ModelSelection, modelSelectionsEqual, featureNames, VoidModelInfo, GlobalSettings, GlobalSettingName, defaultGlobalSettings, defaultProviderSettings } from './voidSettingsTypes.js';
const STORAGE_KEY = 'void.settingsServiceStorage'
@@ -28,12 +28,10 @@ type SetModelSelectionOfFeatureFn = <K extends FeatureName>(
options?: { doNotApplyEffects?: true }
) => Promise<void>;
type SetGlobalSettingFn = <T extends GlobalSettingName, >(settingName: T, newVal: GlobalSettings[T]) => void;
type SetGlobalSettingFn = <T extends GlobalSettingName>(settingName: T, newVal: GlobalSettings[T]) => void;
export type ModelOption = { name: string, selection: ModelSelection }
export type VoidSettingsState = {
readonly settingsOfProvider: SettingsOfProvider; // optionsOfProvider
readonly modelSelectionOfFeature: ModelSelectionOfFeature; // stateOfFeature
@@ -51,6 +49,8 @@ export interface IVoidSettingsService {
readonly state: VoidSettingsState; // in order to play nicely with react, you should immutably change state
readonly waitForInitState: Promise<void>;
readAndInitializeState: (providedState?: VoidSettingsState) => Promise<void>;
onDidChangeState: Event<void>;
setSettingOfProvider: SetSettingOfProviderFn;
@@ -65,7 +65,30 @@ export interface IVoidSettingsService {
const _updatedValidatedState = (state: Omit<VoidSettingsState, '_modelOptions'>) => {
const _updatedModelsAfterDefaultModelsChange = (defaultModelNames: string[], options: { existingModels: VoidModelInfo[] }) => {
const { existingModels } = options
const existingModelsMap: Record<string, VoidModelInfo> = {}
for (const existingModel of existingModels) {
existingModelsMap[existingModel.modelName] = existingModel
}
const newDefaultModels = defaultModelNames.map((modelName, i) => ({
modelName,
isDefault: true,
isAutodetected: true,
isHidden: !!existingModelsMap[modelName]?.isHidden,
}))
return [
...newDefaultModels, // swap out all the default models for the new default models
...existingModels.filter(m => !m.isDefault), // keep any non-default (custom) models
]
}
const _validatedState = (state: Omit<VoidSettingsState, '_modelOptions'>) => {
let newSettingsOfProvider = state.settingsOfProvider
@@ -147,6 +170,8 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
readonly onDidChangeState: Event<void> = this._onDidChangeState.event; // this is primarily for use in react, so react can listen + update on state changes
state: VoidSettingsState;
private readonly _resolver: () => void
waitForInitState: Promise<void> // await this if you need a valid state initially
constructor(
@@ -160,59 +185,57 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
// at the start, we haven't read the partial config yet, but we need to set state to something
this.state = defaultState()
let resolver: () => void = () => { }
this.waitForInitState = new Promise((res, rej) => resolver = res)
this._resolver = resolver
// read and update the actual state immediately
this._readState().then(readS => {
this.readAndInitializeState()
}
// the stored data structure might be outdated, so we need to update it here (can do a more general solution later when we need to)
const newSettingsOfProvider = {
// A HACK BECAUSE WE ADDED DEEPSEEK (did not exist before, comes before readS)
...{ deepseek: defaultSettingsOfProvider.deepseek },
async readAndInitializeState(providedState?: VoidSettingsState) {
// If providedState is given, use it instead of reading from storage
const readS = providedState || await this._readState();
// A HACK BECAUSE WE ADDED MISTRAL (did not exist before, comes before readS)
...{ mistral: defaultSettingsOfProvider.mistral },
// the stored data structure might be outdated, so we need to update it here
const newSettingsOfProvider = {
// A HACK BECAUSE WE ADDED DEEPSEEK (did not exist before, comes before readS)
...{ deepseek: defaultSettingsOfProvider.deepseek },
// A HACK BECAUSE WE ADDED XAI (did not exist before, comes before readS)
...{ xAI: defaultSettingsOfProvider.xAI },
// A HACK BECAUSE WE ADDED XAI (did not exist before, comes before readS)
...{ xAI: defaultSettingsOfProvider.xAI },
// A HACK BECAUSE WE ADDED VLLM (did not exist before, comes before readS)
...{ vLLM: defaultSettingsOfProvider.vLLM },
// A HACK BECAUSE WE ADDED VLLM (did not exist before, comes before readS)
...{ vLLM: defaultSettingsOfProvider.vLLM },
...readS.settingsOfProvider,
...readS.settingsOfProvider,
// A HACK BECAUSE WE ADDED NEW GEMINI MODELS (existed before, comes after readS)
gemini: {
...readS.settingsOfProvider.gemini,
models: [
...readS.settingsOfProvider.gemini.models,
...defaultSettingsOfProvider.gemini.models.filter(m => /* if cant find the model in readS (yes this is O(n^2), very small) */ !readS.settingsOfProvider.gemini.models.find(m2 => m2.modelName === m.modelName))
]
}
// A HACK BECAUSE WE ADDED NEW GEMINI MODELS (existed before, comes after readS)
gemini: {
...readS.settingsOfProvider.gemini,
models: [
...readS.settingsOfProvider.gemini.models,
...defaultSettingsOfProvider.gemini.models.filter(m => /* if cant find the model in readS (yes this is O(n^2), very small) */ !readS.settingsOfProvider.gemini.models.find(m2 => m2.modelName === m.modelName))
]
}
};
const newModelSelectionOfFeature = {
// A HACK BECAUSE WE ADDED FastApply
...{ 'Apply': null },
...readS.modelSelectionOfFeature,
}
const newModelSelectionOfFeature = {
// A HACK BECAUSE WE ADDED FastApply
...{ 'Apply': null },
...readS.modelSelectionOfFeature,
};
readS = {
...readS,
settingsOfProvider: newSettingsOfProvider,
modelSelectionOfFeature: newModelSelectionOfFeature,
}
const finalState = {
...readS,
settingsOfProvider: newSettingsOfProvider,
modelSelectionOfFeature: newModelSelectionOfFeature,
};
this.state = _updatedValidatedState(readS)
resolver()
this._onDidChangeState.fire()
})
this.state = _validatedState(finalState);
this._resolver();
this._onDidChangeState.fire();
}
@@ -253,7 +276,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
globalSettings: newGlobalSettings,
}
this.state = _updatedValidatedState(newState)
this.state = _validatedState(newState)
await this._storeState()
this._onDidChangeState.fire()
@@ -296,18 +319,13 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
setAutodetectedModels(providerName: ProviderName, autodetectedModelNames: string[], logging: object) {
const { models } = this.state.settingsOfProvider[providerName]
const oldModelNames = models.map(m => m.modelName)
const newDefaultModels = modelInfoOfAutodetectedModelNames(autodetectedModelNames, { existingModels: models })
const newModels = [
...newDefaultModels, // swap out all the default models for the new default models
...models.filter(m => !m.isDefault), // keep any non-default (custom) models
]
const newModels = _updatedModelsAfterDefaultModelsChange(autodetectedModelNames, { existingModels: models })
this.setSettingOfProvider(providerName, 'models', newModels)
// if the models changed, log it
@@ -341,7 +359,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
if (existingIdx !== -1) return // if exists, do nothing
const newModels = [
...models,
{ ...developerInfoOfModelName(modelName), modelName, isDefault: false, isHidden: false }
{ modelName, isDefault: false, isHidden: false }
]
this.setSettingOfProvider(providerName, 'models', newModels)
@@ -4,7 +4,6 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { defaultModelsOfProvider } from '../electron-main/llmMessage/MODELS.js';
import { VoidSettingsState } from './voidSettingsService.js'
@@ -40,14 +39,71 @@ export const defaultProviderSettings = {
groq: {
apiKey: '',
},
mistral: {
apiKey: ''
},
xAI: {
apiKey: ''
},
} as const
export const defaultModelsOfProvider = {
openAI: [ // https://platform.openai.com/docs/models/gp
'o1',
'o3-mini',
'o1-mini',
'gpt-4o',
'gpt-4o-mini',
],
anthropic: [ // https://docs.anthropic.com/en/docs/about-claude/models
'claude-3-7-sonnet-latest',
// 'claude-3-5-sonnet-latest',
'claude-3-5-haiku-latest',
'claude-3-opus-latest',
],
xAI: [ // https://docs.x.ai/docs/models?cluster=us-east-1
'grok-2-latest',
'grok-3-latest',
],
gemini: [ // https://ai.google.dev/gemini-api/docs/models/gemini
'gemini-2.0-flash',
'gemini-1.5-flash',
'gemini-1.5-pro',
'gemini-1.5-flash-8b',
'gemini-2.0-flash-thinking-exp',
],
deepseek: [ // https://api-docs.deepseek.com/quick_start/pricing
'deepseek-chat',
'deepseek-reasoner',
],
ollama: [ // autodetected
],
vLLM: [ // autodetected
],
openRouter: [ // https://openrouter.ai/models
'anthropic/claude-3.5-sonnet',
'deepseek/deepseek-r1',
'mistralai/codestral-2501',
'qwen/qwen-2.5-coder-32b-instruct',
],
groq: [ // https://console.groq.com/docs/models
'llama-3.3-70b-versatile',
'llama-3.1-8b-instant',
'qwen-2.5-coder-32b', // preview mode (experimental)
],
// not supporting mistral right now- it's last on Void usage, and a huge pain to set up since it's nonstandard (it supports codestral FIM but it's on v1/fim/completions, etc)
// mistral: [ // https://docs.mistral.ai/getting-started/models/models_overview/
// 'codestral-latest',
// 'mistral-large-latest',
// 'ministral-3b-latest',
// 'ministral-8b-latest',
// ],
openAICompatible: [], // fallback
} as const satisfies Record<ProviderName, string[]>
export type ProviderName = keyof typeof defaultProviderSettings
export const providerNames = Object.keys(defaultProviderSettings) as ProviderName[]
@@ -139,11 +195,6 @@ export const displayInfoOfProviderName = (providerName: ProviderName): DisplayIn
title: 'Groq.com API',
}
}
else if (providerName === 'mistral') {
return {
title: 'Mistral API',
}
}
else if (providerName === 'xAI') {
return {
title: 'xAI API',
@@ -173,10 +224,9 @@ export const displayInfoOfSettingName = (providerName: ProviderName, settingName
providerName === 'openRouter' ? 'sk-or-key...' : // sk-or-v1-key
providerName === 'gemini' ? 'key...' :
providerName === 'groq' ? 'gsk_key...' :
providerName === 'mistral' ? 'key...' :
providerName === 'openAICompatible' ? 'sk-key...' :
providerName === 'xAI' ? 'xai-key...' :
'',
providerName === 'openAICompatible' ? 'sk-key...' :
providerName === 'xAI' ? 'xai-key...' :
'',
subTextMd: providerName === 'anthropic' ? 'Get your [API Key here](https://console.anthropic.com/settings/keys).' :
providerName === 'openAI' ? 'Get your [API Key here](https://platform.openai.com/api-keys).' :
@@ -184,10 +234,9 @@ export const displayInfoOfSettingName = (providerName: ProviderName, settingName
providerName === 'openRouter' ? 'Get your [API Key here](https://openrouter.ai/settings/keys).' :
providerName === 'gemini' ? 'Get your [API Key here](https://aistudio.google.com/apikey).' :
providerName === 'groq' ? 'Get your [API Key here](https://console.groq.com/keys).' :
providerName === 'mistral' ? 'Get your [API Key here](https://console.mistral.ai/api-keys/).' :
providerName === 'xAI' ? 'Get your [API Key here](https://console.x.ai).' :
providerName === 'openAICompatible' ? undefined :
'',
providerName === 'xAI' ? 'Get your [API Key here](https://console.x.ai).' :
providerName === 'openAICompatible' ? undefined :
'',
isPasswordField: true,
}
}
@@ -271,12 +320,6 @@ export const defaultSettingsOfProvider: SettingsOfProvider = {
...modelInfoOfDefaultModelNames(defaultModelsOfProvider.gemini),
_didFillInProviderSettings: undefined,
},
mistral: {
...defaultCustomSettings,
...defaultProviderSettings.mistral,
...modelInfoOfDefaultModelNames(defaultModelsOfProvider.mistral),
_didFillInProviderSettings: undefined,
},
xAI: {
...defaultCustomSettings,
...defaultProviderSettings.xAI,
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
import { LLMChatMessage } from '../../common/llmMessageTypes.js';
import { LLMChatMessage, LLMFIMMessage } from '../../common/llmMessageTypes.js';
import { deepClone } from '../../../../../base/common/objects.js';
@@ -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 {
@@ -322,3 +335,29 @@ export const prepareMessages = ({
} as const
}
export const prepareFIMMessage = ({
messages,
aiInstructions,
}: {
messages: LLMFIMMessage,
aiInstructions: string,
}) => {
let prefix = `\
${!aiInstructions ? '' : `\
// Instructions:
// Do not output an explanation. Try to avoid outputting comments. Only output the middle code.
${aiInstructions.split('\n').map(line => `//${line}`).join('\n')}`}
${messages.prefix}`
const suffix = messages.suffix
const stopTokens = messages.stopTokens
const ret = { prefix, suffix, stopTokens, maxTokens: 300 } as const
console.log('ret', ret)
return ret
}
@@ -63,22 +63,23 @@ export const sendLLMMessage = ({
_fullTextSoFar = fullText
}
const onFinalMessage: OnFinalMessage = ({ fullText, toolCalls }) => {
const onFinalMessage: OnFinalMessage = (params) => {
const { fullText, fullReasoning } = params
if (_didAbort) return
captureLLMEvent(`${loggingName} - Received Full Message`, { messageLength: fullText.length, duration: new Date().getMilliseconds() - submit_time.getMilliseconds() })
onFinalMessage_({ fullText, toolCalls })
captureLLMEvent(`${loggingName} - Received Full Message`, { messageLength: fullText.length, reasoningLength: fullReasoning?.length, duration: new Date().getMilliseconds() - submit_time.getMilliseconds() })
onFinalMessage_(params)
}
const onError: OnError = ({ message: error, fullError }) => {
const onError: OnError = ({ message: errorMessage, fullError }) => {
if (_didAbort) return
console.error('sendLLMMessage onError:', error)
console.error('sendLLMMessage onError:', errorMessage)
// handle failed to fetch errors, which give 0 information by design
if (error === 'TypeError: fetch failed')
error = `Failed to fetch from ${displayInfoOfProviderName(providerName).title}. This likely means you specified the wrong endpoint in Void's Settings, or your local model provider like Ollama is powered off.`
if (errorMessage === 'TypeError: fetch failed')
errorMessage = `Failed to fetch from ${displayInfoOfProviderName(providerName).title}. This likely means you specified the wrong endpoint in Void's Settings, or your local model provider like Ollama is powered off.`
captureLLMEvent(`${loggingName} - Error`, { error })
onError_({ message: error, fullError })
captureLLMEvent(`${loggingName} - Error`, { error: errorMessage })
onError_({ message: errorMessage, fullError })
}
const onAbort = () => {