@herenfor/dsh-wsl 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +51 -0
- package/README.md +75 -2
- package/README.zh.md +76 -0
- package/agent/broker.mjs +107 -0
- package/agent/cordis.patch.yml +3 -0
- package/agent/index.mjs +130 -0
- package/agent/package.json +9 -0
- package/client.js +825 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +1 -0
- package/ids.mjs +89 -0
- package/index.mjs +614 -0
- package/language.mjs +27 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/merge.mjs +65 -0
- package/package.json +59 -4
- package/paths.mjs +24 -0
- package/plugins.mjs +77 -0
- package/remote-mux.mjs +154 -0
- package/routes.mjs +47 -0
- package/routing.mjs +507 -0
- package/selfupdate.mjs +79 -0
- package/sessions.mjs +101 -0
- package/settings.mjs +53 -0
- package/setup.mjs +286 -0
- package/transport/client.mjs +102 -0
- package/transport/daemon.mjs +268 -0
- package/transport/mux.mjs +118 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 HeRenFor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
Third-party notice
|
|
26
|
+
|
|
27
|
+
ids.mjs adapts the list of identity-bearing fields from
|
|
28
|
+
dsh-ssh-workspaces (https://github.com/czp0312/dsh-ssh-workspace), under the following license:
|
|
29
|
+
|
|
30
|
+
MIT License
|
|
31
|
+
|
|
32
|
+
Copyright (c) 2026 czp0312
|
|
33
|
+
Copyright (c) 2026 dsh-ssh-workspaces contributors
|
|
34
|
+
|
|
35
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
36
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
37
|
+
in the Software without restriction, including without limitation the rights
|
|
38
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
39
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
40
|
+
furnished to do so, subject to the following conditions:
|
|
41
|
+
|
|
42
|
+
The above copyright notice and this permission notice shall be included in all
|
|
43
|
+
copies or substantial portions of the Software.
|
|
44
|
+
|
|
45
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
46
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
47
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
48
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
49
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
50
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
51
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,76 @@
|
|
|
1
|
-
#
|
|
1
|
+
# DSH WSL run mode
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
English | [中文](README.zh.md)
|
|
4
|
+
|
|
5
|
+
Lets DeepSeek Harness Desktop on Windows run tasks in WSL. For a task set to WSL, the agent, its commands, file access and plugins all run in a DSH inside WSL, while you keep using the Desktop window.
|
|
6
|
+
|
|
7
|
+
> Beta. Tested only with DSH 0.2.2-rc.1 so far. Please report problems on [GitHub](https://github.com/herenfor/dsh-wsl/issues).
|
|
8
|
+
|
|
9
|
+
## What it does
|
|
10
|
+
|
|
11
|
+
- **Choose where each task runs**: on the new-task page, pick "Windows" or "WSL (distribution)" under the input box. Folders inside WSL (`\\wsl.localhost\…`) always run in WSL.
|
|
12
|
+
- **Add WSL folders**: when adding a folder, choose a Windows folder or a WSL folder. A WSL folder is picked by browsing the Linux file system right in Desktop.
|
|
13
|
+
- **Both sides in one list**: the sidebar lists the folders and tasks of Windows and WSL together, with a small penguin on WSL ones. Approvals, questions, subagents and live titles work as in any task.
|
|
14
|
+
- **WSL plugins on their own tab**: the Plugins page gets a "WSL (distribution)" tab to install, enable, disable and uninstall plugins in WSL, with a page per plugin showing its description and the state of each component. The official Installed tab still covers Windows only; the two sides do not affect each other. Plugins installed only in WSL, including ones that change the interface, work in the Desktop window too.
|
|
15
|
+
- **Desktop's model credentials**: when a WSL task calls a model, it asks Desktop for the credentials; API keys and sign-ins are never written to the WSL disk. An API key you entered in WSL yourself takes precedence.
|
|
16
|
+
- **Desktop's settings win**: settings you can change in Desktop (model, theme, language and so on) are copied one way into WSL; settings only WSL plugins have stay in WSL.
|
|
17
|
+
- **Starts and stops with Desktop**: the WSL side starts when Desktop opens and exits about 5 seconds after Desktop closes.
|
|
18
|
+
- **Follows DSH's language**: Chinese and English for now; other languages show English.
|
|
19
|
+
|
|
20
|
+
## Requirements
|
|
21
|
+
|
|
22
|
+
- DSH Desktop 0.2.x on Windows 10 or 11.
|
|
23
|
+
- WSL 2 with a Linux distribution: Ubuntu, Debian or whichever you have.
|
|
24
|
+
- A DSH in WSL whose version matches Desktop in its first two parts (for example both 0.2.x). Without one, the plugin lists the install commands; it can run them for you or hand them to the Windows DSH (step 3 below).
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
1. Desktop → Plugins → Add plugin → Install a third-party plugin, enter `@herenfor/dsh-wsl` and install.
|
|
29
|
+
2. Quit Desktop completely (if it keeps running in the system tray, quit it there too), wait at least 5 seconds, then open it again. Until then the plugin does not start working, and the bottom of the sidebar says to restart Desktop.
|
|
30
|
+
3. On the new-task page, switch the run location under the input box to WSL. The first time, "Set up the WSL environment" opens; choose one way:
|
|
31
|
+
- **Reuse your dsh**: offered when WSL already has dsh. Desktop shares data with the dsh you use in WSL yourself, so tasks show up on both; updates update both.
|
|
32
|
+
- **A separate copy for this plugin**: installed in `~/.dsh-wsl/dsh` with its data in `~/.dsh-wsl/home`, leaving your own dsh alone. About 250 MB to download, about 900 MB installed.
|
|
33
|
+
|
|
34
|
+
If Node.js or DSH is missing, the window lists the commands to run. Click "Install automatically", click "Let DSH install it" to have the Windows DSH run them, or run them yourself in a WSL terminal. The plugin never downloads anything in the background on its own.
|
|
35
|
+
4. Once connected, the loading note at the bottom of the sidebar goes away and the WSL folders and tasks appear in the sidebar.
|
|
36
|
+
|
|
37
|
+
### Other ways to install
|
|
38
|
+
|
|
39
|
+
The install box in step 1 also takes these, but a plugin installed this way does not update itself:
|
|
40
|
+
|
|
41
|
+
- From GitHub: `github:herenfor/dsh-wsl#path:/experiments/mirror/mirror`, or for a given version `github:herenfor/dsh-wsl#v0.1.0&path:/experiments/mirror/mirror`.
|
|
42
|
+
- From a local folder: download the repository to Windows and enter the full path of its `experiments/mirror/mirror` folder.
|
|
43
|
+
|
|
44
|
+
## Installing plugins in WSL
|
|
45
|
+
|
|
46
|
+
Plugins → "WSL (distribution)" → enter an npm package name, git URL or local folder path in the install box → Check → Install. Windows paths are turned into WSL paths. When it says a restart is needed, quit Desktop completely, wait at least 5 seconds and open it again.
|
|
47
|
+
|
|
48
|
+
## Updates
|
|
49
|
+
|
|
50
|
+
- The plugin itself: published on npm and updated by DSH's own plugin updates. The bottom of the setup window shows its version and lets you turn automatic updates off.
|
|
51
|
+
- The DSH in WSL follows Desktop's version. After Desktop updates, the setup window offers to update the WSL side.
|
|
52
|
+
|
|
53
|
+
## Uninstall
|
|
54
|
+
|
|
55
|
+
1. Desktop → Plugins → Installed → uninstall "WSL run mode", then restart Desktop.
|
|
56
|
+
2. Remove what is left in WSL by hand:
|
|
57
|
+
- `~/.dsh-wsl`: connection files, logs, and in the separate-copy mode its DSH and data.
|
|
58
|
+
- In the reuse mode also `~/.dsh/profiles/desktop-wsl`, the profile the plugin created. Task data lives in `~/.dsh`, shared with your own dsh: **do not delete all of `~/.dsh`**.
|
|
59
|
+
|
|
60
|
+
## When something goes wrong
|
|
61
|
+
|
|
62
|
+
- When the bottom of the sidebar says the WSL side failed to start, click it to see the error.
|
|
63
|
+
- The WSL side's log is `~/.dsh-wsl/run/desktop-wsl/daemon.log`.
|
|
64
|
+
- Feedback: <https://github.com/herenfor/dsh-wsl/issues>
|
|
65
|
+
|
|
66
|
+
## Known limitations
|
|
67
|
+
|
|
68
|
+
- Tested only with DSH 0.2.2-rc.1. Desktop and the DSH in WSL must match in the first two parts of their versions.
|
|
69
|
+
- In the separate-copy mode, WSL tasks are not shared with your own dsh, and tasks cannot be moved between the two ways yet.
|
|
70
|
+
- When a WSL plugin's patch changes the configuration of an official component (rather than switching it off), that change does not apply in the Desktop window.
|
|
71
|
+
- The interface of WSL plugins loads into the whole Desktop window, not only into WSL tasks.
|
|
72
|
+
- Closing Desktop stops the WSL side as well; WSL tasks do not keep running in the background.
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT. The session ID field table is adapted from [dsh-ssh-workspaces](https://github.com/czp0312/dsh-ssh-workspace) (MIT); see `LICENSE` for its notice.
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# DSH WSL 运行模式
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 中文
|
|
4
|
+
|
|
5
|
+
让 Windows 版 DeepSeek Harness(Desktop)在 WSL 里运行任务。选了 WSL 的任务,agent、命令、文件读写和插件都在 WSL 里的 DSH 中运行,界面仍然是 Desktop 的窗口。
|
|
6
|
+
|
|
7
|
+
> 测试版。目前只在 DSH 0.2.2-rc.1 上测试过。遇到问题欢迎到 [GitHub](https://github.com/herenfor/dsh-wsl/issues) 反馈。
|
|
8
|
+
|
|
9
|
+
## 能做什么
|
|
10
|
+
|
|
11
|
+
- **每个任务自己选在哪里运行**:在新任务页的输入框下方选「Windows」或「WSL(发行版名)」。WSL 里的文件夹(`\\wsl.localhost\…`)总是在 WSL 中运行。
|
|
12
|
+
- **添加 WSL 文件夹**:添加文件夹时先选 Windows 文件夹或 WSL 文件夹。选 WSL 文件夹会在 Desktop 里直接浏览 Linux 文件系统。
|
|
13
|
+
- **两边的任务列在一起**:侧栏同时列出 Windows 和 WSL 的文件夹和任务,WSL 的带小企鹅标记。审批、提问、子智能体、标题实时更新,都和普通任务一样。
|
|
14
|
+
- **WSL 里的插件单独管理**:插件页多一个「WSL(发行版名)」标签页,可以安装、启用、停用、卸载 WSL 里的插件,点进去能看说明和每个组件的运行状态。官方的「已安装」页仍然只管 Windows,两边互不影响。只装在 WSL 里的插件,包括改界面的插件,也能在 Desktop 窗口里使用。
|
|
15
|
+
- **用 Desktop 的模型凭据**:WSL 里的任务调用模型时,向 Desktop 取凭据,API Key 和登录信息不会写进 WSL 的磁盘。在 WSL 里自己填过 API Key 的,用 WSL 自己的。
|
|
16
|
+
- **设置以 Desktop 为准**:模型、主题、语言等 Desktop 上能调的设置,单向同步到 WSL;只有 WSL 里的插件才有的设置,留在 WSL。
|
|
17
|
+
- **跟着 Desktop 启停**:打开 Desktop 时自动启动 WSL 端,关闭 Desktop 约 5 秒后 WSL 端退出。
|
|
18
|
+
- **界面语言跟随 DSH**:目前有中文和英文,其他语言显示英文。
|
|
19
|
+
|
|
20
|
+
## 需要什么
|
|
21
|
+
|
|
22
|
+
- Windows 10 或 11 上的 DSH Desktop 0.2.x。
|
|
23
|
+
- WSL 2 和一个 Linux 发行版。Ubuntu、Debian 等都可以,用你装的那个。
|
|
24
|
+
- WSL 里一份 DSH,版本号前两段要和 Desktop 相同(例如都是 0.2.x)。没有的话,插件会列出安装命令,可以一键安装,也可以交给 Windows 端的 DSH 去装,见下面第 3 步。
|
|
25
|
+
|
|
26
|
+
## 安装
|
|
27
|
+
|
|
28
|
+
1. Desktop → 插件 → 添加插件 → 安装第三方插件,填 `@herenfor/dsh-wsl`,然后安装。
|
|
29
|
+
2. 完全退出 Desktop(如果它还在系统托盘里运行,也要在托盘里退出),等 5 秒以上再打开。不重启的话插件不会开始工作,侧栏底部会提示「重启 Desktop 后生效」。
|
|
30
|
+
3. 在新任务页,把输入框下方的运行位置切到 WSL。第一次会弹出「设置 WSL 运行环境」,选一种方式:
|
|
31
|
+
- **复用你装的 dsh**:WSL 里已经装了 dsh 时可以选。Desktop 和你在 WSL 里自己用的 dsh 共用数据,任务互相看得到;更新时两边一起更新。
|
|
32
|
+
- **插件专用一份**:装在 `~/.dsh-wsl/dsh`,数据放在 `~/.dsh-wsl/home`,不影响你自己的 dsh。要下载约 250 MB,装好后约占 900 MB。
|
|
33
|
+
|
|
34
|
+
缺 Node.js 或 DSH 时,窗口里会列出要运行的命令。你可以点「自动安装」,点「交给 DSH 安装」让 Windows 端的 DSH 去执行,或者自己在 WSL 终端里运行。插件不会自己在后台下载。
|
|
35
|
+
4. 连上之后,侧栏底部的加载提示会消失,WSL 的文件夹和任务出现在侧栏里。
|
|
36
|
+
|
|
37
|
+
### 其他安装方式
|
|
38
|
+
|
|
39
|
+
第 1 步的安装框也接受下面两种写法,但这样装的不会自动更新:
|
|
40
|
+
|
|
41
|
+
- 从 GitHub 安装:`github:herenfor/dsh-wsl#path:/experiments/mirror/mirror`。指定版本时写成 `github:herenfor/dsh-wsl#v0.1.0&path:/experiments/mirror/mirror`。
|
|
42
|
+
- 从本地文件夹安装:把仓库下载到 Windows 上,填 `experiments/mirror/mirror` 文件夹的完整路径。
|
|
43
|
+
|
|
44
|
+
## 在 WSL 里装插件
|
|
45
|
+
|
|
46
|
+
插件 →「WSL(发行版名)」→ 在安装框里填 npm 包名、git 地址或本地文件夹路径 → 检查 → 安装。Windows 路径会自动换成 WSL 路径。提示需要重启的,完全退出 Desktop,等 5 秒以上再打开。
|
|
47
|
+
|
|
48
|
+
## 更新
|
|
49
|
+
|
|
50
|
+
- 插件本身:发布在 npm 上,用 DSH 自带的插件更新。设置窗口底部会显示插件版本,也可以在那里关闭自动更新。
|
|
51
|
+
- WSL 里的 DSH:版本跟着 Desktop。Desktop 升级后,设置窗口会提示更新 WSL 端。
|
|
52
|
+
|
|
53
|
+
## 卸载
|
|
54
|
+
|
|
55
|
+
1. Desktop → 插件 → 已安装 → 卸载「WSL 运行模式」,然后重启 Desktop。
|
|
56
|
+
2. WSL 里留下的文件需要手动删除:
|
|
57
|
+
- `~/.dsh-wsl`:连接文件、日志,以及插件专用模式下的 DSH 和数据。
|
|
58
|
+
- 复用模式下还有 `~/.dsh/profiles/desktop-wsl`,这是插件建的 profile。任务数据在 `~/.dsh` 里,和你自己的 dsh 共用,**不要删整个 `~/.dsh`**。
|
|
59
|
+
|
|
60
|
+
## 出问题时
|
|
61
|
+
|
|
62
|
+
- 侧栏底部显示「启动失败」时,点它可以看到错误信息。
|
|
63
|
+
- WSL 端日志在 `~/.dsh-wsl/run/desktop-wsl/daemon.log`。
|
|
64
|
+
- 反馈:<https://github.com/herenfor/dsh-wsl/issues>
|
|
65
|
+
|
|
66
|
+
## 已知限制
|
|
67
|
+
|
|
68
|
+
- 只在 DSH 0.2.2-rc.1 上测试过。Desktop 和 WSL 里的 DSH,版本号前两段必须相同。
|
|
69
|
+
- 插件专用模式下,WSL 的任务和你自己的 dsh 不互通,目前也不能在两种方式之间搬任务。
|
|
70
|
+
- WSL 里的插件如果用补丁改了官方组件的配置(而不是关掉它),这个改动在 Desktop 窗口里不生效。
|
|
71
|
+
- WSL 里插件的界面加载在整个 Desktop 窗口里,不只出现在 WSL 任务中。
|
|
72
|
+
- 关闭 Desktop 后 WSL 端也会退出,WSL 任务不会在后台继续运行。
|
|
73
|
+
|
|
74
|
+
## 许可证
|
|
75
|
+
|
|
76
|
+
MIT。会话 ID 字段表改编自 [dsh-ssh-workspaces](https://github.com/czp0312/dsh-ssh-workspace)(MIT),版权声明见 `LICENSE`。
|
package/agent/broker.mjs
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Credential broker, WSL half (docs/credentials.md, option 3). The WSL DSH Host keeps its own credential service
|
|
2
|
+
// (credentials-local); this wraps its read methods so that a credential the person did not set in WSL is asked of
|
|
3
|
+
// Desktop, through the bridge daemon (transport/daemon.mjs, "broker" connections). Nothing from Desktop is stored here.
|
|
4
|
+
//
|
|
5
|
+
// - resolve / describe / readRecord / describeRecord: WSL's own first, then Desktop's.
|
|
6
|
+
// - listRecords: both, WSL's own winning on the same key.
|
|
7
|
+
// - modifyRecord on a record only Desktop has (an account token refresh): read Desktop's, run the update here, write
|
|
8
|
+
// it back to Desktop as a compare-and-swap, retrying if Desktop's changed meanwhile. Desktop keeps the only copy.
|
|
9
|
+
// - set / unset / deleteRecord: WSL's own store only.
|
|
10
|
+
import { connect } from 'node:net'
|
|
11
|
+
import { readFileSync } from 'node:fs'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
|
|
14
|
+
export class BrokerClient {
|
|
15
|
+
constructor(runDir) {
|
|
16
|
+
this.socketPath = join(runDir, 'rpc.sock')
|
|
17
|
+
this.tokenPath = join(runDir, 'daemon.token')
|
|
18
|
+
this.pending = new Map()
|
|
19
|
+
this.next = 1
|
|
20
|
+
}
|
|
21
|
+
connection() {
|
|
22
|
+
if (this.socket && !this.socket.destroyed) return this.socket
|
|
23
|
+
const socket = connect(this.socketPath)
|
|
24
|
+
socket.write(`${readFileSync(this.tokenPath, 'utf8').trim()} broker\n`)
|
|
25
|
+
let buffer = ''
|
|
26
|
+
socket.on('data', (chunk) => {
|
|
27
|
+
buffer += chunk.toString('utf8')
|
|
28
|
+
let nl
|
|
29
|
+
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
30
|
+
const line = buffer.slice(0, nl); buffer = buffer.slice(nl + 1)
|
|
31
|
+
let answer
|
|
32
|
+
try { answer = JSON.parse(line) } catch { continue }
|
|
33
|
+
const waiter = this.pending.get(answer.id)
|
|
34
|
+
if (!waiter) continue
|
|
35
|
+
this.pending.delete(answer.id)
|
|
36
|
+
if (answer.error) waiter.reject(new Error(answer.error)); else waiter.resolve(answer.value)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
const fail = (error) => { for (const w of this.pending.values()) w.reject(error ?? new Error('broker connection closed')); this.pending.clear() }
|
|
40
|
+
socket.on('error', fail)
|
|
41
|
+
socket.on('close', () => fail())
|
|
42
|
+
socket.unref()
|
|
43
|
+
return (this.socket = socket)
|
|
44
|
+
}
|
|
45
|
+
/** One request to Desktop; rejects when Desktop is not connected or does not answer. */
|
|
46
|
+
ask(op, args = {}, timeoutMs = 15_000) {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const id = this.next++
|
|
49
|
+
const timer = setTimeout(() => { this.pending.delete(id); reject(new Error('Desktop did not answer')) }, timeoutMs)
|
|
50
|
+
timer.unref?.()
|
|
51
|
+
this.pending.set(id, { resolve: (v) => { clearTimeout(timer); resolve(v) }, reject: (e) => { clearTimeout(timer); reject(e) } })
|
|
52
|
+
try { this.connection().write(`${JSON.stringify({ id, op, ...args })}\n`) } catch (error) { this.pending.delete(id); clearTimeout(timer); reject(error) }
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
close() { this.socket?.destroy() }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Wrap the credential service instance; returns the restore function. */
|
|
59
|
+
export function brokerCredentials(credentials, client, logger) {
|
|
60
|
+
const own = {}
|
|
61
|
+
const orig = {}
|
|
62
|
+
for (const name of ['resolve', 'describe', 'readRecord', 'describeRecord', 'listRecords', 'modifyRecord']) {
|
|
63
|
+
own[name] = Object.hasOwn(credentials, name)
|
|
64
|
+
orig[name] = credentials[name].bind(credentials)
|
|
65
|
+
}
|
|
66
|
+
const desktop = async (op, args, fallback) => {
|
|
67
|
+
try { return await client.ask(op, args) } catch (error) { logger?.debug?.('wsl-agent: Desktop credentials %s: %s', op, error.message); return fallback }
|
|
68
|
+
}
|
|
69
|
+
credentials.resolve = async (ref) => (await orig.resolve(ref)) ?? (await desktop('resolve', { ref }, null)) ?? undefined
|
|
70
|
+
credentials.describe = async (ref) => {
|
|
71
|
+
const local = await orig.describe(ref)
|
|
72
|
+
if (local.configured) return local
|
|
73
|
+
const theirs = await desktop('describe', { ref })
|
|
74
|
+
return theirs?.configured ? { configured: true, source: `desktop${theirs.source ? `-${theirs.source}` : ''}`, writable: local.writable } : local
|
|
75
|
+
}
|
|
76
|
+
credentials.readRecord = async (key) => (await orig.readRecord(key)) ?? (await desktop('readRecord', { key }, null)) ?? undefined
|
|
77
|
+
credentials.describeRecord = async (key) => {
|
|
78
|
+
const local = await orig.describeRecord(key)
|
|
79
|
+
if (local.configured) return local
|
|
80
|
+
const theirs = await desktop('describeRecord', { key })
|
|
81
|
+
return theirs?.configured ? { ...theirs, writable: local.writable } : local
|
|
82
|
+
}
|
|
83
|
+
credentials.listRecords = async () => {
|
|
84
|
+
const local = await orig.listRecords()
|
|
85
|
+
const theirs = (await desktop('listRecords', {}, [])) ?? []
|
|
86
|
+
const keys = new Set(local.map((e) => e.key))
|
|
87
|
+
return [...local, ...theirs.filter((e) => !keys.has(e.key))]
|
|
88
|
+
}
|
|
89
|
+
credentials.modifyRecord = async (key, mutate) => {
|
|
90
|
+
if ((await orig.readRecord(key)) !== undefined) return orig.modifyRecord(key, mutate)
|
|
91
|
+
let current = await desktop('readRecord', { key }, null)
|
|
92
|
+
if (current === null) return orig.modifyRecord(key, mutate)
|
|
93
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
94
|
+
const next = await mutate(current)
|
|
95
|
+
if (next === undefined) return current
|
|
96
|
+
const result = await client.ask('swapRecord', { key, expected: current, next })
|
|
97
|
+
if (result.swapped) return result.record ?? undefined
|
|
98
|
+
current = result.record
|
|
99
|
+
if (current === null) return orig.modifyRecord(key, mutate)
|
|
100
|
+
}
|
|
101
|
+
throw new Error('wsl-agent: Desktop credential record kept changing; giving up')
|
|
102
|
+
}
|
|
103
|
+
return () => {
|
|
104
|
+
for (const name of Object.keys(orig)) { if (own[name]) credentials[name] = orig[name]; else delete credentials[name] }
|
|
105
|
+
client.close()
|
|
106
|
+
}
|
|
107
|
+
}
|
package/agent/index.mjs
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// WSL side of the UI mirror experiment. Serves GET /wsl-bridge/events: a server-sent event stream that sends
|
|
2
|
+
// { graph, origin } on connect and after every graph change. `graph` is this Host's client module graph as is;
|
|
3
|
+
// `origin[id]` is 'builtin' when the row's bundle ships inside the DSH installation, 'user' otherwise.
|
|
4
|
+
import { readFileSync, realpathSync } from 'node:fs'
|
|
5
|
+
import { createRequire } from 'node:module'
|
|
6
|
+
import { homedir } from 'node:os'
|
|
7
|
+
import { dirname, join, sep } from 'node:path'
|
|
8
|
+
import { BrokerClient, brokerCredentials } from './broker.mjs'
|
|
9
|
+
|
|
10
|
+
export const name = 'wsl-agent'
|
|
11
|
+
export const inject = ['clientModules', 'webServer']
|
|
12
|
+
export const EVENTS = '/wsl-bridge/events'
|
|
13
|
+
const MODULES_ID = '@deepseek-ai/dsh-client-modules'
|
|
14
|
+
|
|
15
|
+
const real = (p) => { try { return realpathSync(p) } catch { return p } }
|
|
16
|
+
|
|
17
|
+
// The node_modules directory the DSH installation keeps its own packages in, found from the client-modules bundle:
|
|
18
|
+
// <root>/@deepseek-ai/dsh-client-modules/lib/client.js
|
|
19
|
+
export function builtinRoot(modulesClientPath) {
|
|
20
|
+
return dirname(dirname(dirname(dirname(real(modulesClientPath)))))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The WSL Host's HTTP route table (path names only), read from the web server's private maps, and the exact Fetch
|
|
24
|
+
// routes plugins register under /api (`ctx.connection.fetch.register`, e.g. DS Bot's /api/bot), with their methods.
|
|
25
|
+
export function routeTable(ws, connection) {
|
|
26
|
+
const fetch = [...(connection?.fetchRoutes ?? [])].map(([path, route]) => ({ path, methods: [...(route.methods ?? [])] }))
|
|
27
|
+
return { exact: [...(ws.exact?.keys() ?? [])], prefix: [...(ws.prefixes?.keys() ?? [])], upgrade: [...(ws.upgrades?.keys() ?? [])], fetch }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Entries that the bundle patches of third-party plugins switch off: top-level patch items `{ id, disabled: true }`
|
|
32
|
+
* (no insert). `patches` is [{ bundle, items }] with each bundle's parsed cordis.patch.yml; `names` maps an entry id to
|
|
33
|
+
* its package name (the client graph row id). Returns [{ id, name, by }] for the ids that have a name.
|
|
34
|
+
*/
|
|
35
|
+
export function disabledByPatches(patches, names) {
|
|
36
|
+
const out = []
|
|
37
|
+
for (const { bundle, items } of patches) {
|
|
38
|
+
for (const item of Array.isArray(items) ? items : []) {
|
|
39
|
+
if (item === null || typeof item !== 'object' || typeof item.id !== 'string' || item.disabled !== true || 'insert' in item) continue
|
|
40
|
+
const name = names.get(item.id)
|
|
41
|
+
if (typeof name === 'string') out.push({ id: item.id, name, by: bundle })
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The parsed cordis.patch.yml of each enabled third-party bundle of a profile: [{ bundle, items }]. Enabled bundles are
|
|
49
|
+
* the profile manifest's `dsh.profile.bundles`; official ones (@deepseek-ai/) are skipped.
|
|
50
|
+
*/
|
|
51
|
+
export function bundlePatches(profileDir, parseYaml) {
|
|
52
|
+
let pkg
|
|
53
|
+
try { pkg = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) } catch { return [] }
|
|
54
|
+
const out = []
|
|
55
|
+
for (const bundle of pkg.dsh?.profile?.bundles ?? []) {
|
|
56
|
+
if (typeof bundle !== 'string' || bundle.startsWith('@deepseek-ai/')) continue
|
|
57
|
+
try {
|
|
58
|
+
const dir = join(profileDir, 'node_modules', bundle)
|
|
59
|
+
const patch = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).dsh?.bundle?.patch
|
|
60
|
+
if (typeof patch === 'string') out.push({ bundle, items: parseYaml(readFileSync(join(dir, patch), 'utf8')) })
|
|
61
|
+
} catch {}
|
|
62
|
+
}
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** This Host's profile folder: <DSH home>/profiles/<--profile>. */
|
|
67
|
+
function profileDir() {
|
|
68
|
+
const i = process.argv.indexOf('--profile')
|
|
69
|
+
const profile = i >= 0 ? process.argv[i + 1] : undefined
|
|
70
|
+
return profile ? join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'profiles', profile) : undefined
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function snapshot(cm, root, ws, connection, disabled) {
|
|
74
|
+
const graph = cm.graph()
|
|
75
|
+
const origin = {}
|
|
76
|
+
for (const row of graph.entries) {
|
|
77
|
+
const path = cm.clientPath(row.id)
|
|
78
|
+
origin[row.id] = path !== undefined && real(path).startsWith(root + sep) ? 'builtin' : 'user'
|
|
79
|
+
}
|
|
80
|
+
return { graph, origin, routes: ws ? routeTable(ws, connection) : undefined, disabled, distro: process.env.WSL_DISTRO_NAME }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function apply(ctx) {
|
|
84
|
+
const cm = ctx.clientModules
|
|
85
|
+
const root = builtinRoot(cm.clientPath(MODULES_ID))
|
|
86
|
+
const clients = new Set()
|
|
87
|
+
const connection = () => { try { return ctx.get('connection') } catch { return undefined } }
|
|
88
|
+
// Official entries that WSL plugins' bundle patches switch off: Desktop drops their client rows from the page too.
|
|
89
|
+
let parseYaml
|
|
90
|
+
try {
|
|
91
|
+
// DSH's own `!!js` expressions (a `disabled:` decided at run time) are kept as opaque values: they are not the literal
|
|
92
|
+
// `disabled: true` looked for here, and leaving the tag unknown printed a YAML warning on every read.
|
|
93
|
+
const { parse } = createRequire(join(root, 'wsl-agent.js'))('yaml')
|
|
94
|
+
const customTags = [{ tag: 'tag:yaml.org,2002:js', resolve: (source) => ({ $js: source }) }]
|
|
95
|
+
parseYaml = (text) => parse(text, { customTags })
|
|
96
|
+
} catch (error) { ctx.logger.warn('wsl-agent: no YAML parser: %s', error.message) }
|
|
97
|
+
const disabled = () => {
|
|
98
|
+
const dir = profileDir()
|
|
99
|
+
if (!dir || !parseYaml) return []
|
|
100
|
+
const names = new Map()
|
|
101
|
+
try { for (const entry of ctx.get('loader').entries()) if (typeof entry.options?.id === 'string' && typeof entry.options?.name === 'string') names.set(entry.options.id, entry.options.name) } catch {}
|
|
102
|
+
return disabledByPatches(bundlePatches(dir, parseYaml), names)
|
|
103
|
+
}
|
|
104
|
+
const frame = () => `data: ${JSON.stringify(snapshot(cm, root, ctx.webServer, connection(), disabled()))}\n\n`
|
|
105
|
+
const push = () => { const f = frame(); for (const res of clients) res.write(f) }
|
|
106
|
+
ctx.effect(() => ctx.webServer.register({
|
|
107
|
+
kind: 'exact',
|
|
108
|
+
path: EVENTS,
|
|
109
|
+
handler(req, res) {
|
|
110
|
+
if (req.method !== 'GET') { res.writeHead(405); res.end(); return }
|
|
111
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' })
|
|
112
|
+
res.write(frame())
|
|
113
|
+
clients.add(res)
|
|
114
|
+
res.on('close', () => clients.delete(res))
|
|
115
|
+
},
|
|
116
|
+
}), 'wsl-agent: events route')
|
|
117
|
+
ctx.effect(() => cm.onGraphChanged(push), 'wsl-agent: graph changes')
|
|
118
|
+
// Routes change when plugins start or stop and the web server announces nothing, so compare every two seconds.
|
|
119
|
+
ctx.effect(() => {
|
|
120
|
+
let last = JSON.stringify(routeTable(ctx.webServer, connection()))
|
|
121
|
+
const timer = setInterval(() => { const now = JSON.stringify(routeTable(ctx.webServer, connection())); if (now !== last) { last = now; push() } }, 2000)
|
|
122
|
+
timer.unref()
|
|
123
|
+
return () => clearInterval(timer)
|
|
124
|
+
}, 'wsl-agent: route changes')
|
|
125
|
+
ctx.effect(() => () => { for (const res of clients) res.destroy(); clients.clear() }, 'wsl-agent: close streams')
|
|
126
|
+
ctx.logger.info('wsl-agent: builtin root %s', root)
|
|
127
|
+
// Started by the bridge daemon (which sets DSH_WSL_RUN): credentials not set in WSL are asked of Desktop.
|
|
128
|
+
const runDir = process.env.DSH_WSL_RUN
|
|
129
|
+
if (runDir) ctx.inject(['credentials'], (c) => brokerCredentials(c.credentials, new BrokerClient(runDir), c.logger))
|
|
130
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-wsl-agent",
|
|
3
|
+
"version": "0.0.0-experiment",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Experiment: runs in the WSL DSH Host and publishes its client module graph, marking which rows come from user-installed plugins.",
|
|
7
|
+
"exports": { ".": "./index.mjs" },
|
|
8
|
+
"dsh": { "bundle": { "patch": "./cordis.patch.yml" }, "manifestVersion": 1 }
|
|
9
|
+
}
|