dsh-wsl-bridge 0.4.6 → 0.4.7
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/CHANGELOG.md +14 -1
- package/README.en.md +14 -10
- package/README.md +14 -10
- package/client.js +299 -0
- package/cordis.patch.yml +8 -7
- package/docs/ARCHITECTURE.md +4 -3
- package/index.js +15 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +13 -3
- package/src/client-workspace.js +111 -0
- package/src/dir-list.js +120 -0
- package/src/dir-route.js +104 -0
- package/src/home-route.js +86 -0
- package/src/session-home.js +107 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.4.
|
|
3
|
+
## 0.4.7 — 2026-10-19
|
|
4
|
+
|
|
5
|
+
- A new conversation that selects WSL lists the current distribution's
|
|
6
|
+
folders, starting at `$HOME`, and asks which folder should become the
|
|
7
|
+
workspace. The folder control opens that directory instead of DSH's
|
|
8
|
+
`default-workspace`. The Windows path is whatever `wslpath -w` prints on
|
|
9
|
+
that computer. Esc closes the visit; choosing WSL again after leaving the
|
|
10
|
+
mode asks once more. Host-wide `wsl_*` tools still use the Windows session
|
|
11
|
+
directory.
|
|
12
|
+
- The picker keeps an opaque surface when a skin makes the app background
|
|
13
|
+
translucent.
|
|
14
|
+
- The WSL preset description is Chinese.
|
|
15
|
+
|
|
16
|
+
## 0.4.6 — 2026-10-19
|
|
4
17
|
|
|
5
18
|
- A `WSL` session now uses the current distribution's `$HOME` as its default
|
|
6
19
|
workspace. The first bash command, relative paths, and grep/glob without a
|
package/README.en.md
CHANGED
|
@@ -4,17 +4,17 @@
|
|
|
4
4
|
|
|
5
5
|
A native WSL (Linux) execution surface for [DSH](https://github.com/deepseek-ai/DeepSeek-Harness) on Windows. It starts no extra web server and installs nothing inside the distribution. Windows sessions and WSL sessions share one desktop app.
|
|
6
6
|
|
|
7
|
-
Version **0.4.
|
|
7
|
+
Version **0.4.7**. Requires **Windows**, a working WSL distribution, and **DSH `>= 0.2.1-rc.2`**. Source: <https://github.com/TGWgulugu/dsh-wsl-bridge>
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
11
|
-
Run this in a normal terminal, not inside a DSH tool sandbox. If `dsh` is not on `PATH`, use the full path to the `dsh.cmd` that ships with DSH Desktop.
|
|
11
|
+
The plugin is published on npm as [dsh-wsl-bridge](https://www.npmjs.com/package/dsh-wsl-bridge). Run this in a normal terminal, not inside a DSH tool sandbox. DSH installs that npm package. If `dsh` is not on `PATH`, use the full path to the `dsh.cmd` that ships with DSH Desktop.
|
|
12
12
|
|
|
13
13
|
```powershell
|
|
14
14
|
dsh plugin --profile desktop add dsh-wsl-bridge
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
To install the source in this repository instead of the npm release:
|
|
18
18
|
|
|
19
19
|
```powershell
|
|
20
20
|
dsh plugin --profile desktop add .
|
|
@@ -58,17 +58,21 @@ A routing note is on by default: for Linux work, prefer `wsl_bash` and Linux pat
|
|
|
58
58
|
|
|
59
59
|
Choose **WSL** when creating a session. In that session, `bash`, `read`, `write`, `edit`, `grep`, and `glob` run inside Linux and take Linux paths. Persona, skills, goals, plan mode, compaction, subagents, todos, web, and present stay the standard DSH set.
|
|
60
60
|
|
|
61
|
-
**
|
|
61
|
+
**You choose the workspace inside the current distribution.** On a new conversation, selecting WSL lists that distribution's folders, starting at `$HOME`. Open a subfolder or go up a level, then confirm with "Use this folder". The chosen directory is whatever `wslpath -w` prints on that computer. No distribution name or username is built into the plugin. The folder control opens that directory instead of DSH's `default-workspace`.
|
|
62
|
+
|
|
63
|
+
Esc or Cancel closes the window for this visit only. Switch the mode away from WSL and back to WSL, and the window asks again. After the first message, DSH no longer allows a mode change, so the window does not return in that conversation.
|
|
64
|
+
|
|
65
|
+
The first `bash`, relative paths, and `grep` / `glob` without a `path` start in the folder you chose. Pass `/mnt/<drive>/...` when the search should cover a Windows directory. The `wsl_*` tools above do not use this window; they keep using the current Windows session directory.
|
|
62
66
|
|
|
63
67
|
| Tool | What it does |
|
|
64
68
|
|---|---|
|
|
65
69
|
| `bash` | Persistent bash in the distribution. The syntax is bash, not PowerShell |
|
|
66
|
-
| `read` / `write` / `edit` | Linux paths. Relative paths are relative to
|
|
67
|
-
| `grep` | GNU grep in the distribution. Rows look like `path:line: text`. Omitting `path` searches
|
|
68
|
-
| `glob` | Lists files matching a glob, oldest first. Omitting `path` lists
|
|
70
|
+
| `read` / `write` / `edit` | Linux paths. Relative paths are relative to the folder you chose |
|
|
71
|
+
| `grep` | GNU grep in the distribution. Rows look like `path:line: text`. Omitting `path` searches the folder you chose |
|
|
72
|
+
| `glob` | Lists files matching a glob, oldest first. Omitting `path` lists the folder you chose |
|
|
69
73
|
| `wsl_env` | Distribution facts |
|
|
70
74
|
|
|
71
|
-
`$HOME`
|
|
75
|
+
The folder list starts at `$HOME` and shows one level of directories. Text that a login profile prints is not treated as a path. If the lookup fails, the Windows session directory is not opened instead. The picker uses an opaque surface, so a skin that makes the app background translucent does not show through it.
|
|
72
76
|
|
|
73
77
|
## Configuration
|
|
74
78
|
|
|
@@ -80,7 +84,7 @@ Set these on the plugin row `config` (`cordis.patch.yml`, or a profile patch tha
|
|
|
80
84
|
| `timeoutMs` | `120000` | Default deadline for `bash` / `wsl_bash`, in milliseconds |
|
|
81
85
|
| `fileTimeoutMs` | `30000` | Deadline for one read, write, or search |
|
|
82
86
|
| `maxFileBytes` | `2097152` | Maximum bytes `read` / `edit` will load |
|
|
83
|
-
| `defaultWorkdir` | `session`
|
|
87
|
+
| `defaultWorkdir` | `session` | For the host `wsl_*` tools, `session` is the current Windows session directory. In WSL mode, `session` is the folder you chose. `home` uses the distribution's `$HOME`. An absolute Linux path is also accepted |
|
|
84
88
|
| `dangerGuard` | `true` | Refuses obviously destructive commands unless that call passes `allowDangerous: true` |
|
|
85
89
|
| `backgroundJobs` | `true` | Allows `runInBackground`. Refuses clearly when the host has no job registry |
|
|
86
90
|
| `promptSection` | `true` | Adds the routing note to host sessions |
|
|
@@ -96,7 +100,7 @@ The refusal list covers recursive deletes, block-device writes, power control, `
|
|
|
96
100
|
- Windows only. Both rows disable themselves on any other host.
|
|
97
101
|
- The tools run through `wsl.exe` and are **not** inside DSH's Windows file sandbox. Treat them as able to write anything that WSL user can write, including Windows paths reached through `/mnt/<drive>`. See [docs/SECURITY.md](docs/SECURITY.md).
|
|
98
102
|
- `grep` / `glob` use GNU grep and find. They do not read `.gitignore`. Hidden files, `node_modules`, and VCS metadata are skipped.
|
|
99
|
-
-
|
|
103
|
+
- On a new conversation, WSL mode asks which folder inside the distribution should be the workspace. The folder control opens that directory. Skills and file references follow it. Esc closes that visit; leaving WSL mode and choosing it again asks once more. A conversation that has already started cannot change mode.
|
|
100
104
|
- The first call waits for the distribution to start and can take several seconds. `timeoutMs` defaults to 120 seconds.
|
|
101
105
|
- This plugin does not install Node or any other toolchain inside the distribution.
|
|
102
106
|
|
package/README.md
CHANGED
|
@@ -4,17 +4,17 @@
|
|
|
4
4
|
|
|
5
5
|
在 Windows 上给 [DSH](https://github.com/deepseek-ai/DeepSeek-Harness) 一个原生的 WSL(Linux)执行面。不额外启动 web 服务,也不在 WSL 里安装 dsh。Windows 会话和 WSL 会话在同一个桌面端里使用。
|
|
6
6
|
|
|
7
|
-
当前版本 **0.4.
|
|
7
|
+
当前版本 **0.4.7**。需要 **Windows**、一个可用的 WSL 发行版,以及 **DSH `>= 0.2.1-rc.2`**。源码:<https://github.com/TGWgulugu/dsh-wsl-bridge>
|
|
8
8
|
|
|
9
9
|
## 安装
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
插件已发布到 npm:[dsh-wsl-bridge](https://www.npmjs.com/package/dsh-wsl-bridge)。在普通终端里执行下面的命令,DSH 会从 npm 安装这个包。不要在 DSH 会话的工具沙箱里跑。`dsh` 不在 PATH 上时,换成 Desktop 自带的 `dsh.cmd` 完整路径。
|
|
12
12
|
|
|
13
13
|
```powershell
|
|
14
14
|
dsh plugin --profile desktop add dsh-wsl-bridge
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
要装本仓库里的源码,而不是 npm 上的版本时:
|
|
18
18
|
|
|
19
19
|
```powershell
|
|
20
20
|
dsh plugin --profile desktop add .
|
|
@@ -58,17 +58,21 @@ dsh plugin --profile desktop remove dsh-wsl-bridge
|
|
|
58
58
|
|
|
59
59
|
新建会话时选择 **WSL**。这一会话里的 `bash`、`read`、`write`、`edit`、`grep`、`glob` 都在 Linux 里执行,参数是 Linux 路径。persona、skill、goal、plan、compaction、subagent、todo、web、present 仍用 DSH 标准会话的那一套。
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
**工作区由你在当前发行版里选定。** 新建对话并选中 WSL 后,窗口从 `$HOME` 列出该发行版的文件夹。可以进入子文件夹,也可以回到上一级,然后用「使用此文件夹」确认。选中的目录用这台电脑上的 `wslpath -w` 现查,不写死发行版名或用户名。右上角的文件夹按钮打开这个目录,而不是 DSH 自带的 `default-workspace`。
|
|
62
|
+
|
|
63
|
+
按 Esc 或「取消」只关掉这一次。把模式从 WSL 换走,再选回 WSL,窗口会再出现。对话一旦发出第一条消息,DSH 就不再允许更换模式,这个窗口也不会在那条对话里再次出现。
|
|
64
|
+
|
|
65
|
+
第一次 `bash`、相对路径,以及不传 `path` 的 `grep` / `glob`,都从你选的目录开始。要搜 Windows 目录时,显式传入 `/mnt/<盘符>/...`。上面的 `wsl_*` 工具不走这个窗口,仍使用当前 Windows 会话目录。
|
|
62
66
|
|
|
63
67
|
| 工具 | 作用 |
|
|
64
68
|
|---|---|
|
|
65
69
|
| `bash` | 发行版里的持久 bash,语法是 bash,不是 PowerShell |
|
|
66
|
-
| `read` / `write` / `edit` | 读写 Linux
|
|
67
|
-
| `grep` | 发行版内的 GNU grep。结果是 `path:line: text`。不传 `path`
|
|
68
|
-
| `glob` | 按 glob 列出文件,旧文件在前。不传 `path`
|
|
70
|
+
| `read` / `write` / `edit` | 读写 Linux 路径。相对路径相对你选的工作区 |
|
|
71
|
+
| `grep` | 发行版内的 GNU grep。结果是 `path:line: text`。不传 `path` 时搜你选的工作区 |
|
|
72
|
+
| `glob` | 按 glob 列出文件,旧文件在前。不传 `path` 时列你选的工作区 |
|
|
69
73
|
| `wsl_env` | 发行版信息 |
|
|
70
74
|
|
|
71
|
-
|
|
75
|
+
目录列表从 `$HOME` 开始,只列出这一层的文件夹。登录脚本如果往标准输出打字,不会被当成路径。查不到目录时,不会改去打开 Windows 会话目录。选择窗口使用不透明底色,不跟随会把背景透出来的皮肤。
|
|
72
76
|
|
|
73
77
|
## 配置
|
|
74
78
|
|
|
@@ -80,7 +84,7 @@ dsh plugin --profile desktop remove dsh-wsl-bridge
|
|
|
80
84
|
| `timeoutMs` | `120000` | `bash` / `wsl_bash` 的默认时限(毫秒) |
|
|
81
85
|
| `fileTimeoutMs` | `30000` | 读、写、搜索的单次时限(毫秒) |
|
|
82
86
|
| `maxFileBytes` | `2097152` | `read` / `edit` 最多读取的字节数 |
|
|
83
|
-
| `defaultWorkdir` |
|
|
87
|
+
| `defaultWorkdir` | `session` | 宿主 `wsl_*` 工具的 `session` 是当前 Windows 会话目录。WSL 模式的 `session` 是你选的工作区。`home` 使用当前发行版的 `$HOME`。也可以填绝对 Linux 路径 |
|
|
84
88
|
| `dangerGuard` | `true` | 拒绝明显的破坏性命令,除非该次调用带 `allowDangerous: true` |
|
|
85
89
|
| `backgroundJobs` | `true` | 是否允许 `runInBackground`。宿主没有任务注册表时会明确拒绝 |
|
|
86
90
|
| `promptSection` | `true` | 是否加入宿主会话的路由提示 |
|
|
@@ -96,7 +100,7 @@ dsh plugin --profile desktop remove dsh-wsl-bridge
|
|
|
96
100
|
- 只支持 Windows。其他系统上这两行配置会禁用。
|
|
97
101
|
- 工具通过 `wsl.exe` 执行,**不受** DSH 的 Windows 文件沙箱约束。把它看成:发行版里该用户能写的路径,以及通过 `/mnt/<盘符>` 能碰到的 Windows 路径,都可以写。详见 [docs/SECURITY.md](docs/SECURITY.md)。
|
|
98
102
|
- `grep` / `glob` 使用 GNU grep 和 find,不读 `.gitignore`。隐藏文件、`node_modules` 和版本库元数据会跳过。
|
|
99
|
-
-
|
|
103
|
+
- 新建对话选中 WSL 后,从当前发行版里选工作区。右上角的文件夹按钮打开这个目录。技能和文件引用跟着这个工作区走。Esc 只关闭这一次;换走模式再选回 WSL 会再问一次。已开始的对话不能再改模式。
|
|
100
104
|
- 第一次调用要等发行版冷启动,可能要数秒。`timeoutMs` 默认 120 秒。
|
|
101
105
|
- 本插件不在发行版里安装 Node 或其他工具链。
|
|
102
106
|
|
package/client.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: 'dsh-wsl-bridge',
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
const DIRS_ROUTE_PATH = '/api/dsh-wsl-bridge/dirs'
|
|
5
|
+
|
|
6
|
+
function apply(ctx) {
|
|
7
|
+
let React
|
|
8
|
+
try {
|
|
9
|
+
React = require('react')
|
|
10
|
+
} catch (error) {
|
|
11
|
+
ctx.logger?.warn?.('dsh-wsl-bridge: folder picker skipped: ' + messageOf(error))
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
const sessions = readService(ctx, 'sessions')
|
|
15
|
+
const workspaces = readService(ctx, 'workspaces')
|
|
16
|
+
const ui = readService(ctx, 'uiWorkspace')
|
|
17
|
+
const slots = readService(ctx, 'slots')
|
|
18
|
+
if (sessions?.list?.subscribe === undefined || typeof workspaces?.create !== 'function'
|
|
19
|
+
|| typeof ui?.openWorkspace !== 'function' || typeof slots?.inject !== 'function') {
|
|
20
|
+
ctx.logger?.info?.('dsh-wsl-bridge: folder picker left inactive')
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
const store = createPickerStore()
|
|
24
|
+
const pickerState = new Map()
|
|
25
|
+
slots.inject('shell.overlay', () => slots.register({
|
|
26
|
+
name: 'shell.overlay',
|
|
27
|
+
id: 'dsh-wsl-bridge-folder',
|
|
28
|
+
order: 40,
|
|
29
|
+
}, () => React.createElement(FolderPicker, { React, store, workspaces, ui })))
|
|
30
|
+
const sync = () => {
|
|
31
|
+
const sessionList = sessions.list.getSnapshot()
|
|
32
|
+
if (sessionList?.phase !== 'ready') return
|
|
33
|
+
const sessionId = mainViewSessionId(sessionList.byId)
|
|
34
|
+
if (sessionId === undefined) return
|
|
35
|
+
const summary = sessionList.byId?.[sessionId]
|
|
36
|
+
const preset = presetOf(summary, sessionList.projectionsBySession?.[sessionId])
|
|
37
|
+
const decision = nextPickerState(pickerState.get(sessionId), preset, summary?.cwd)
|
|
38
|
+
pickerState.set(sessionId, decision.state)
|
|
39
|
+
if (decision.open && !store.isOpen()) store.open()
|
|
40
|
+
}
|
|
41
|
+
ctx.effect(() => sessions.list.subscribe(sync))
|
|
42
|
+
sync()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function FolderPicker({ React, store, workspaces, ui }) {
|
|
46
|
+
const snap = React.useSyncExternalStore(store.subscribe, store.getSnapshot)
|
|
47
|
+
React.useEffect(() => {
|
|
48
|
+
if (!snap.open) return undefined
|
|
49
|
+
const onKey = (event) => {
|
|
50
|
+
if (event.key === 'Escape') store.dismiss()
|
|
51
|
+
}
|
|
52
|
+
window.addEventListener('keydown', onKey)
|
|
53
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
54
|
+
}, [snap.open, store])
|
|
55
|
+
if (!snap.open) return null
|
|
56
|
+
const h = React.createElement
|
|
57
|
+
const listing = snap.listing
|
|
58
|
+
return h('div', { style: backdropStyle },
|
|
59
|
+
h('div', {
|
|
60
|
+
role: 'dialog',
|
|
61
|
+
'aria-modal': 'true',
|
|
62
|
+
'aria-labelledby': 'dsh-wsl-bridge-title',
|
|
63
|
+
style: panelStyle,
|
|
64
|
+
},
|
|
65
|
+
h('h2', { id: 'dsh-wsl-bridge-title', style: titleStyle }, '选择 WSL 工作区'),
|
|
66
|
+
h('p', { style: hintStyle }, '从当前发行版里选一个文件夹。右上角的文件夹按钮会打开这里,不再打开 default-workspace。'),
|
|
67
|
+
h('div', { style: pathStyle }, listing?.path ?? '正在读取目录…'),
|
|
68
|
+
snap.error === '' ? null : h('p', { style: errorStyle }, snap.error),
|
|
69
|
+
h('div', { style: listStyle },
|
|
70
|
+
listing?.parent ? h('button', {
|
|
71
|
+
type: 'button',
|
|
72
|
+
style: rowStyle,
|
|
73
|
+
onClick: () => store.load(listing.parent.path),
|
|
74
|
+
}, '上一级') : null,
|
|
75
|
+
...(listing?.entries ?? []).map((entry) => h('button', {
|
|
76
|
+
key: entry.path,
|
|
77
|
+
type: 'button',
|
|
78
|
+
style: rowStyle,
|
|
79
|
+
onClick: () => store.load(entry.path),
|
|
80
|
+
}, entry.name + '/')),
|
|
81
|
+
listing && listing.entries.length === 0
|
|
82
|
+
? h('p', { style: hintStyle }, '没有子文件夹,可以直接使用当前目录。')
|
|
83
|
+
: null,
|
|
84
|
+
),
|
|
85
|
+
h('div', { style: actionStyle },
|
|
86
|
+
h('button', { type: 'button', style: secondaryStyle, onClick: () => store.dismiss() }, '取消'),
|
|
87
|
+
h('button', {
|
|
88
|
+
type: 'button',
|
|
89
|
+
style: primaryStyle,
|
|
90
|
+
disabled: listing?.windows === undefined || snap.creating,
|
|
91
|
+
onClick: () => store.confirm(workspaces, ui),
|
|
92
|
+
}, snap.creating ? '正在创建…' : '使用此文件夹'),
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function createPickerStore() {
|
|
99
|
+
let snap = { open: false, listing: null, error: '', creating: false }
|
|
100
|
+
let loadTicket = 0
|
|
101
|
+
const listeners = new Set()
|
|
102
|
+
const publish = (next) => {
|
|
103
|
+
snap = next
|
|
104
|
+
for (const listener of listeners) listener()
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
subscribe(listener) {
|
|
108
|
+
listeners.add(listener)
|
|
109
|
+
return () => listeners.delete(listener)
|
|
110
|
+
},
|
|
111
|
+
getSnapshot() {
|
|
112
|
+
return snap
|
|
113
|
+
},
|
|
114
|
+
isOpen() {
|
|
115
|
+
return snap.open
|
|
116
|
+
},
|
|
117
|
+
open() {
|
|
118
|
+
publish({ open: true, listing: null, error: '', creating: false })
|
|
119
|
+
void this.load('')
|
|
120
|
+
},
|
|
121
|
+
dismiss() {
|
|
122
|
+
publish({ open: false, listing: null, error: '', creating: false })
|
|
123
|
+
},
|
|
124
|
+
async load(linuxPath) {
|
|
125
|
+
const ticket = ++loadTicket
|
|
126
|
+
publish({ ...snap, error: '', creating: false })
|
|
127
|
+
try {
|
|
128
|
+
const listing = await readDirectory(linuxPath)
|
|
129
|
+
if (!snap.open || ticket !== loadTicket) return
|
|
130
|
+
publish({ open: true, listing, error: '', creating: false })
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (!snap.open || ticket !== loadTicket) return
|
|
133
|
+
publish({ ...snap, error: '读不到这个目录:' + messageOf(error), creating: false })
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
async confirm(workspaces, ui) {
|
|
137
|
+
const windowsPath = snap.listing?.windows
|
|
138
|
+
if (typeof windowsPath !== 'string' || snap.creating) return
|
|
139
|
+
publish({ ...snap, creating: true, error: '' })
|
|
140
|
+
try {
|
|
141
|
+
await openChosenWorkspace(workspaces, ui, windowsPath)
|
|
142
|
+
publish({ open: false, listing: null, error: '', creating: false })
|
|
143
|
+
} catch (error) {
|
|
144
|
+
publish({ ...snap, creating: false, error: '没能创建工作区:' + messageOf(error) })
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function readDirectory(linuxPath) {
|
|
151
|
+
const url = linuxPath === undefined || linuxPath === ''
|
|
152
|
+
? DIRS_ROUTE_PATH
|
|
153
|
+
: DIRS_ROUTE_PATH + '?path=' + encodeURIComponent(linuxPath)
|
|
154
|
+
const response = await fetch(url)
|
|
155
|
+
const body = await response.json().catch(() => null)
|
|
156
|
+
if (!response.ok || typeof body?.path !== 'string' || typeof body?.windows !== 'string') {
|
|
157
|
+
throw new Error(typeof body?.error === 'string' ? body.error : 'HTTP ' + response.status)
|
|
158
|
+
}
|
|
159
|
+
return body
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function openChosenWorkspace(workspaces, ui, windowsPath) {
|
|
163
|
+
const created = await workspaces.create({ path: windowsPath })
|
|
164
|
+
const workspace = created?.workspace ?? created?.value?.workspace ?? created
|
|
165
|
+
const id = typeof workspace?.workspaceId === 'string' ? workspace.workspaceId : ''
|
|
166
|
+
if (id === '') throw new Error('WSL workspace was not created')
|
|
167
|
+
await ui.openWorkspace(id, undefined, { agentPreset: 'wsl' })
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function shouldOfferFolderPicker(preset, cwd) {
|
|
171
|
+
return preset === 'wsl' && !isDistributionWorkspacePath(cwd)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function nextPickerState(previous, preset, cwd) {
|
|
175
|
+
if (preset !== 'wsl') return { state: 'other', open: false }
|
|
176
|
+
if (!shouldOfferFolderPicker(preset, cwd)) return { state: 'wsl', open: false }
|
|
177
|
+
if (previous === 'shown' || previous === 'wsl') return { state: previous, open: false }
|
|
178
|
+
return { state: 'shown', open: true }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isDistributionWorkspacePath(value) {
|
|
182
|
+
return /^\\\\wsl(?:\.localhost|\$)\\/iu.test(normalize(value))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function presetOf(summary, projections) {
|
|
186
|
+
const values = summary?.projectionValues ?? projections?.values
|
|
187
|
+
const preset = values?.agentPreset
|
|
188
|
+
return typeof preset === 'string' ? preset : ''
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function mainViewSessionId(byId) {
|
|
192
|
+
if (byId === undefined || byId === null) return undefined
|
|
193
|
+
for (const row of Object.values(byId)) {
|
|
194
|
+
if (row !== undefined && (row.retainedBy?.mainView ?? 0) > 0) {
|
|
195
|
+
return typeof row.id === 'string' ? row.id : undefined
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return undefined
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function normalize(value) {
|
|
202
|
+
return String(value ?? '').replace(/\//gu, '\\').replace(/\\+$/u, '')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function readService(ctx, name) {
|
|
206
|
+
try {
|
|
207
|
+
if (ctx?.[name] !== undefined) return ctx[name]
|
|
208
|
+
return typeof ctx.get === 'function' ? ctx.get(name) : undefined
|
|
209
|
+
} catch {
|
|
210
|
+
return undefined
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function messageOf(error) {
|
|
215
|
+
return error instanceof Error ? error.message : String(error)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const backdropStyle = {
|
|
219
|
+
position: 'fixed',
|
|
220
|
+
inset: 0,
|
|
221
|
+
zIndex: 40,
|
|
222
|
+
display: 'flex',
|
|
223
|
+
alignItems: 'center',
|
|
224
|
+
justifyContent: 'center',
|
|
225
|
+
background: 'rgba(0, 0, 0, 0.62)',
|
|
226
|
+
padding: 24,
|
|
227
|
+
}
|
|
228
|
+
const panelStyle = {
|
|
229
|
+
width: 'min(520px, 100%)',
|
|
230
|
+
maxHeight: 'min(640px, 100%)',
|
|
231
|
+
display: 'flex',
|
|
232
|
+
flexDirection: 'column',
|
|
233
|
+
gap: 12,
|
|
234
|
+
padding: 20,
|
|
235
|
+
borderRadius: 12,
|
|
236
|
+
backgroundColor: 'Canvas',
|
|
237
|
+
color: 'CanvasText',
|
|
238
|
+
border: '1px solid color-mix(in srgb, CanvasText 18%, Canvas)',
|
|
239
|
+
boxShadow: '0 18px 50px rgba(0, 0, 0, 0.35)',
|
|
240
|
+
boxSizing: 'border-box',
|
|
241
|
+
}
|
|
242
|
+
const titleStyle = { margin: 0, fontSize: 18, fontWeight: 600 }
|
|
243
|
+
const hintStyle = { margin: 0, fontSize: 13, lineHeight: 1.5, color: 'color-mix(in srgb, CanvasText 78%, Canvas)' }
|
|
244
|
+
const pathStyle = {
|
|
245
|
+
fontFamily: 'ui-monospace, monospace',
|
|
246
|
+
fontSize: 13,
|
|
247
|
+
padding: '8px 10px',
|
|
248
|
+
borderRadius: 8,
|
|
249
|
+
backgroundColor: 'color-mix(in srgb, CanvasText 8%, Canvas)',
|
|
250
|
+
color: 'CanvasText',
|
|
251
|
+
overflow: 'hidden',
|
|
252
|
+
textOverflow: 'ellipsis',
|
|
253
|
+
whiteSpace: 'nowrap',
|
|
254
|
+
}
|
|
255
|
+
const errorStyle = { margin: 0, fontSize: 13, color: '#b42318' }
|
|
256
|
+
const listStyle = {
|
|
257
|
+
display: 'flex',
|
|
258
|
+
flexDirection: 'column',
|
|
259
|
+
gap: 4,
|
|
260
|
+
minHeight: 160,
|
|
261
|
+
maxHeight: 320,
|
|
262
|
+
overflow: 'auto',
|
|
263
|
+
}
|
|
264
|
+
const rowStyle = {
|
|
265
|
+
textAlign: 'left',
|
|
266
|
+
border: '1px solid color-mix(in srgb, CanvasText 16%, Canvas)',
|
|
267
|
+
backgroundColor: 'color-mix(in srgb, CanvasText 6%, Canvas)',
|
|
268
|
+
color: 'CanvasText',
|
|
269
|
+
borderRadius: 8,
|
|
270
|
+
padding: '8px 10px',
|
|
271
|
+
cursor: 'pointer',
|
|
272
|
+
font: 'inherit',
|
|
273
|
+
}
|
|
274
|
+
const actionStyle = { display: 'flex', justifyContent: 'flex-end', gap: 8 }
|
|
275
|
+
const secondaryStyle = {
|
|
276
|
+
font: 'inherit',
|
|
277
|
+
padding: '8px 14px',
|
|
278
|
+
borderRadius: 8,
|
|
279
|
+
border: '1px solid color-mix(in srgb, CanvasText 22%, Canvas)',
|
|
280
|
+
backgroundColor: 'Canvas',
|
|
281
|
+
color: 'CanvasText',
|
|
282
|
+
cursor: 'pointer',
|
|
283
|
+
}
|
|
284
|
+
const primaryStyle = {
|
|
285
|
+
font: 'inherit',
|
|
286
|
+
padding: '8px 14px',
|
|
287
|
+
borderRadius: 8,
|
|
288
|
+
border: 0,
|
|
289
|
+
backgroundColor: '#247bbf',
|
|
290
|
+
color: '#fff',
|
|
291
|
+
cursor: 'pointer',
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
inject: ['slots', 'sessions', 'workspaces', 'uiWorkspace'],
|
|
296
|
+
apply,
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
})
|
package/cordis.patch.yml
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
config:
|
|
23
23
|
id: wsl
|
|
24
24
|
name: WSL
|
|
25
|
-
description: '
|
|
25
|
+
description: '在当前 WSL 发行版里使用 Linux 路径。新建对话并选中后,从该发行版的文件夹里指定工作区。'
|
|
26
26
|
order: 2
|
|
27
27
|
plugins:
|
|
28
28
|
- id: persona
|
|
@@ -30,11 +30,12 @@
|
|
|
30
30
|
config:
|
|
31
31
|
prefix: >-
|
|
32
32
|
You are a coding agent powered by the {{model}} model. Your working
|
|
33
|
-
directory is the
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
directory is the folder chosen as this session's workspace inside
|
|
34
|
+
the current WSL distribution. When asked for the current workspace,
|
|
35
|
+
name that folder. bash, read, write, edit, grep and glob take Linux
|
|
36
|
+
paths. grep and glob search that folder unless you pass another
|
|
37
|
+
path. The Windows filesystem is reachable as /mnt/<drive>. Use
|
|
38
|
+
bash syntax for commands, not PowerShell.
|
|
38
39
|
- id: agent-instructions
|
|
39
40
|
name: '@deepseek-ai/dsh-agent-instructions'
|
|
40
41
|
config:
|
|
@@ -46,7 +47,7 @@
|
|
|
46
47
|
config:
|
|
47
48
|
distro: ''
|
|
48
49
|
timeoutMs: 120000
|
|
49
|
-
defaultWorkdir:
|
|
50
|
+
defaultWorkdir: session
|
|
50
51
|
- id: tool-jobs
|
|
51
52
|
name: '@deepseek-ai/dsh-tool-jobs'
|
|
52
53
|
- id: tool-schedule
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -53,9 +53,10 @@ model tool call
|
|
|
53
53
|
ACLs and extended attributes are not copied from the old inode.
|
|
54
54
|
- **Paths**: Windows drive paths map to `/mnt/<drive>`. Host `wsl_*` tools still
|
|
55
55
|
translate the session directory into the default workdir. A `WSL` session
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
the distribution's own `wslpath`
|
|
56
|
+
asks for a folder inside the distribution and opens that directory as the
|
|
57
|
+
workspace (`wslpath -w` of the chosen path). Relative paths and grep/glob
|
|
58
|
+
follow that workspace. `wsl_path` prefers the distribution's own `wslpath`
|
|
59
|
+
and falls back to the host-side mapping.
|
|
59
60
|
- **grep/glob** run GNU grep/find inside the distribution (the host search suite
|
|
60
61
|
ships a Windows ripgrep, which cannot see Linux paths). They prune hidden
|
|
61
62
|
entries and `node_modules`/VCS metadata; `.gitignore` is not read (documented).
|
package/index.js
CHANGED
|
@@ -29,6 +29,9 @@ import { appendFileSync } from 'node:fs'
|
|
|
29
29
|
import { resolveConfig } from './src/config.js'
|
|
30
30
|
import { registerPromptSection } from './src/prompt.js'
|
|
31
31
|
import { runSelfCheck } from './src/self-check.js'
|
|
32
|
+
import { registerHomeRoute } from './src/home-route.js'
|
|
33
|
+
import { registerDirRoute } from './src/dir-route.js'
|
|
34
|
+
import { createHomeLookup, ensureWslHomeWorkspace } from './src/session-home.js'
|
|
32
35
|
import { createShellPool } from './src/shell.js'
|
|
33
36
|
import { buildTools } from './src/tools.js'
|
|
34
37
|
|
|
@@ -76,6 +79,18 @@ export function apply(ctx, rawConfig) {
|
|
|
76
79
|
ctx.on('agent/disposed', ({ agent }) => {
|
|
77
80
|
pool.dispose(agent, 'agent disposed')
|
|
78
81
|
})
|
|
82
|
+
const homeWindows = createHomeLookup(pool, config)
|
|
83
|
+
// Routes stay unwired until the desktop web server exists. Reading it
|
|
84
|
+
// without inject throws, and the page then gets the app shell instead of JSON.
|
|
85
|
+
if (typeof ctx.inject === 'function') {
|
|
86
|
+
ctx.inject(['webServer'], (inner) => {
|
|
87
|
+
registerHomeRoute(inner, homeWindows)
|
|
88
|
+
registerDirRoute(inner, pool, config)
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
void ensureWslHomeWorkspace(ctx, { pool, config, homeWindows }).catch((error) => {
|
|
92
|
+
ctx.logger?.warn?.('dsh-wsl-bridge: WSL home workspace not registered: ' + messageOf(error))
|
|
93
|
+
})
|
|
79
94
|
|
|
80
95
|
const tools = buildTools({ pool, config, subprocess, jobs })
|
|
81
96
|
for (const tool of tools) {
|
package/locale/en.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "WSL Bridge",
|
|
4
|
-
"description": "Native WSL execution for DSH on Windows: persistent Linux bash, Linux-path file and search tools, and a WSL session mode
|
|
4
|
+
"description": "Native WSL execution for DSH on Windows: persistent Linux bash, Linux-path file and search tools, and a WSL session mode. A new conversation in WSL mode asks which folder inside the current distribution should be the workspace. No extra web server, nothing to install inside the distribution."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/zh.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "WSL 桥",
|
|
4
|
-
"description": "在 Windows 上给 DSH 原生 WSL 执行面:持久 Linux bash、Linux 路径的文件与搜索工具,以及 WSL
|
|
4
|
+
"description": "在 Windows 上给 DSH 原生 WSL 执行面:持久 Linux bash、Linux 路径的文件与搜索工具,以及 WSL 会话模式。新建对话选中 WSL 后,从当前发行版的文件夹里指定工作区。不额外起 web 服务,WSL 内无需安装任何东西。"
|
|
5
5
|
}
|
|
6
6
|
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-wsl-bridge",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./index.js",
|
|
8
8
|
"./wsl-world": "./wsl-world.js",
|
|
9
|
+
"./client": "./client.js",
|
|
9
10
|
"./locale/*.json": "./locale/*.json",
|
|
10
11
|
"./icon": "./icon.svg"
|
|
11
12
|
},
|
|
@@ -18,6 +19,14 @@
|
|
|
18
19
|
},
|
|
19
20
|
"bundle": {
|
|
20
21
|
"patch": "./cordis.patch.yml"
|
|
22
|
+
},
|
|
23
|
+
"client": {
|
|
24
|
+
"inject": [
|
|
25
|
+
"@deepseek-ai/dsh-api-session-controller",
|
|
26
|
+
"@deepseek-ai/dsh-api-workspace-controller",
|
|
27
|
+
"@deepseek-ai/dsh-client-ui-workspace"
|
|
28
|
+
],
|
|
29
|
+
"platform": "web"
|
|
21
30
|
}
|
|
22
31
|
},
|
|
23
32
|
"peerDependencies": {
|
|
@@ -26,6 +35,7 @@
|
|
|
26
35
|
"files": [
|
|
27
36
|
"index.js",
|
|
28
37
|
"wsl-world.js",
|
|
38
|
+
"client.js",
|
|
29
39
|
"src/**/*.js",
|
|
30
40
|
"cordis.patch.yml",
|
|
31
41
|
"locale/*.json",
|
|
@@ -48,7 +58,7 @@
|
|
|
48
58
|
"url": "https://github.com/TGWgulugu/dsh-wsl-bridge/issues"
|
|
49
59
|
},
|
|
50
60
|
"homepage": "https://github.com/TGWgulugu/dsh-wsl-bridge#readme",
|
|
51
|
-
"description": "Native WSL execution for DeepSeek Harness on Windows: persistent Linux bash, Linux-path file and search tools, and a
|
|
61
|
+
"description": "Native WSL execution for DeepSeek Harness on Windows: persistent Linux bash, Linux-path file and search tools, and a WSL session mode that asks which distribution folder to use as the workspace. No extra web server and nothing to install inside the distribution.",
|
|
52
62
|
"keywords": [
|
|
53
63
|
"dsh",
|
|
54
64
|
"dsh-plugin",
|
|
@@ -60,7 +70,7 @@
|
|
|
60
70
|
"workspace"
|
|
61
71
|
],
|
|
62
72
|
"scripts": {
|
|
63
|
-
"test": "node test/protocol.test.js && node test/shell.test.js && node test/tools.test.js && node test/world-tools.test.js && node test/guard.test.js && node test/jobs.test.js && node test/fsops.test.js",
|
|
73
|
+
"test": "node test/protocol.test.js && node test/shell.test.js && node test/tools.test.js && node test/world-tools.test.js && node test/session-home.test.js && node test/dir-list.test.js && node test/client-workspace.test.js && node test/guard.test.js && node test/jobs.test.js && node test/fsops.test.js",
|
|
64
74
|
"test:integration": "node test/integration.test.js"
|
|
65
75
|
}
|
|
66
76
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decide when the WSL folder picker should open, and open the folder the
|
|
3
|
+
* user picked. The browser entry cannot import this file.
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-wsl-bridge/client-workspace
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A workspace already lives inside a distribution when its Windows path is
|
|
10
|
+
* `\\wsl.localhost\...` or `\\wsl$\...`. Drive letters are Windows folders.
|
|
11
|
+
*
|
|
12
|
+
* @param {unknown} value
|
|
13
|
+
* @returns {boolean}
|
|
14
|
+
*/
|
|
15
|
+
export function isDistributionWorkspacePath(value) {
|
|
16
|
+
return /^\\\\wsl(?:\.localhost|\$)\\/iu.test(normalize(value))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Offer the picker when this session is in WSL mode and its folder is still
|
|
21
|
+
* a Windows directory, including DSH's first-use `default-workspace`.
|
|
22
|
+
*
|
|
23
|
+
* @param {unknown} preset
|
|
24
|
+
* @param {unknown} cwd
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
export function shouldOfferFolderPicker(preset, cwd) {
|
|
28
|
+
return preset === 'wsl' && !isDistributionWorkspacePath(cwd)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Decide whether this snapshot should open the folder picker.
|
|
33
|
+
*
|
|
34
|
+
* `shown` means this visit to WSL mode already opened the window. Leaving
|
|
35
|
+
* WSL mode records `other`, so choosing WSL again asks one more time.
|
|
36
|
+
* Esc does not lock the session permanently.
|
|
37
|
+
*
|
|
38
|
+
* @param {string|undefined} previous
|
|
39
|
+
* @param {string} preset
|
|
40
|
+
* @param {unknown} cwd
|
|
41
|
+
* @returns {{state: string, open: boolean}}
|
|
42
|
+
*/
|
|
43
|
+
export function nextPickerState(previous, preset, cwd) {
|
|
44
|
+
if (preset !== 'wsl') return { state: 'other', open: false }
|
|
45
|
+
if (!shouldOfferFolderPicker(preset, cwd)) return { state: 'wsl', open: false }
|
|
46
|
+
if (previous === 'shown' || previous === 'wsl') return { state: previous, open: false }
|
|
47
|
+
return { state: 'shown', open: true }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {object|undefined} summary
|
|
52
|
+
* @param {object|undefined} projections
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function presetOf(summary, projections) {
|
|
56
|
+
const values = summary?.projectionValues ?? projections?.values
|
|
57
|
+
const preset = values?.agentPreset
|
|
58
|
+
return typeof preset === 'string' ? preset : ''
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {Record<string, {id?: string, retainedBy?: {mainView?: number}}>|undefined} byId
|
|
63
|
+
* @returns {string|undefined}
|
|
64
|
+
*/
|
|
65
|
+
export function mainViewSessionId(byId) {
|
|
66
|
+
if (byId === undefined || byId === null) return undefined
|
|
67
|
+
for (const row of Object.values(byId)) {
|
|
68
|
+
if (row !== undefined && (row.retainedBy?.mainView ?? 0) > 0) {
|
|
69
|
+
return typeof row.id === 'string' ? row.id : undefined
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return undefined
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {object|undefined} created
|
|
77
|
+
* @returns {string|undefined}
|
|
78
|
+
*/
|
|
79
|
+
export function workspaceIdFromCreate(created) {
|
|
80
|
+
const workspace = created?.workspace ?? created?.value?.workspace ?? created
|
|
81
|
+
return typeof workspace?.workspaceId === 'string' ? workspace.workspaceId : undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Register the picked Windows path and open it as a WSL session.
|
|
86
|
+
* The path is whatever `wslpath -w` printed for the folder the user chose.
|
|
87
|
+
*
|
|
88
|
+
* @param {object} workspaces
|
|
89
|
+
* @param {object} ui
|
|
90
|
+
* @param {string} windowsPath
|
|
91
|
+
* @returns {Promise<string>}
|
|
92
|
+
*/
|
|
93
|
+
export async function openChosenWorkspace(workspaces, ui, windowsPath) {
|
|
94
|
+
if (typeof windowsPath !== 'string' || windowsPath === '') throw new Error('WSL folder is missing')
|
|
95
|
+
if (typeof workspaces?.create !== 'function' || typeof ui?.openWorkspace !== 'function') {
|
|
96
|
+
throw new Error('workspace services are unavailable')
|
|
97
|
+
}
|
|
98
|
+
const created = await workspaces.create({ path: windowsPath })
|
|
99
|
+
const id = workspaceIdFromCreate(created)
|
|
100
|
+
if (id === undefined) throw new Error('WSL workspace was not created')
|
|
101
|
+
await ui.openWorkspace(id, undefined, { agentPreset: 'wsl' })
|
|
102
|
+
return id
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {unknown} value
|
|
107
|
+
* @returns {string}
|
|
108
|
+
*/
|
|
109
|
+
function normalize(value) {
|
|
110
|
+
return String(value ?? '').replace(/\//gu, '\\').replace(/\\+$/u, '')
|
|
111
|
+
}
|
package/src/dir-list.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List one directory inside the current WSL distribution.
|
|
3
|
+
* The path is passed as base64 so a name cannot change the command.
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-wsl-bridge/dir-list
|
|
6
|
+
*/
|
|
7
|
+
import { isWindowsPath } from './paths.js'
|
|
8
|
+
|
|
9
|
+
const MAX_ENTRIES = 400
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {unknown} value
|
|
13
|
+
* @returns {boolean}
|
|
14
|
+
*/
|
|
15
|
+
export function isListedLinuxPath(value) {
|
|
16
|
+
return typeof value === 'string'
|
|
17
|
+
&& value.startsWith('/')
|
|
18
|
+
&& !value.includes('\0')
|
|
19
|
+
&& !/[\r\n]/u.test(value)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} [linuxPath] absolute Linux directory, or empty to start at `$HOME`
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
export function directoryListCommand(linuxPath) {
|
|
27
|
+
const assignment = linuxPath === undefined || linuxPath === null || linuxPath === ''
|
|
28
|
+
? 'dir=$HOME'
|
|
29
|
+
: 'dir=$(printf %s ' + quoteBase64(linuxPath) + ' | base64 -d)'
|
|
30
|
+
return [
|
|
31
|
+
assignment,
|
|
32
|
+
'case $dir in /*) ;; *) exit 2 ;; esac',
|
|
33
|
+
'dir=${dir%/}',
|
|
34
|
+
'[ -n "$dir" ] || dir=/',
|
|
35
|
+
'[ -d "$dir" ] || exit 3',
|
|
36
|
+
'win=$(wslpath -w "$dir") || exit 4',
|
|
37
|
+
"printf '\\n__WSL_CWD__%s\\n' \"$(printf %s \"$dir\" | base64 | tr -d '\\n')\"",
|
|
38
|
+
"printf '__WSL_CWDWIN__%s\\n' \"$(printf %s \"$win\" | base64 | tr -d '\\n')\"",
|
|
39
|
+
'parent=$(dirname -- "$dir")',
|
|
40
|
+
'if [ "$parent" != "$dir" ]; then',
|
|
41
|
+
' pwin=$(wslpath -w "$parent") || exit 4',
|
|
42
|
+
" printf '__WSL_PARENT__%s\\n' \"$(printf %s \"$parent\" | base64 | tr -d '\\n')\"",
|
|
43
|
+
" printf '__WSL_PARENTWIN__%s\\n' \"$(printf %s \"$pwin\" | base64 | tr -d '\\n')\"",
|
|
44
|
+
'fi',
|
|
45
|
+
'find "$dir" -mindepth 1 -maxdepth 1 -type d -printf \'%f\\n\' 2>/dev/null | sort | while IFS= read -r name; do',
|
|
46
|
+
' [ -n "$name" ] || continue',
|
|
47
|
+
" printf '__WSL_NAME__%s\\n' \"$(printf %s \"$name\" | base64 | tr -d '\\n')\"",
|
|
48
|
+
'done',
|
|
49
|
+
].join('\n')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {string} stdout
|
|
54
|
+
* @returns {{path: string, windows: string, parent: {path: string, windows: string}|null, entries: {name: string, path: string, windows: string}[]}|null}
|
|
55
|
+
*/
|
|
56
|
+
export function parseDirectoryListing(stdout) {
|
|
57
|
+
const path = decodeMarker(stdout, 'WSL_CWD')
|
|
58
|
+
const windows = normalizeWindows(decodeMarker(stdout, 'WSL_CWDWIN'))
|
|
59
|
+
if (!path.startsWith('/') || !isWindowsPath(windows)) return null
|
|
60
|
+
const parentPath = decodeMarker(stdout, 'WSL_PARENT')
|
|
61
|
+
const parentWindows = normalizeWindows(decodeMarker(stdout, 'WSL_PARENTWIN'))
|
|
62
|
+
const parent = parentPath.startsWith('/') && isWindowsPath(parentWindows)
|
|
63
|
+
? { path: parentPath, windows: parentWindows }
|
|
64
|
+
: null
|
|
65
|
+
/** @type {{name: string, path: string, windows: string}[]} */
|
|
66
|
+
const entries = []
|
|
67
|
+
const linuxRoot = path.replace(/\/+$/u, '') || '/'
|
|
68
|
+
const windowsRoot = windows.replace(/\\+$/u, '')
|
|
69
|
+
for (const match of String(stdout ?? '').matchAll(/__WSL_NAME__([^\r\n]*)/gu)) {
|
|
70
|
+
if (entries.length >= MAX_ENTRIES) break
|
|
71
|
+
const name = decodeBase64(match[1].trim())
|
|
72
|
+
if (name === '' || name === '.' || name === '..' || name.includes('/') || name.includes('\\') || name.includes('\0')) continue
|
|
73
|
+
const childPath = (linuxRoot === '/' ? '' : linuxRoot) + '/' + name
|
|
74
|
+
const windowsPath = windowsRoot + '\\' + name
|
|
75
|
+
if (!isWindowsPath(windowsPath)) continue
|
|
76
|
+
entries.push({ name, path: childPath, windows: windowsPath })
|
|
77
|
+
}
|
|
78
|
+
entries.sort((left, right) => left.name.localeCompare(right.name))
|
|
79
|
+
return { path, windows, parent, entries }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {string} linuxPath
|
|
84
|
+
* @returns {string}
|
|
85
|
+
*/
|
|
86
|
+
function quoteBase64(linuxPath) {
|
|
87
|
+
if (!isListedLinuxPath(linuxPath)) throw new Error('path rejected')
|
|
88
|
+
const encoded = Buffer.from(linuxPath, 'utf8').toString('base64')
|
|
89
|
+
if (!/^[A-Za-z0-9+/=]+$/u.test(encoded)) throw new Error('path rejected')
|
|
90
|
+
return "'" + encoded + "'"
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {string} stdout
|
|
95
|
+
* @param {string} name
|
|
96
|
+
* @returns {string}
|
|
97
|
+
*/
|
|
98
|
+
function decodeMarker(stdout, name) {
|
|
99
|
+
const matches = [...String(stdout ?? '').matchAll(new RegExp('__' + name + '__([^\\r\\n]*)', 'gu'))]
|
|
100
|
+
if (matches.length === 0) return ''
|
|
101
|
+
return decodeBase64(matches[matches.length - 1][1].trim())
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {string} value
|
|
106
|
+
* @returns {string}
|
|
107
|
+
*/
|
|
108
|
+
function decodeBase64(value) {
|
|
109
|
+
if (value === '' || !/^[A-Za-z0-9+/=]+$/u.test(value)) return ''
|
|
110
|
+
const text = Buffer.from(value, 'base64').toString('utf8')
|
|
111
|
+
return text.includes('\0') ? '' : text
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param {string} value
|
|
116
|
+
* @returns {string}
|
|
117
|
+
*/
|
|
118
|
+
function normalizeWindows(value) {
|
|
119
|
+
return String(value ?? '').replace(/\//gu, '\\')
|
|
120
|
+
}
|
package/src/dir-route.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List one WSL directory for the folder picker.
|
|
3
|
+
* Answers only on loopback. An omitted path starts at `$HOME`.
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-wsl-bridge/dir-route
|
|
6
|
+
*/
|
|
7
|
+
import { directoryListCommand, isListedLinuxPath, parseDirectoryListing } from './dir-list.js'
|
|
8
|
+
import { isLoopback } from './home-route.js'
|
|
9
|
+
|
|
10
|
+
export const DIRS_ROUTE_PATH = '/api/dsh-wsl-bridge/dirs'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {object} ctx
|
|
14
|
+
* @param {object} pool
|
|
15
|
+
* @param {object} config
|
|
16
|
+
*/
|
|
17
|
+
export function registerDirRoute(ctx, pool, config) {
|
|
18
|
+
const webServer = readService(ctx, 'webServer')
|
|
19
|
+
if (webServer === undefined || typeof webServer.register !== 'function') return
|
|
20
|
+
ctx.effect(() => webServer.register(dirRoute(pool, config)), 'dsh-wsl-bridge: directory route')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {object} pool
|
|
25
|
+
* @param {object} config
|
|
26
|
+
* @returns {{kind: string, path: string, handler: Function}}
|
|
27
|
+
*/
|
|
28
|
+
export function dirRoute(pool, config) {
|
|
29
|
+
return {
|
|
30
|
+
kind: 'prefix',
|
|
31
|
+
path: DIRS_ROUTE_PATH,
|
|
32
|
+
async handler(req, res) {
|
|
33
|
+
try {
|
|
34
|
+
if (!isLoopback(req)) {
|
|
35
|
+
writeJson(res, 403, { error: 'forbidden' })
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
if (req?.method !== 'GET') {
|
|
39
|
+
writeJson(res, 405, { error: 'method-not-allowed' })
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
const url = new URL(req?.url ?? '/', 'http://127.0.0.1')
|
|
43
|
+
if (url.pathname !== DIRS_ROUTE_PATH) {
|
|
44
|
+
writeJson(res, 404, { error: 'not-found' })
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
const requested = url.searchParams.get('path') ?? ''
|
|
48
|
+
if (requested !== '' && !isListedLinuxPath(requested)) {
|
|
49
|
+
writeJson(res, 400, { error: 'path rejected' })
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
const result = await pool.runOnce({
|
|
53
|
+
command: directoryListCommand(requested),
|
|
54
|
+
timeoutMs: config?.fileTimeoutMs,
|
|
55
|
+
})
|
|
56
|
+
const listing = result?.exitCode === 0 && result?.timedOut !== true && result?.aborted !== true
|
|
57
|
+
? parseDirectoryListing(result.stdout)
|
|
58
|
+
: null
|
|
59
|
+
if (listing === null) {
|
|
60
|
+
const detail = String(result?.stderr ?? '').trim()
|
|
61
|
+
writeJson(res, 503, { error: detail === '' ? 'could not list the WSL directory' : detail })
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
writeJson(res, 200, listing)
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (res.headersSent) return
|
|
67
|
+
writeJson(res, 503, { error: messageOf(error) })
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {object} res
|
|
75
|
+
* @param {number} status
|
|
76
|
+
* @param {object} body
|
|
77
|
+
*/
|
|
78
|
+
function writeJson(res, status, body) {
|
|
79
|
+
const payload = JSON.stringify(body)
|
|
80
|
+
res.writeHead(status, {
|
|
81
|
+
'content-type': 'application/json; charset=utf-8',
|
|
82
|
+
'cache-control': 'no-store',
|
|
83
|
+
'content-length': Buffer.byteLength(payload),
|
|
84
|
+
})
|
|
85
|
+
res.end(payload)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {object} ctx
|
|
90
|
+
* @param {string} name
|
|
91
|
+
* @returns {object|undefined}
|
|
92
|
+
*/
|
|
93
|
+
function readService(ctx, name) {
|
|
94
|
+
try {
|
|
95
|
+
return typeof ctx.get === 'function' ? ctx.get(name) : undefined
|
|
96
|
+
} catch {
|
|
97
|
+
return undefined
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @param {unknown} error @returns {string} */
|
|
102
|
+
function messageOf(error) {
|
|
103
|
+
return error instanceof Error ? error.message : String(error)
|
|
104
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tell the desktop page which directory `wslpath -w "$HOME"` returned.
|
|
3
|
+
* The path is computed on the host that is running the plugin. The route
|
|
4
|
+
* answers only on loopback.
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-wsl-bridge/home-route
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const HOME_ROUTE_PATH = '/api/dsh-wsl-bridge/home'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {object} ctx
|
|
13
|
+
* @param {() => Promise<string>} homeWindows
|
|
14
|
+
*/
|
|
15
|
+
export function registerHomeRoute(ctx, homeWindows) {
|
|
16
|
+
const webServer = readService(ctx, 'webServer')
|
|
17
|
+
if (webServer === undefined || typeof webServer.register !== 'function') return
|
|
18
|
+
ctx.effect(() => webServer.register(homeRoute(homeWindows)), 'dsh-wsl-bridge: home route')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {() => Promise<string>} homeWindows
|
|
23
|
+
* @returns {{kind: string, path: string, handler: Function}}
|
|
24
|
+
*/
|
|
25
|
+
export function homeRoute(homeWindows) {
|
|
26
|
+
return {
|
|
27
|
+
kind: 'exact',
|
|
28
|
+
path: HOME_ROUTE_PATH,
|
|
29
|
+
handler(req, res) {
|
|
30
|
+
if (!isLoopback(req)) {
|
|
31
|
+
writeJson(res, 403, { error: 'forbidden' })
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
if (req?.method !== 'GET') {
|
|
35
|
+
writeJson(res, 405, { error: 'method-not-allowed' })
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
Promise.resolve(homeWindows()).then(
|
|
39
|
+
(path) => writeJson(res, 200, { path }),
|
|
40
|
+
(error) => writeJson(res, 503, { error: messageOf(error) }),
|
|
41
|
+
)
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {object|undefined} req
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
export function isLoopback(req) {
|
|
51
|
+
const address = String(req?.socket?.remoteAddress ?? '')
|
|
52
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {object} res
|
|
57
|
+
* @param {number} status
|
|
58
|
+
* @param {object} body
|
|
59
|
+
*/
|
|
60
|
+
function writeJson(res, status, body) {
|
|
61
|
+
const payload = JSON.stringify(body)
|
|
62
|
+
res.writeHead(status, {
|
|
63
|
+
'content-type': 'application/json; charset=utf-8',
|
|
64
|
+
'cache-control': 'no-store',
|
|
65
|
+
'content-length': Buffer.byteLength(payload),
|
|
66
|
+
})
|
|
67
|
+
res.end(payload)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {object} ctx
|
|
72
|
+
* @param {string} name
|
|
73
|
+
* @returns {object|undefined}
|
|
74
|
+
*/
|
|
75
|
+
function readService(ctx, name) {
|
|
76
|
+
try {
|
|
77
|
+
return typeof ctx.get === 'function' ? ctx.get(name) : undefined
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** @param {unknown} error @returns {string} */
|
|
84
|
+
function messageOf(error) {
|
|
85
|
+
return error instanceof Error ? error.message : String(error)
|
|
86
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve this computer's WSL home (`wslpath -w "$HOME"`) and register it.
|
|
3
|
+
* The folder the session actually uses is chosen in the desktop picker.
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-wsl-bridge/session-home
|
|
6
|
+
*/
|
|
7
|
+
import { isWindowsPath } from './paths.js'
|
|
8
|
+
|
|
9
|
+
/** One-shot probe. Login noise may precede the markers; the last one wins. */
|
|
10
|
+
export const HOME_PROBE_COMMAND = "printf '\\n__WSL_HOME__%s\\n__WSL_WINHOME__%s\\n' \"$HOME\" \"$(wslpath -w \"$HOME\")\""
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} stdout
|
|
14
|
+
* @returns {{linux: string, windows: string}|null}
|
|
15
|
+
*/
|
|
16
|
+
export function parseHomeProbe(stdout) {
|
|
17
|
+
const linux = lastMarker(stdout, 'WSL_HOME')
|
|
18
|
+
const windows = lastMarker(stdout, 'WSL_WINHOME').replace(/\//gu, '\\')
|
|
19
|
+
if (!linux.startsWith('/') || linux.includes('\0') || !isWindowsPath(windows)) return null
|
|
20
|
+
return { linux, windows }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {unknown} left
|
|
25
|
+
* @param {unknown} right
|
|
26
|
+
* @returns {boolean}
|
|
27
|
+
*/
|
|
28
|
+
export function sameWindowsDirectory(left, right) {
|
|
29
|
+
const norm = (value) => String(value ?? '').replace(/\//gu, '\\').replace(/\\+$/u, '').toLowerCase()
|
|
30
|
+
return norm(left) !== '' && norm(left) === norm(right)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Read `$HOME` through `wslpath -w` once and reuse that Windows path.
|
|
35
|
+
* Nothing here names a distribution or a user.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} pool
|
|
38
|
+
* @param {object} config
|
|
39
|
+
* @returns {() => Promise<string>}
|
|
40
|
+
*/
|
|
41
|
+
export function createHomeLookup(pool, config) {
|
|
42
|
+
/** @type {Promise<string>|undefined} */
|
|
43
|
+
let pending
|
|
44
|
+
return function homeWindows() {
|
|
45
|
+
pending ??= readHomeWindows(pool, config).catch((error) => {
|
|
46
|
+
pending = undefined
|
|
47
|
+
throw error
|
|
48
|
+
})
|
|
49
|
+
return pending
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Register the probed home as a workspace. An existing registration is reused.
|
|
55
|
+
*
|
|
56
|
+
* @param {object} ctx
|
|
57
|
+
* @param {{pool: object, config: object, homeWindows?: () => Promise<string>}} options
|
|
58
|
+
*/
|
|
59
|
+
export async function ensureWslHomeWorkspace(ctx, { pool, config, homeWindows }) {
|
|
60
|
+
const registry = readService(ctx, 'workspaceRegistry')
|
|
61
|
+
if (registry === undefined || typeof registry.create !== 'function') return
|
|
62
|
+
const lookup = homeWindows ?? createHomeLookup(pool, config)
|
|
63
|
+
await registry.create(await lookup())
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {object} pool
|
|
68
|
+
* @param {object} config
|
|
69
|
+
* @returns {Promise<string>}
|
|
70
|
+
*/
|
|
71
|
+
async function readHomeWindows(pool, config) {
|
|
72
|
+
const result = await pool.runOnce({
|
|
73
|
+
command: HOME_PROBE_COMMAND,
|
|
74
|
+
timeoutMs: config.fileTimeoutMs,
|
|
75
|
+
})
|
|
76
|
+
const parsed = result?.exitCode === 0 && result?.timedOut !== true && result?.aborted !== true
|
|
77
|
+
? parseHomeProbe(result.stdout)
|
|
78
|
+
: null
|
|
79
|
+
if (parsed === null) {
|
|
80
|
+
const detail = String(result?.stderr ?? '').trim()
|
|
81
|
+
throw new Error('could not resolve the WSL home directory' + (detail === '' ? '' : ': ' + detail))
|
|
82
|
+
}
|
|
83
|
+
return parsed.windows
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {object} ctx
|
|
88
|
+
* @param {string} name
|
|
89
|
+
* @returns {object|undefined}
|
|
90
|
+
*/
|
|
91
|
+
function readService(ctx, name) {
|
|
92
|
+
try {
|
|
93
|
+
return typeof ctx.get === 'function' ? ctx.get(name) : undefined
|
|
94
|
+
} catch {
|
|
95
|
+
return undefined
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {string} text
|
|
101
|
+
* @param {string} name
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
function lastMarker(text, name) {
|
|
105
|
+
const matches = [...String(text ?? '').matchAll(new RegExp('__' + name + '__([^\\r\\n]*)', 'gu'))]
|
|
106
|
+
return matches.length === 0 ? '' : matches[matches.length - 1][1].trim()
|
|
107
|
+
}
|