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 CHANGED
@@ -1,6 +1,19 @@
1
1
  # Changelog
2
2
 
3
- ## 0.4.6 — 2026-10-19 (unreleased)
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.6**. Requires **Windows**, a working WSL distribution, and **DSH `>= 0.2.1-rc.2`**. Source: <https://github.com/TGWgulugu/dsh-wsl-bridge>
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
- Before the package is published, or to install the checkout you have open:
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
- **The default workspace is the current distribution's home directory (`$HOME`).** The first `bash` starts there. Relative paths resolve there. `grep` and `glob` search there when `path` is omitted. Pass `/mnt/<drive>/...` when the search should cover a Windows directory.
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 `$HOME` |
67
- | `grep` | GNU grep in the distribution. Rows look like `path:line: text`. Omitting `path` searches `$HOME` |
68
- | `glob` | Lists files matching a glob, oldest first. Omitting `path` lists `$HOME` |
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` is resolved once. Text that a login profile prints is not treated as the path. If the lookup fails, search does not fall back to the Windows session directory.
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` for host tools; `home` for WSL mode | `session` translates the Windows session directory to `/mnt/...`. `home` uses the distribution's `$HOME`. An absolute Linux path is also accepted |
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
- - The folder selected in the desktop UI stays that UI's workspace. WSL-mode tools no longer search it by default. Skill directories and UI file references still use Windows paths.
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.6**。需要 **Windows**、一个可用的 WSL 发行版,以及 **DSH `>= 0.2.1-rc.2`**。源码:<https://github.com/TGWgulugu/dsh-wsl-bridge>
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
- 在普通终端里执行(不要在 DSH 会话的工具沙箱里跑)。`dsh` 不在 PATH 上时,换成 Desktop 自带的 `dsh.cmd` 完整路径。
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
- **默认工作区是当前发行版的家目录(`$HOME`)。** 第一次 `bash` 从家目录开始;相对路径按家目录解析;`grep` 和 `glob` 不传 `path` 时也搜索家目录。要搜 Windows 目录时,显式传入 `/mnt/<盘符>/...`。
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 路径。相对路径相对 `$HOME` |
67
- | `grep` | 发行版内的 GNU grep。结果是 `path:line: text`。不传 `path` 时搜 `$HOME` |
68
- | `glob` | 按 glob 列出文件,旧文件在前。不传 `path` 时列 `$HOME` |
70
+ | `read` / `write` / `edit` | 读写 Linux 路径。相对路径相对你选的工作区 |
71
+ | `grep` | 发行版内的 GNU grep。结果是 `path:line: text`。不传 `path` 时搜你选的工作区 |
72
+ | `glob` | 按 glob 列出文件,旧文件在前。不传 `path` 时列你选的工作区 |
69
73
  | `wsl_env` | 发行版信息 |
70
74
 
71
- 家目录只查询一次。登录脚本如果往标准输出打字,不会被当成路径。查询失败时,搜索不会退回 Windows 会话目录。
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` | 宿主工具是 `session`;WSL 模式是 `home` | `session` 把 Windows 会话目录译成 `/mnt/...`。`home` 使用当前发行版的 `$HOME`。也可以填绝对 Linux 路径 |
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
- - 桌面端选中的 Windows 文件夹仍然是界面上的工作区。WSL 模式的工具默认不再搜那个目录。技能目录和界面上的文件引用仍按 Windows 路径解析。
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: 'Linux paths inside WSL. The default workspace is that distribution home directory.'
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 home directory of the current WSL distribution.
34
- bash, read, write, edit, grep and glob take Linux paths. grep and
35
- glob search that home directory unless you pass another path.
36
- The Windows filesystem is reachable as /mnt/<drive>. Use bash
37
- syntax for commands, not PowerShell.
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: home
50
+ defaultWorkdir: session
50
51
  - id: tool-jobs
51
52
  name: '@deepseek-ai/dsh-tool-jobs'
52
53
  - id: tool-schedule
@@ -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
- (`defaultWorkdir: home`) reads the distribution's `$HOME` once and uses that
57
- path for the initial shell, relative paths, and grep/glob. `wsl_path` prefers
58
- the distribution's own `wslpath` and falls back to the host-side mapping.
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 — no extra web server, nothing to install inside the distribution."
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 会话模式——不额外起 web 服务,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.6",
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 declarative WSL session mode. No extra web server and nothing to install inside the distribution.",
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
+ }
@@ -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
+ }
@@ -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
+ }