dsh-remote 0.5.10 → 0.6.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/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 dsh-remote contributors
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-remote contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -34,14 +34,21 @@ Real capture (host scrubbed to a placeholder):
34
34
 
35
35
  - **Multi-machine SSH** — save any number of hosts (`host`/`port`/`user` + **private key** or **password**). Passwords are stored locally and never shown back in the UI. Switch with one click in Settings.
36
36
  - **Two-tab workspace picker** (fills the native "Add workspace" flow):
37
- - **本机 / Local** — opens the **native OS folder chooser** over the host, or lets you type a local path → adopted directly as a normal DSH local workspace (local workspaces fully coexist).
38
- - **远程 / Remote** — the picker is a **centered modal** (never squeezed into a narrow sidebar). Pick a **machine** → the path field is **pre-filled with `/`** and live **autocompletes** directories; **selecting a directory immediately lists its next level** (OS/VSCode-style cascade). A **浏览…** floating browser (opaque, height-capped, scrollable, follows symlinks) fills the field without committing — you review, edit, then confirm. On confirm it creates a **real local mirror** (`~/.dsh/remote-workspaces/<host>/<base>-<hash>`) that passes `fs.realpath` → the harness adopts it as a real workspace while dsh-remote keeps it synced over SFTP.
39
- - **Bidirectional SFTP sync** — `rw_sync` (remote → mirror) and `rw_push` (mirror → remote) round-trip your local-mirror edits back to the machine.
40
- - **Model tools** — `rw_info`, `rw_connect`, `rw_pick_workspace`, `rw_list_dir`, `rw_read_file`, `rw_write_file`, `rw_exec`, `rw_sync`, `rw_push`, `rw_disconnect`.
41
- - **Write directly to a remote file** — `rw_write_file` creates or overwrites a remote file (making parent directories), so you don't have to round-trip through a local mirror for a single-file edit.
37
+ - **本机 / Local** — opens the **native OS folder chooser** over the host, or lets you type a local path → adopted directly as a normal DSH local workspace (local workspaces fully coexist). The chooser uses the DSH `directoryPicker` service when available and otherwise falls back to the plugin's own native picker (macOS `osascript` / Linux `zenity`→`kdialog`) — so it works even when the framework service isn't registered on the desktop boot path.
38
+ - **远程 / Remote** — the picker is a **centered modal** (never squeezed into a narrow sidebar). Pick a **machine** → the path field is **pre-filled with `/`** and live **autocompletes** directories; **selecting a directory immediately lists its next level** (OS/VSCode-style cascade). A **浏览…** floating browser (opaque, height-capped, scrollable, follows symlinks) fills the field without committing — you review, edit, then confirm. On confirm it creates a **real local mirror** under `$DSH_HOME/remote-workspaces/<host>-<user>-<port>/<base>` (a short path-hash is appended only when a different remote path already took the same basename) that passes `fs.realpath` → the harness adopts it as a real workspace while dsh-remote keeps it synced over SFTP. The chosen workspace is persisted on the machine, so it survives restarts.
39
+ - **Bidirectional SFTP sync** — `rw_sync` (remote → mirror) and `rw_push` (mirror → remote) round-trip your local-mirror edits back to the machine. Both are **incremental**: files whose size + mtime already match are skipped, and a per-file size cap prevents accidental big-binary downloads. Directory sweeps run with bounded parallelism.
40
+ - **Model tools** — `rw_info`, `rw_connect`, `rw_pick_workspace`, `rw_list_dir`, `rw_read_file`, `rw_write_file`, `rw_exec` (runs in the current workspace by default, or `cwd=<path>`), `rw_search` (portable recursive grep), `rw_download`, `rw_upload`, `rw_sync`, `rw_push`, `rw_disconnect`.
41
+ - **Write directly to a remote file** — `rw_write_file` creates or overwrites a remote file (making parent directories), so you don't have to round-trip through a local mirror for a single-file edit. `rw_download` / `rw_upload` move a single file either way when you need the real bytes.
42
42
  - **Connection health** — a **「测试连接」 test-connection** button in the Settings page validates host/user/key/password before you save a machine.
43
43
  - The active `user@host:/path` is injected into every system prompt so the agent knows its working root.
44
44
  - **No official `dsh-workspace` core is modified** — everything is delivered as a normal plugin (directory-flow holes filled by the client half at `priority -100`).
45
+ - **Cross-platform remotes** — commands use portable POSIX forms (`ls -la`, `sed -n`, `find … -exec grep`), so the same plugin works against macOS/BSD as well as GNU/Linux hosts.
46
+ - **Host-key verification (TOFU)** — every SSH connect verifies the host key
47
+ (`hostKeyMode: accept-new`): first connect records it, a later CHANGE is rejected
48
+ as a possible man-in-the-middle. `verify` also refuses hosts never seen before;
49
+ `off` disables it. Stored at `$DSH_HOME/remote-workspaces/known_hosts.json`; reset
50
+ with `/remote forget-key`.
51
+ - **Data lives under the harness home** — machines + mirrors follow `$DSH_HOME` (the desktop app sets it to its own `userData/harness`); pre-0.6 data under `~/.dsh/remote-workspaces` is migrated automatically on first run.
45
52
 
46
53
  ## Install
47
54
 
@@ -60,7 +67,8 @@ dsh plugin add dsh-remote # add the bundle
60
67
  3. **Work with the agent** — treat it like any workspace:
61
68
  - `rw_list_dir(path?)`/`rw_read_file` — inspect remote files
62
69
  - `rw_write_file(path, content)` — create or overwrite a remote file directly
63
- - `rw_exec(command)` — run remote shell commands
70
+ - `rw_search(pattern, path?)` — grep remote files
71
+ - `rw_exec(command, cwd?)` — run remote shell commands (defaults to the workspace dir)
64
72
  - `rw_sync` / `rw_push` — pull/push the local mirror to and from the remote
65
73
 
66
74
  ## CLI defaults (optional)
@@ -107,6 +115,38 @@ npx --yes @deepseek-ai/dsh plugin --profile web remove dsh-remote # back to re
107
115
 
108
116
  After a successful start, `Settings → 远程工作区` appears and the "Add workspace" flow gains the 本机 / 远程 tabs (screenshots above).
109
117
 
118
+ ## Development (sandbox, not product)
119
+
120
+ Iterate **in the sandbox**, never by hand-editing a product profile — the
121
+ product profile is re-managed by the plugin manager and reverts hand-deployed
122
+ files on reinstall. Use the helper script:
123
+
124
+ ```bash
125
+ scripts/dev-run.sh --restart # start / restart the isolated sandbox
126
+ scripts/dev-run.sh --stop # stop it
127
+ scripts/dev-run.sh --status # is it running?
128
+ ```
129
+
130
+ - Runs its own DSH instance (`dev-harness/harness` inside this repo) with the
131
+ plugin copied in from `lib/` — it boots through the same `bin.js web --patch`
132
+ path as the desktop app, so the sandbox reproduces the product boot behavior.
133
+ - The sandbox web UI serves on `http://127.0.0.1:50599` and the plugin routes
134
+ are live immediately (e.g. `GET /dsh-remote/machines`).
135
+ - **Host-half changes** (`lib/index.js`) need a sandbox restart (`--restart`);
136
+ **client-half changes** (`lib/client.js`) need a page refresh.
137
+ - Node ESM resolves dependencies from the importing file's real path, so the
138
+ script **copies** `lib/` (hardlink copy, `cp -al`) into the sandbox profile
139
+ instead of symlinking — a symlink breaks `@deepseek-ai/*` resolution.
140
+ - Run `scripts/check.mjs` (static framework-constraint gate: command-name
141
+ regex, …) before every commit; `scripts/boot-smoke.sh` boots an isolated
142
+ instance to prove the plugin still starts.
143
+ - Full rules live in `scripts/dev-standards.md` (command names, cordis service
144
+ access via `ctx.get()` only, optional framework services may never register,
145
+ verify third-party callback contracts against the real runtime, …).
146
+
147
+ Deploying to a product profile is a separate, explicit action (`./sync.sh`)
148
+ and should be done only when you intend to release.
149
+
110
150
  ## Configuration
111
151
 
112
152
  | Key | Type | Default | Meaning |
@@ -115,10 +155,12 @@ After a successful start, `Settings → 远程工作区` appears and the "Add wo
115
155
  | `port` | int | `22` | default SSH port |
116
156
  | `username` | string | `''` | default SSH user |
117
157
  | `password` | string | `''` | default SSH password (non-empty overrides key) |
118
- | `privateKeyPath` | string | `''` | private key path (`~/.ssh/id_rsa` when empty) |
158
+ | `privateKeyPath` | string | `''` | private key path (used only when explicitly provided) |
119
159
  | `workspace` | string | `''` | default remote workspace path |
120
160
  | `commandTimeoutMs` | int | 20000 | per remote command timeout |
121
161
  | `connectTimeoutMs` | int | 15000 | SSH connect timeout |
162
+ | `maxFileBytes` | int | 52428800 | skip mirroring files larger than this (0 = no cap) |
163
+ | `hostKeyMode` | string | `accept-new` | host-key policy: `accept-new` (TOFU), `verify` (reject unknown hosts), `off` (skip) |
122
164
 
123
165
  ## Safety
124
166
 
package/README.zh.md CHANGED
@@ -34,13 +34,16 @@ DSH 的 Web 界面刻意只监听 `127.0.0.1`(CLI 为安全拒绝 `--host 0.0.
34
34
 
35
35
  - **多机 SSH** —— 可存任意多台主机(host/port/user + **私钥**或**密码**)。密码只存在本地,界面不回显;在设置里一键切当前机。
36
36
  - **双 tab 工作区选择器**(填充原生「Add workspace」流程):
37
- - **本机** —— 走 **host 端原生系统文件夹对话框**选本地目录(或直接输入本地路径)→ 直接成为普通 DSH 本地工作区(与本地工作区共存)。
38
- - **远程** —— 选择器是**居中弹窗**(窄侧边栏也不会被挤压)。先**选机器** → 路径框**自动预填 `/`** 并**实时补全**目录;**选中一个目录立即列出它下一级**(OS/VSCode 式级联)。另有 **「浏览…」浮层**(不透明、定高、内部滚动、跟随软链),选中**回填输入框不提交**,你复核/修改后再确定。确定会创建**真实本地镜像**(`~/.dsh/remote-workspaces/<host>/<basename>-<hash>`,`fs.realpath` 通过)→ harness 把它当真实工作区收养,同时 dsh-remote 通过 SFTP 保持同步。
39
- - **双向 SFTP 同步** —— `rw_sync`(远程→镜像)、`rw_push`(镜像→远程),本地镜像改动可回传机器。
40
- - **模型工具** —— `rw_info`、`rw_connect`、`rw_pick_workspace`、`rw_list_dir`、`rw_read_file`、`rw_write_file`、`rw_exec`、`rw_sync`、`rw_push`、`rw_disconnect`。
41
- - **直接写远程文件** —— `rw_write_file` 直接创建/覆盖远程文件(自动建父目录),单个文件改动不必绕本地镜像来回同步。
37
+ - **本机** —— 走 **host 端原生系统文件夹对话框**选本地目录(或直接输入本地路径)→ 直接成为普通 DSH 本地工作区(与本地工作区共存)。优先用 DSH 的 `directoryPicker` 服务,服务缺失时**回退到插件自持的原生选择器**(macOS `osascript` / Linux `zenity`→`kdialog`)——桌面启动路径上框架服务不注册也能用。
38
+ - **远程** —— 选择器是**居中弹窗**(窄侧边栏也不会被挤压)。先**选机器** → 路径框**自动预填 `/`** 并**实时补全**目录;**选中一个目录立即列出它下一级**(OS/VSCode 式级联)。另有 **「浏览…」浮层**(不透明、定高、内部滚动、跟随软链),选中**回填输入框不提交**,你复核/修改后再确定。确定会创建**真实本地镜像**(`$DSH_HOME/remote-workspaces/<host>-<user>-<port>/<basename>`;仅当同主机上**别的远端路径**已占用同名 basename 时才追加短路径 hash)→ harness 把它当真实工作区收养,同时 dsh-remote 通过 SFTP 保持同步。所选工作区会**持久化到该机器**,重启不丢。
39
+ - **双向 SFTP 同步(增量)** —— `rw_sync`(远程→镜像)、`rw_push`(镜像→远程),本地镜像改动可回传机器。两者都会**跳过 size+mtime 未变化的文件**,并有**单文件大小上限**防误拉大二进制;目录遍历带**有界并发**。
40
+ - **模型工具** —— `rw_info`、`rw_connect`、`rw_pick_workspace`、`rw_list_dir`、`rw_read_file`、`rw_write_file`、`rw_exec`(默认在工作区目录执行,可传 `cwd=`)、`rw_search`(可移植递归 grep)、`rw_download`、`rw_upload`、`rw_sync`、`rw_push`、`rw_disconnect`。
41
+ - **直接写远程文件** —— `rw_write_file` 直接创建/覆盖远程文件(自动建父目录),单个文件改动不必绕本地镜像来回同步;`rw_download` / `rw_upload` 可单文件双向取真字节。
42
42
  - **连接体检** —— 设置页提供「测试连接」按钮,在保存机器之前先验证 host/user/密码/私钥是否可用。
43
43
  - 当前 `user@host:/path` 会注入每次系统提示,让 Agent 明确自己的工作根。
44
+ - **远端跨平台** —— 命令全部用可移植 POSIX 写法(`ls -la` / `sed -n` / `find … -exec grep`),macOS/BSD 与 GNU/Linux 远端都能用。
45
+ - **主机指纹校验(TOFU)** —— 每次 SSH 连接都校验主机密钥(`hostKeyMode: accept-new`):首次连接记录,之后**密钥一旦变化立即拒绝**(防中间人)。`verify` 模式还会拒绝从未见过的机器;`off` 关闭校验。指纹存于 `$DSH_HOME/remote-workspaces/known_hosts.json`;误判可用 `/remote forget-key` 重置。
46
+ - **数据跟随 Harness 根目录** —— 机器清单与镜像放在 `$DSH_HOME/remote-workspaces`(桌面版即 `userData/harness` 下);0.6 之前落在 `~/.dsh/remote-workspaces` 的数据**首次启动自动迁移**,不丢失。
44
47
  - **不改任何 `dsh-workspace` 官方代码** —— 全部作为普通插件实现(client 半以 `priority -100` 填充 directory-flow holes)。
45
48
 
46
49
  ## 安装
@@ -60,7 +63,8 @@ dsh plugin add dsh-remote # 添加 bundle
60
63
  3. **让 Agent 工作** —— 把它当普通工作区用:
61
64
  - `rw_list_dir(path?)` / `rw_read_file` —— 查看远程文件
62
65
  - `rw_write_file(path, content)` —— 直接创建或覆盖远程文件
63
- - `rw_exec(command)` —— 在远程执行命令
66
+ - `rw_search(pattern, path?)` —— 远程 grep
67
+ - `rw_exec(command, cwd?)` —— 在远程执行命令(默认在工作区目录)
64
68
  - `rw_sync` / `rw_push` —— 拉取/推送本地镜像 <-> 远程
65
69
 
66
70
  ## 可选:CLI 默认机
@@ -108,6 +112,32 @@ npx --yes @deepseek-ai/dsh plugin --profile web remove dsh-remote # 恢复用
108
112
 
109
113
  启动成功后,设置 →「远程工作区」会出现;「Add workspace」流程会带「本机 / 远程」两个 tab(见上方效果图)。
110
114
 
115
+ ## 开发(沙箱优先,勿改产品)
116
+
117
+ 迭代**一律在沙箱**里做,绝不手工改产品 profile——产品 profile 由插件管理器重管,
118
+ 重装会把手工部署的文件还原掉。用仓库内的辅助脚本:
119
+
120
+ ```bash
121
+ scripts/dev-run.sh --restart # 启动 / 重启隔离沙箱
122
+ scripts/dev-run.sh --stop # 停止
123
+ scripts/dev-run.sh --status # 是否在运行
124
+ ```
125
+
126
+ - 自带一套独立 DSH 实例(仓库内 `dev-harness/harness`),把 `lib/` 复制进沙箱
127
+ profile——与桌面 App 走同一条 `bin.js web --patch` 启动路径,沙箱即产品启动行为。
128
+ - 沙箱 web UI 在 `http://127.0.0.1:50599`,插件路由立即可见(如
129
+ `GET /dsh-remote/machines`)。
130
+ - **宿主半改动**(`lib/index.js`)需重启沙箱(`--restart`);**客户端半改动**
131
+ (`lib/client.js`)只需刷新页面。
132
+ - Node ESM 按导入文件的真实路径解析依赖,脚本用**硬链接拷贝**(`cp -al`)把
133
+ `lib/` 复制进沙箱 profile,而不是软链——软链会破坏 `@deepseek-ai/*` 的解析。
134
+ - 每次提交前跑 `scripts/check.mjs`(静态框架约束闸门:命令名正则等);
135
+ `scripts/boot-smoke.sh` 用隔离实例证明插件仍能启动。
136
+ - 完整规则见 `scripts/dev-standards.md`(命令名、cordis 服务只许 `ctx.get()`、
137
+ 可选框架服务可能压根不注册、三方库回调契约以真实运行为准等)。
138
+
139
+ 部署到产品 profile 是单独的受控动作(`./sync.sh`),只在确定要发布时做。
140
+
111
141
  ## 配置
112
142
 
113
143
  | 键 | 类型 | 默认 | 说明 |
@@ -116,10 +146,12 @@ npx --yes @deepseek-ai/dsh plugin --profile web remove dsh-remote # 恢复用
116
146
  | `port` | int | `22` | 默认 SSH 端口 |
117
147
  | `username` | string | `''` | 默认 SSH 用户 |
118
148
  | `password` | string | `''` | 默认 SSH 密码(非空覆盖 key) |
119
- | `privateKeyPath` | string | `''` | 私钥路径(空=`~/.ssh/id_rsa`) |
149
+ | `privateKeyPath` | string | `''` | 私钥路径(仅在显式提供时使用) |
120
150
  | `workspace` | string | `''` | 默认远程工作区路径 |
121
151
  | `commandTimeoutMs` | int | 20000 | 单条远程命令超时 |
122
152
  | `connectTimeoutMs` | int | 15000 | SSH 连接超时 |
153
+ | `maxFileBytes` | int | 52428800 | 镜像同步时跳过超过该大小的文件(0=不设上限) |
154
+ | `hostKeyMode` | string | `accept-new` | 主机指纹策略:`accept-new`(首次信任)、`verify`(拒绝未知主机)、`off`(跳过校验) |
123
155
 
124
156
  ## 安全提醒
125
157
 
package/cordis.patch.yml CHANGED
@@ -1,6 +1,6 @@
1
- # dsh-remote bundle patch: inserts this plugin into a profile's layer stack.
2
- # Applied when a profile lists this bundle; rows reference the package by name so
3
- # Node resolution finds the installed code.
4
- - insert:
5
- - id: dsh-remote
6
- name: 'dsh-remote'
1
+ # dsh-remote bundle patch: inserts this plugin into a profile's layer stack.
2
+ # Applied when a profile lists this bundle; rows reference the package by name so
3
+ # Node resolution finds the installed code.
4
+ - insert:
5
+ - id: dsh-remote
6
+ name: 'dsh-remote'
package/lib/client.js CHANGED
@@ -200,14 +200,14 @@ window.__ModuleLoader__.load({
200
200
  const [suggestOpen, setSuggestOpen] = React.useState(false)
201
201
  const suggestTimer = React.useRef(null)
202
202
  const suggestRef = React.useRef(null)
203
- // 点击候选路径下拉外部的空白处时,收起自动补全列表
203
+ // 点击自动补全下拉外部的空白处时收起补全列表(上游 0.5.10)
204
204
  React.useEffect(() => {
205
205
  if (!suggestOpen) return
206
206
  const onDown = (ev) => { if (suggestRef.current && !suggestRef.current.contains(ev.target)) setSuggestOpen(false) }
207
207
  document.addEventListener('mousedown', onDown)
208
208
  return () => document.removeEventListener('mousedown', onDown)
209
209
  }, [suggestOpen])
210
- // 机器下拉:点外部任意处关闭,避免候选层常驻遮挡下方按钮
210
+ // 机器下拉(自绘 dropdown):点外部任意处关闭,避免候选层常驻遮挡下方按钮(上游 0.5.8)
211
211
  const [mOpen, setMOpen] = React.useState(false)
212
212
  const ddRef = React.useRef(null)
213
213
  React.useEffect(() => {
@@ -414,13 +414,13 @@ window.__ModuleLoader__.load({
414
414
  React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
415
415
  React.createElement('label', { style: { fontSize: 12, opacity: 0.8, whiteSpace: 'nowrap' } }, '远程机器:'),
416
416
  React.createElement('div', { ref: ddRef, style: { position: 'relative', display: 'inline-block' } },
417
- React.createElement('button', { style: { ...inputS, textAlign: 'left', cursor: 'pointer', minWidth: 200, maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, onClick: () => setMOpen((v) => !v) },
417
+ React.createElement('button', { style: { ...inputS, textAlign: 'left', cursor: 'pointer', minWidth: 200, maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, onClick: () => setMOpen((x) => !x) },
418
418
  (machines.find((m) => m.id === machineId) || {}).name || (machineId ? '…' : '— 选择机器 —'),
419
419
  ),
420
420
  mOpen
421
- ? React.createElement('div', { style: { position: 'absolute', top: '100%', left: 0, zIndex: 1000, marginTop: 2, minWidth: 300, maxHeight: 260, overflow: 'auto', background: '#1f1f23', border: '1px solid ' + T.border, borderRadius: T.radius, padding: 4 } },
421
+ ? React.createElement('div', { style: { position: 'absolute', top: '100%', left: 0, zIndex: 1000, marginTop: 2, minWidth: 300, maxHeight: 260, overflow: 'auto', background: v('--dsw-alias-bg-overlay', '#1f1f23'), border: '1px solid ' + T.border, borderRadius: T.radius, padding: 4 } },
422
422
  machines.length
423
- ? machines.map((m) => React.createElement('div', { key: m.id, onClick: () => { setMachineId(m.id); setLevels(null); if (m.id) { loadLevels(m.id, '', 0); if (!path.trim()) { setPath('/'); loadSuggestions('/', m.id) } } setMOpen(false) }, style: { padding: '6px 8px', borderRadius: 4, cursor: 'pointer', color: m.id === machineId ? T.ok : 'inherit' } }, m.name + ' (' + m.username + '@' + m.host + ':' + m.port + ')'))
423
+ ? machines.map((m) => React.createElement('div', { key: m.id, onClick: () => { setMachineId(m.id); setLevels(null); setMOpen(false); if (m.id) { loadLevels(m.id, '', 0); if (!path.trim()) { setPath('/'); loadSuggestions('/', m.id) } } }, style: { padding: '6px 8px', borderRadius: 4, cursor: 'pointer', color: m.id === machineId ? T.ok : 'inherit' } }, m.name + ' (' + m.username + '@' + m.host + ':' + m.port + ')'))
424
424
  : React.createElement('div', { style: { padding: 6, opacity: 0.6 } }, '还没有机器,请先在设置里添加'),
425
425
  )
426
426
  : null,
package/lib/index.js CHANGED
@@ -18,7 +18,9 @@
18
18
  import z from '@deepseek-ai/schemastery'
19
19
  import { defineTool } from '@deepseek-ai/dsh-tools'
20
20
  import ssh2 from 'ssh2'
21
- import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs'
21
+ import { createHash } from 'node:crypto'
22
+ import { execFile } from 'node:child_process'
23
+ import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, copyFileSync, utimesSync } from 'node:fs'
22
24
  import { homedir } from 'node:os'
23
25
  import path from 'node:path'
24
26
 
@@ -53,6 +55,13 @@ export const Config = z.object({
53
55
  connectTimeoutMs: z.number().step(1).min(1000).default(15000),
54
56
  /** Hard ceiling on collected remote output per call. */
55
57
  maxOutputChars: z.number().step(1).min(1024).default(200000),
58
+ /** Skip mirroring files larger than this many bytes (0 = no cap). */
59
+ maxFileBytes: z.number().step(1).min(0).default(52428800),
60
+ /** Host-key policy: `accept-new` (default) records a host's key on first
61
+ * connect and verifies it afterwards (mirrors ssh's StrictHostKeyChecking
62
+ * accept-new); `verify` also rejects hosts never seen before; `off` skips
63
+ * verification entirely (MITM-unsafe, not recommended). */
64
+ hostKeyMode: z.string().default('accept-new'),
56
65
  })
57
66
 
58
67
  // ── shell / path helpers (proven in the read tooling) ───────────────────────
@@ -100,18 +109,89 @@ const remotePathBase = (p) => {
100
109
  return base || 'workspace'
101
110
  }
102
111
 
112
+ /** Harness home: respect `DSH_HOME` when set (the desktop app sets it to its
113
+ * own `userData/harness`), otherwise fall back to `~/.dsh`. Keeping plugin
114
+ * data under the same root the harness uses means uninstalling / upgrading the
115
+ * app no longer leaves state behind in the home directory. */
116
+ function dshBase() {
117
+ const env = process.env.DSH_HOME
118
+ if (env && String(env).trim()) return path.resolve(String(env).trim())
119
+ return path.join(homedir(), '.dsh')
120
+ }
121
+
122
+ /** Root holding every remote host's mirrors + the machine registry. */
123
+ function remoteWorkspacesRoot() {
124
+ return path.join(dshBase(), 'remote-workspaces')
125
+ }
126
+
127
+ /** Recursive directory copy (EXDEV fallback for migrateLegacyData). */
128
+ function copyDirSync(from, to) {
129
+ mkdirSync(to, { recursive: true })
130
+ for (const e of readdirSync(from, { withFileTypes: true })) {
131
+ const s = path.join(from, e.name)
132
+ const d = path.join(to, e.name)
133
+ if (e.isDirectory()) copyDirSync(s, d)
134
+ else try { copyFileSync(s, d) } catch { /* skip unreadable */ }
135
+ }
136
+ }
137
+
138
+ /** One-time migration of pre-0.6 data (which lived at `~/.dsh/remote-workspaces`
139
+ * regardless of `DSH_HOME`). Moves the legacy dir into the current home so a
140
+ * desktop install keeps its existing machines and mirrors. Idempotent; a
141
+ * rename across devices falls back to a copy. */
142
+ function migrateLegacyData() {
143
+ const legacy = path.join(homedir(), '.dsh', 'remote-workspaces')
144
+ const target = remoteWorkspacesRoot()
145
+ if (legacy === target || !existsSync(legacy) || existsSync(target)) return
146
+ try {
147
+ mkdirSync(path.dirname(target), { recursive: true })
148
+ try {
149
+ renameSync(legacy, target)
150
+ } catch (err) {
151
+ if (err.code !== 'EXDEV') throw err
152
+ copyDirSync(legacy, target)
153
+ }
154
+ } catch {
155
+ // Migration is best-effort: a fresh registry is created on next save.
156
+ }
157
+ }
158
+
159
+ /** 32-bit string hash rendered in base36 (short, collision-safe enough). */
160
+ function shortHash(s) {
161
+ let h = 0
162
+ for (let i = 0; i < s.length; i++) {
163
+ h = ((h << 5) - h + s.charCodeAt(i)) | 0
164
+ }
165
+ return (h >>> 0).toString(36)
166
+ }
167
+
103
168
  /** Stable local root for one remote host's mirrors */
104
169
  function mirrorRootFor(host, user, port) {
105
170
  const tag = [host, user, port].filter(Boolean).join('-').replace(/[^a-zA-Z0-9._-]/g, '_')
106
- return path.join(homedir(), '.dsh', 'remote-workspaces', tag)
171
+ return path.join(remoteWorkspacesRoot(), tag)
107
172
  }
108
173
 
109
174
  /** Local mirror dir for a specific remote path (idempotent → returns same dir).
110
175
  * Named after the remote directory's basename so the harness workspace label
111
- * reads cleanly (e.g. .../project) instead of a concatenated hash string. */
176
+ * reads cleanly (e.g. .../project). When the plain basename dir is already
177
+ * taken by a DIFFERENT remote path on the same host, a short path-hash suffix
178
+ * is appended so mirrors never collide. */
112
179
  function mirrorDirFor(remotePath, host, user, port) {
113
180
  const base = remotePathBase(remotePath)
114
- return path.join(mirrorRootFor(host, user, port), base)
181
+ const root = mirrorRootFor(host, user, port)
182
+ const plain = path.join(root, base)
183
+ const norm = normalizeRemotePath(remotePath)
184
+ // A pre-existing mirror for this exact remote origin → reuse it (idempotent).
185
+ try {
186
+ const meta = JSON.parse(readFileSync(path.join(plain, '.dsh-remote-meta.json'), 'utf8'))
187
+ if (meta.remotePath === norm) return plain
188
+ } catch {
189
+ /* no mirror yet → fall through */
190
+ }
191
+ // Plain dir is free → first mirror for this basename keeps the clean name.
192
+ // It exists but points elsewhere (or is a non-mirror dir) → hashed variant.
193
+ if (!existsSync(plain)) return plain
194
+ return path.join(root, base + '-' + shortHash(norm))
115
195
  }
116
196
 
117
197
  /** Create the local mirror dir + a meta file describing its remote origin. */
@@ -120,82 +200,130 @@ function ensureMirror(remotePath, host, user, port) {
120
200
  mkdirSync(dir, { recursive: true })
121
201
  writeFileSync(
122
202
  path.join(dir, '.dsh-remote-meta.json'),
123
- JSON.stringify({ host, port, username: user, remotePath, createdAt: new Date().toISOString() }, null, 2),
203
+ JSON.stringify({ host, port, username: user, remotePath: normalizeRemotePath(remotePath), createdAt: new Date().toISOString() }, null, 2),
124
204
  )
125
205
  return dir
126
206
  }
127
207
 
128
- /** Recursively sync remote → local mirror. Bounded by depth/files. */
129
- async function syncTree(sftp, remoteDir, localDir, maxDepth, maxFiles) {
208
+ /** Run `fn` over `items` with at most `limit` concurrent in-flight calls. */
209
+ async function mapLimit(items, limit, fn) {
210
+ const limitN = Math.max(1, Math.min(Math.floor(limit) || 1, items.length))
211
+ let next = 0
212
+ const worker = async () => {
213
+ while (next < items.length) {
214
+ const i = next++
215
+ try { await fn(items[i], i) } catch { /* per-item errors are the fn's concern */ }
216
+ }
217
+ }
218
+ const workers = []
219
+ for (let w = 0; w < limitN; w++) workers.push(worker())
220
+ await Promise.all(workers)
221
+ }
222
+
223
+ /** Recursively sync remote → local mirror. Bounded by depth/files/size;
224
+ * skips files whose size+mtime already match locally. Returns counters. */
225
+ async function syncTree(sftp, remoteDir, localDir, maxDepth, maxFiles, maxFileBytes) {
130
226
  const entries = await sftp.readdir(remoteDir).then(
131
227
  (list) => list,
132
228
  () => [],
133
229
  )
134
- let files = 0
135
- const touched = []
230
+ const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, touched: [] }
231
+
232
+ // Directories first (sequential, depth-bounded) so every file has a home.
136
233
  for (const e of entries) {
137
234
  const name = String(e.filename)
138
235
  if (name === '.' || name === '..') continue
236
+ const isDir = e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()
237
+ if (!isDir) continue
238
+ if (maxDepth <= 0 || stats.files >= maxFiles) continue
239
+ const rp = remoteDir === '/' ? '/' + name : remoteDir + '/' + name
240
+ const lp = path.join(localDir, name)
241
+ mkdirSync(lp, { recursive: true })
242
+ const sub = await syncTree(sftp, rp, lp, maxDepth - 1, maxFiles - stats.files, maxFileBytes)
243
+ stats.dirs += sub.dirs + 1
244
+ stats.files += sub.files
245
+ stats.skippedUnchanged += sub.skippedUnchanged
246
+ stats.skippedLarge += sub.skippedLarge
247
+ stats.touched.push(...sub.touched)
248
+ if (stats.files >= maxFiles) break
249
+ }
250
+ if (stats.files >= maxFiles) return stats
251
+
252
+ // Files in one bounded-parallel sweep per directory level.
253
+ const fileEntries = entries.filter(
254
+ (e) => !(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()) &&
255
+ String(e.filename) !== '.' && String(e.filename) !== '..',
256
+ )
257
+ await mapLimit(fileEntries, 4, async (e) => {
258
+ if (stats.files >= maxFiles) return
259
+ const name = String(e.filename)
139
260
  const rp = remoteDir === '/' ? '/' + name : remoteDir + '/' + name
140
261
  const lp = path.join(localDir, name)
141
- const isDir = e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()
142
- if (isDir) {
143
- if (maxDepth <= 0) continue
144
- mkdirSync(lp, { recursive: true })
145
- const sub = await syncTree(sftp, rp, lp, maxDepth - 1, maxFiles)
146
- files += sub.files
147
- touched.push(...sub.touched)
148
- continue
149
- }
150
- if (files >= maxFiles) break
151
262
  try {
263
+ const st = await sftp.stat(rp)
264
+ if (maxFileBytes > 0 && st.size > maxFileBytes) { stats.skippedLarge++; return }
265
+ const lpStat = existsSync(lp) ? statSync(lp) : null
266
+ const unchanged = lpStat && lpStat.size === st.size && Math.floor(lpStat.mtimeMs / 1000) === st.mtime
267
+ if (unchanged) { stats.skippedUnchanged++; return }
152
268
  const buf = await sftp.readFile(rp)
153
269
  writeFileSync(lp, buf)
154
- touched.push(rp)
155
- files++
270
+ try { utimesSync(lp, new Date(st.mtime * 1000), new Date(st.mtime * 1000)) } catch {}
271
+ stats.files++
272
+ stats.touched.push(rp)
156
273
  } catch {
157
274
  /* skip unreadable */
158
275
  }
159
- }
160
- return { files, touched }
276
+ })
277
+ return stats
161
278
  }
162
279
 
163
- /** Recursively upload a local mirror tree → remote SFTP dir. Bounded by files. */
164
- async function pushTree(sftp, localDir, remoteDir, remoteBaseDir, maxFiles) {
280
+ /** Recursively upload a local mirror tree → remote SFTP dir. Bounded by files;
281
+ * skips files whose remote copy already matches (same size, remote no older). */
282
+ async function pushTree(sftp, localDir, remoteDir, maxFiles, maxFileBytes) {
165
283
  const entries = readdirSync(localDir, { withFileTypes: true }).filter((e) => e.name !== '.dsh-remote-meta.json')
166
- let files = 0
167
- let dirsCreated = 0
168
- const pushed = []
284
+ const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, pushed: [] }
285
+
169
286
  for (const e of entries) {
170
- if (e.isDirectory()) {
171
- const rp = remoteDir === '/' ? '/' + e.name : remoteDir + '/' + e.name
172
- try {
173
- await sftp.mkdir(rp)
174
- dirsCreated++
175
- } catch { /* already exists */ }
176
- const sub = await pushTree(sftp, path.join(localDir, e.name), rp, remoteBaseDir, maxFiles)
177
- files += sub.files
178
- pushed.push(...sub.pushed)
179
- continue
180
- }
181
- if (files >= maxFiles) break
287
+ if (!e.isDirectory()) continue
288
+ if (stats.files >= maxFiles) break
289
+ const rp = remoteDir === '/' ? '/' + e.name : remoteDir + '/' + e.name
290
+ try { await sftp.mkdir(rp) } catch { /* already exists */ }
291
+ stats.dirs++
292
+ const sub = await pushTree(sftp, path.join(localDir, e.name), rp, maxFiles - stats.files, maxFileBytes)
293
+ stats.files += sub.files
294
+ stats.skippedUnchanged += sub.skippedUnchanged
295
+ stats.skippedLarge += sub.skippedLarge
296
+ stats.pushed.push(...sub.pushed)
297
+ if (stats.files >= maxFiles) break
298
+ }
299
+ if (stats.files >= maxFiles) return stats
300
+
301
+ const fileEntries = entries.filter((e) => !e.isDirectory())
302
+ await mapLimit(fileEntries, 4, async (e) => {
303
+ if (stats.files >= maxFiles) return
182
304
  const lp = path.join(localDir, e.name)
183
305
  const rp = remoteDir === '/' ? '/' + e.name : remoteDir + '/' + e.name
184
306
  try {
307
+ const lstat = statSync(lp)
308
+ if (maxFileBytes > 0 && lstat.size > maxFileBytes) { stats.skippedLarge++; return }
309
+ let rstat = null
310
+ try { rstat = await sftp.stat(rp) } catch { /* remote file absent → upload */ }
311
+ const unchanged = rstat && rstat.size === lstat.size && rstat.mtime >= Math.floor(lstat.mtimeMs / 1000)
312
+ if (unchanged) { stats.skippedUnchanged++; return }
185
313
  const buf = readFileSync(lp)
186
314
  await sftp.writeFile(rp, buf)
187
- pushed.push(rp)
188
- files++
315
+ stats.files++
316
+ stats.pushed.push(rp)
189
317
  } catch {
190
318
  /* skip unreadable / unwritable */
191
319
  }
192
- }
193
- return { files, pushed }
320
+ })
321
+ return stats
194
322
  }
195
323
 
196
324
  // ── persistent multi-machine registry ─────────────────────────────────────
197
325
  const MACHINES_FILE = 'machines.json'
198
- const machinesFile = () => path.join(homedir(), '.dsh', 'remote-workspaces', MACHINES_FILE)
326
+ const machinesFile = () => path.join(remoteWorkspacesRoot(), MACHINES_FILE)
199
327
  function loadMachines() {
200
328
  try {
201
329
  const j = JSON.parse(readFileSync(machinesFile(), 'utf8'))
@@ -225,6 +353,98 @@ function applyMachine(config, m) {
225
353
  }
226
354
  function machineId() { return 'm-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6) }
227
355
 
356
+ // ── host-key registry (TOFU: trust-on-first-use, like ssh accept-new) ──────
357
+ const KNOWN_HOSTS_FILE = 'known_hosts.json'
358
+ const knownHostsFile = () => path.join(remoteWorkspacesRoot(), KNOWN_HOSTS_FILE)
359
+
360
+ /** Read the trusted host-key map { "host:port": { algo, fingerprint } }. */
361
+ function loadKnownHosts() {
362
+ try {
363
+ const j = JSON.parse(readFileSync(knownHostsFile(), 'utf8'))
364
+ if (j && typeof j === 'object' && !Array.isArray(j)) return j
365
+ } catch {}
366
+ return {}
367
+ }
368
+
369
+ /** Extract the host-key algorithm name from a raw SSH host-key blob
370
+ * (SSH wire format: `uint32 len` + algorithm string + key data). */
371
+ function blobAlgorithm(blob) {
372
+ if (!Buffer.isBuffer(blob) || blob.length < 4) return ''
373
+ try {
374
+ const len = blob.readUInt32BE(0)
375
+ return blob.toString('utf8', 4, 4 + len)
376
+ } catch {
377
+ return ''
378
+ }
379
+ }
380
+
381
+ /** SHA-256 fingerprint (base64) of an ssh2 host-key blob, like `SHA256:…`
382
+ * in a known_hosts file (without the `SHA256:` prefix).
383
+ *
384
+ * ssh2 v1.17 passes hostVerifier the RAW host-key blob Buffer (the SSH
385
+ * wire-format `string(algo) string(keydata)`), NOT the old `{ algo, hash }`
386
+ * object. Fingerprinting `key.hash` on a raw Buffer crashed with "The 'data'
387
+ * argument must be of type string or an instance of Buffer, TypedArray, or
388
+ * DataView. Received undefined" on EVERY connect (v0.6.6 bug) — so the
389
+ * v0.6.1 TOFU guard never actually worked against real ssh2. Accept both
390
+ * shapes defensively. */
391
+ function keyFingerprint(key) {
392
+ const blob = Buffer.isBuffer(key) ? key : (key && key.hash)
393
+ if (!blob) throw new Error('host key missing (hostVerifier received no key blob)')
394
+ return createHash('sha256').update(blob).digest('base64')
395
+ }
396
+
397
+ /** Build an ssh2 `hostVerifier` bound to the current config. Returns
398
+ * `{ mode, verifier, lastError, knownHosts, forgetHost }`. The verifier:
399
+ * - `off` → always accepts;
400
+ * - `accept-new` → records a host's key on first connect, rejects any CHANGE
401
+ * afterwards (classic TOFU / MITM detection);
402
+ * - `verify` → same, plus rejects hosts never recorded before. */
403
+ function createHostKeyGuard(config) {
404
+ const id = () => `${config.host}:${config.port}`
405
+ const mode = config.hostKeyMode === 'verify' || config.hostKeyMode === 'off'
406
+ ? config.hostKeyMode
407
+ : 'accept-new'
408
+ const guard = {
409
+ mode,
410
+ lastError: null,
411
+ knownHosts: loadKnownHosts,
412
+ forgetHost() {
413
+ const kh = loadKnownHosts()
414
+ delete kh[id()]
415
+ try { mkdirSync(path.dirname(knownHostsFile()), { recursive: true }) } catch {}
416
+ writeFileSync(knownHostsFile(), JSON.stringify(kh, null, 2))
417
+ },
418
+ verifier(key) {
419
+ if (mode === 'off') return true
420
+ const fp = keyFingerprint(key)
421
+ const kh = loadKnownHosts()
422
+ const stored = kh[id()]
423
+ if (stored) {
424
+ if (stored.fingerprint === fp) return true
425
+ guard.lastError =
426
+ `host key for ${id()} CHANGED (stored ${stored.fingerprint}, received ${fp}) — ` +
427
+ 'possible man-in-the-middle; run /remote-forget-key to re-trust if this is expected'
428
+ return false
429
+ }
430
+ if (mode === 'verify') {
431
+ guard.lastError = `unknown host key for ${id()} (hostKeyMode=verify) — trust it first with accept-new`
432
+ return false
433
+ }
434
+ kh[id()] = { algo: blobAlgorithm(key) || (key && key.algo) || 'unknown', fingerprint: fp, firstSeen: new Date().toISOString() }
435
+ try { mkdirSync(path.dirname(knownHostsFile()), { recursive: true }) } catch {}
436
+ writeFileSync(knownHostsFile(), JSON.stringify(kh, null, 2))
437
+ return true
438
+ },
439
+ }
440
+ return guard
441
+ }
442
+
443
+ /** Whether the current target's key has been recorded/trusted before. */
444
+ function isHostKeyKnown(host, port) {
445
+ return Object.prototype.hasOwnProperty.call(loadKnownHosts(), `${host}:${port}`)
446
+ }
447
+
228
448
  // ── SSH pool (key OR password) ──────────────────────────────────────────────
229
449
 
230
450
  class SshPool {
@@ -232,6 +452,9 @@ class SshPool {
232
452
  this.config = config
233
453
  this.client = null
234
454
  this.connecting = null
455
+ // Generational token: bumped on every target change / close so a stale
456
+ // in-flight connect can never hand this pool a connection to an old host.
457
+ this.epoch = 0
235
458
  }
236
459
 
237
460
  resolveKeyPath() {
@@ -257,31 +480,47 @@ class SshPool {
257
480
  connect() {
258
481
  if (this.client) return Promise.resolve(this.client)
259
482
  if (this.connecting) return this.connecting
260
- this.connecting = this._doConnect().finally(() => {
261
- this.connecting = null
262
- })
263
- return this.connecting
483
+ const epoch = this.epoch
484
+ const pending = this._doConnect(epoch)
485
+ this.connecting = pending
486
+ const clear = () => {
487
+ if (this.epoch === epoch && this.connecting === pending) this.connecting = null
488
+ }
489
+ // Keep the chain clean in both directions: the raw pending is what callers
490
+ // await (they still see rejections); this side just clears the slot.
491
+ pending.then(clear, clear)
492
+ return pending
264
493
  }
265
494
 
266
- _doConnect() {
495
+ _doConnect(epoch) {
267
496
  return new Promise((resolve, reject) => {
268
497
  const client = new Client()
269
498
  let settled = false
499
+ // Fresh host-key guard per connect attempt so lastError is never stale.
500
+ const guard = createHostKeyGuard(this.config)
501
+ const isCurrent = () => this.epoch === epoch
270
502
  const fail = (err) => {
271
503
  if (settled) return
272
504
  settled = true
273
- if (this.client === client) this.client = null
274
- reject(err)
505
+ if (isCurrent() && this.client === client) this.client = null
506
+ reject(guard.lastError ? new Error(guard.lastError) : err)
275
507
  }
276
508
  client.on('ready', () => {
277
509
  if (settled) return
278
510
  settled = true
511
+ if (!isCurrent()) {
512
+ // The target changed while we were connecting — never adopt this
513
+ // client, and make sure the caller learns the connect is void.
514
+ try { client.end() } catch {}
515
+ reject(new Error('ssh target changed during connect'))
516
+ return
517
+ }
279
518
  this.client = client
280
519
  resolve(client)
281
520
  })
282
521
  client.on('error', fail)
283
522
  client.on('close', () => {
284
- if (this.client === client) this.client = null
523
+ if (isCurrent() && this.client === client) this.client = null
285
524
  fail(new Error('ssh connection closed'))
286
525
  })
287
526
 
@@ -292,6 +531,7 @@ class SshPool {
292
531
  readyTimeout: this.config.connectTimeoutMs,
293
532
  keepaliveInterval: 15000,
294
533
  keepaliveCountMax: 3,
534
+ hostVerifier: (key) => guard.verifier(key),
295
535
  }
296
536
  if (this.config.password) {
297
537
  opts.password = this.config.password
@@ -341,9 +581,17 @@ class SshPool {
341
581
  if (settled) return
342
582
  exitCode = -1
343
583
  exitSignal = 'TIMEOUT'
584
+ // Kill the remote command (SIGTERM) rather than just dropping the
585
+ // channel, so a runaway process cannot keep running and holding
586
+ // the SSH connection after we've given up on its output. The hard
587
+ // close below frees the channel even if the process ignores it.
344
588
  try {
345
- stream.close()
589
+ if (typeof stream.signal === 'function') stream.signal('SIGTERM')
346
590
  } catch {}
591
+ const hardClose = setTimeout(() => {
592
+ try { stream.close() } catch {}
593
+ }, 800)
594
+ if (typeof hardClose.unref === 'function') hardClose.unref()
347
595
  settle()
348
596
  }, timeoutMs || this.config.commandTimeoutMs)
349
597
  stream.on('close', (code, signal) => {
@@ -389,8 +637,17 @@ class SshPool {
389
637
  }
390
638
 
391
639
  close() {
640
+ // Bump the epoch so any in-flight connect is orphaned, drop the pending
641
+ // promise (its rejection becomes nobody's problem — swallow it), and end
642
+ // the live client if there is one.
643
+ this.epoch++
392
644
  const client = this.client
393
645
  this.client = null
646
+ const pending = this.connecting
647
+ this.connecting = null
648
+ if (pending && typeof pending.catch === 'function') {
649
+ try { pending.catch(() => {}) } catch {}
650
+ }
394
651
  if (client) {
395
652
  try {
396
653
  client.end()
@@ -405,6 +662,10 @@ export async function apply(ctx, config) {
405
662
  const pool = new SshPool(config)
406
663
  ctx.effect(() => () => pool.close(), 'dsh-remote.close')
407
664
 
665
+ // Bring any pre-0.6 data (~/.dsh/remote-workspaces) into the DSH_HOME-based
666
+ // location so existing machines + mirrors keep working after the move.
667
+ migrateLegacyData()
668
+
408
669
  // ── machine registry (multi-host) ─────────────────────────────────────────
409
670
  const store = loadMachines()
410
671
  const machines = store.list
@@ -433,6 +694,19 @@ export async function apply(ctx, config) {
433
694
  if (cur && cur.host) applyMachine(config, cur)
434
695
  }
435
696
 
697
+ /** Set the active remote workspace AND persist it on the current machine so
698
+ * it survives restarts (previously a tool-set workspace reset on reload). */
699
+ const persistWorkspace = (p) => {
700
+ config.workspace = p
701
+ if (store.currentId) {
702
+ const i = machineIndex(store.currentId)
703
+ if (i >= 0) {
704
+ machines[i].workspace = p
705
+ saveMachines(machines, store.currentId)
706
+ }
707
+ }
708
+ }
709
+
436
710
  const run = async (cmd, opts = {}) => {
437
711
  const res = await pool.exec(cmd, opts.timeoutMs)
438
712
  const parts = []
@@ -445,27 +719,51 @@ export async function apply(ctx, config) {
445
719
  return text
446
720
  }
447
721
 
448
- /** Structured-listing of a remote dir: name + usable type. `dir` is decided by
449
- * `[ -d ]`, which FOLLOWS symlinks — so a symlink to a directory is enterable
450
- * (the picker can drill into it), while a file symlink stays a file. */
722
+ /** Structured-listing of a remote dir: name + usable type.
723
+ *
724
+ * Implementation note — this deliberately uses `ls -1A -F` instead of a
725
+ * `for f in .[!.]* *` loop or `find`:
726
+ * • `-F` marks real directories with a trailing `/` and symlinks with `@`;
727
+ * on Linux GNU ls classifies from readdir `d_type` WITHOUT stat, so the
728
+ * listing can never hang on a stuck FUSE/network mount the way
729
+ * `[ -d "$f" ]` / `find -type` do (those stat every entry and block in
730
+ * an uninterruptible D-state that even SIGTERM can't clear).
731
+ * • there is no shell glob, so zsh's `nomatch` can't abort the listing on
732
+ * directories that have no dotfiles (the old `.[!.]*` glob did).
733
+ * Symlinked entries (e.g. `/bin@`) get ONE bounded follow-up stat — only
734
+ * symlinks are touched, never the stuck mount — to decide dir vs file. */
451
735
  const listDirStructured = async (p, timeoutMs) => {
452
736
  const target = p || '/'
453
- const cmd =
454
- `cd ${shq(target)} 2>/dev/null && for f in .[!.]* *; do ` +
455
- `[ -e "$f" ] || [ -L "$f" ] || continue; ` +
456
- `if [ -d "$f" ]; then printf 'd\\t%s\\n' "$f"; else printf 'f\\t%s\\n' "$f"; fi; done`
457
- const res = await pool.exec(cmd, timeoutMs || config.commandTimeoutMs)
458
- const items = []
737
+ const to = timeoutMs || config.commandTimeoutMs
738
+ const res = await pool.exec(`ls -1A -F ${shq(target)}`, to)
459
739
  if (res.code !== 0 && res.stderr) {
460
740
  throw new Error('ls failed: ' + (res.stderr || '').trim())
461
741
  }
742
+ const items = []
743
+ const symIdx = []
462
744
  for (const line of String(res.stdout || '').split('\n')) {
463
- const idx = line.indexOf('\t')
464
- if (idx < 0) continue
465
- const type = line.slice(0, idx)
466
- const name = line.slice(idx + 1)
467
- if (name === '.' || name === '..' || !name) continue
468
- items.push({ type: type === 'd' ? 'dir' : 'file', name })
745
+ if (!line) continue
746
+ const c = line[line.length - 1]
747
+ if (c === '/') items.push({ type: 'dir', name: line.slice(0, -1) })
748
+ else if (c === '@') {
749
+ items.push({ type: 'symlink', name: line.slice(0, -1) })
750
+ symIdx.push(items.length - 1)
751
+ } else {
752
+ // regular file, executable (`*`), socket (`=`), fifo (`|`) …
753
+ items.push({ type: 'file', name: c === '*' || c === '=' || c === '|' ? line.slice(0, -1) : line })
754
+ }
755
+ }
756
+ // Resolve symlink-to-dir vs symlink-to-file. Bounded and failure-tolerant:
757
+ // if the stat is slow it times out and symlinks just degrade to files
758
+ // (non-enterable) instead of failing the whole browse.
759
+ if (symIdx.length) {
760
+ const cmd = 'for f in ' + symIdx.map((i) => shq(items[i].name)).join(' ') +
761
+ '; do if [ -d "$f" ]; then echo d; else echo f; fi; done'
762
+ const r2 = await pool.exec(cmd, Math.min(to, 5000))
763
+ const kinds = String(r2.stdout || '').split('\n')
764
+ for (let i = 0; i < symIdx.length; i++) {
765
+ items[symIdx[i]].type = (kinds[i] || '').trim() === 'd' ? 'dir' : 'file'
766
+ }
469
767
  }
470
768
  return { path: target, items }
471
769
  }
@@ -482,6 +780,8 @@ export async function apply(ctx, config) {
482
780
  localMirror: wsPath() ? mirrorDirFor(wsPath(), config.host, config.username, config.port) : '',
483
781
  currentId: store.currentId || null,
484
782
  machines: machines.map(sanitizeMachine),
783
+ hostKeyMode: config.hostKeyMode === 'verify' || config.hostKeyMode === 'off' ? config.hostKeyMode : 'accept-new',
784
+ hostKeyKnown: config.host ? isHostKeyKnown(config.host, config.port) : false,
485
785
  })
486
786
 
487
787
  // ── tools ─────────────────────────────────────────────────────────────────
@@ -495,7 +795,7 @@ export async function apply(ctx, config) {
495
795
  defineTool({
496
796
  name: 'rw_info',
497
797
  description:
498
- 'Show the remote environment: host/user/port, connection health, and the current remote workspace path. Call this first to orient, or when a remote_* call fails to check connectivity.',
798
+ 'Show the remote environment: host/user/port, connection health, and the current remote workspace path. Call this first to orient, or when an rw_* call fails to check connectivity.',
499
799
  parameters: {},
500
800
  output: {
501
801
  schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
@@ -506,6 +806,7 @@ export async function apply(ctx, config) {
506
806
  const lines = [
507
807
  `Remote host: ${s.username || '<user>'}@${s.host || '<host>'}:${s.port}`,
508
808
  `Current remote workspace: ${s.workspace || '(none — call rw_pick_workspace to set one)'}`,
809
+ `Local mirror: ${s.localMirror || '(none)'}`,
509
810
  `Connected: ${s.connected ? 'yes' : 'no'}`,
510
811
  '',
511
812
  ]
@@ -577,10 +878,10 @@ export async function apply(ctx, config) {
577
878
  const res = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
578
879
  const ok = res.stdout.trim() === 'DIR'
579
880
  if (!ok) return { text: `not a directory (or missing) on ${p}` }
580
- config.workspace = p
881
+ persistWorkspace(p)
581
882
  const local = ensureMirror(p, config.host, config.username, config.port)
582
883
  return {
583
- text: `Remote workspace set to ${p} on ${config.username}@${config.host}.\nLocal mirror (native workspace path): ${local}\n\nRun rw_sync to download its files into the local mirror.`,
884
+ text: `Remote workspace set to ${p} on ${config.username}@${config.host} (saved for this machine).\nLocal mirror (native workspace path): ${local}\n\nRun rw_sync to download its files into the local mirror.`,
584
885
  }
585
886
  },
586
887
  }),
@@ -610,8 +911,11 @@ export async function apply(ctx, config) {
610
911
  } catch (err) {
611
912
  return { text: 'sftp unavailable: ' + ((err && err.message) || err) }
612
913
  }
613
- const { files, touched } = await syncTree(sftp, p, local, depth, maxFiles)
614
- return { text: `Downloaded ${files} file(s) from ${p} → ${local}${files >= maxFiles ? ' (hit download cap)' : ''}.` }
914
+ const r = await syncTree(sftp, p, local, depth, maxFiles, config.maxFileBytes)
915
+ let text = `Downloaded ${r.files} file(s) from ${p} → ${local}${r.files >= maxFiles ? ' (hit download cap)' : ''}.`
916
+ if (r.skippedUnchanged) text += ` ${r.skippedUnchanged} unchanged.`
917
+ if (r.skippedLarge) text += ` ${r.skippedLarge} too large (over ${config.maxFileBytes} bytes).`
918
+ return { text }
615
919
  },
616
920
  }),
617
921
 
@@ -638,8 +942,11 @@ export async function apply(ctx, config) {
638
942
  } catch (err) {
639
943
  return { text: 'sftp unavailable: ' + ((err && err.message) || err) }
640
944
  }
641
- const { files } = await pushTree(sftp, local, p, p, maxFiles)
642
- return { text: `Uploaded ${files} file(s) from ${local} → ${p}.` }
945
+ const { files, skippedUnchanged, skippedLarge, pushed } = await pushTree(sftp, local, p, maxFiles, config.maxFileBytes)
946
+ let text = `Uploaded ${files} file(s) from ${local} → ${p}.`
947
+ if (skippedUnchanged) text += ` ${skippedUnchanged} unchanged.`
948
+ if (skippedLarge) text += ` ${skippedLarge} too large (over ${config.maxFileBytes} bytes).`
949
+ return { text }
643
950
  },
644
951
  }),
645
952
 
@@ -657,7 +964,9 @@ export async function apply(ctx, config) {
657
964
  async execute(args) {
658
965
  const p = args.path ? normalizeRemotePath(String(args.path)) : wsPath()
659
966
  if (!p) throw new Error('rw_list_dir: no path and no remote workspace set')
660
- return { text: await run(`ls -la --color=never ${shq(p)}`) }
967
+ // `ls -la` (no --color / -N): piped output never carries color on either
968
+ // GNU or BSD ls, so the GNU-only flags would break macOS/BSD remotes.
969
+ return { text: await run(`ls -la ${shq(p)}`) }
661
970
  },
662
971
  }),
663
972
 
@@ -682,7 +991,9 @@ export async function apply(ctx, config) {
682
991
  let from = Math.max(Number(args.startLine) || 1, 1)
683
992
  let to = Number(args.endLine) || 0
684
993
  if (!to || to - from + 1 > maxLines) to = from + maxLines - 1
685
- const raw = await run(`sed -n '${from},${to}p' -- ${shq(p)}`, { timeoutMs: config.commandTimeoutMs })
994
+ // Absolute paths start with '/', so the GNU-only `--` terminator is
995
+ // dropped to keep BSD/macOS sed (which rejects `--`) working.
996
+ const raw = await run(`sed -n '${from},${to}p' ${shq(p)}`, { timeoutMs: config.commandTimeoutMs })
686
997
  const numbered = raw.split('\n').map((l, i) => `${String(from + i).padStart(6)}\t${l}`).join('\n').replace(/\s+$/, '')
687
998
  let text = numbered === '' ? '(empty or out of range)' : numbered
688
999
  if (!args.endLine) text += '\n(shown up to ' + maxLines + ' lines; use startLine/endLine to page)'
@@ -693,9 +1004,10 @@ export async function apply(ctx, config) {
693
1004
  defineTool({
694
1005
  name: 'rw_exec',
695
1006
  description:
696
- 'Run a shell command on the remote host. Use for anything that is not reading a file (build, test, grep, etc). Output is capped.',
1007
+ 'Run a shell command on the remote host. Use for anything that is not reading a file (build, test, grep, etc). Output is capped. Runs in the current remote workspace by default; pass cwd to run elsewhere.',
697
1008
  parameters: {
698
1009
  command: { type: 'string', required: true, description: 'Shell command (run on the remote host)' },
1010
+ cwd: { type: 'string', description: 'Working directory for the command (default: the current remote workspace)' },
699
1011
  },
700
1012
  output: {
701
1013
  schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
@@ -704,7 +1016,10 @@ export async function apply(ctx, config) {
704
1016
  async execute(args) {
705
1017
  const cmd = String(args.command || '')
706
1018
  if (!cmd) throw new Error('rw_exec: command is required')
707
- return { text: await run(cmd, { timeoutMs: config.commandTimeoutMs }) }
1019
+ const ws = wsPath()
1020
+ const cwd = args.cwd ? normalizeRemotePath(String(args.cwd)) : (ws || '')
1021
+ const full = cwd ? `cd ${shq(cwd)} && ${cmd}` : cmd
1022
+ return { text: await run(full, { timeoutMs: config.commandTimeoutMs }) }
708
1023
  },
709
1024
  }),
710
1025
 
@@ -750,6 +1065,110 @@ export async function apply(ctx, config) {
750
1065
  },
751
1066
  }),
752
1067
 
1068
+ defineTool({
1069
+ name: 'rw_search',
1070
+ description:
1071
+ 'Search remote files for a pattern (grep, recursive, case-insensitive by default). Returns matching file:line rows; output is capped. Use instead of rw_exec grep when you want a bounded, portable search.',
1072
+ parameters: {
1073
+ pattern: { type: 'string', required: true, description: 'Pattern to search for (extended regex)' },
1074
+ path: { type: 'string', description: 'Directory to search (default: current remote workspace)' },
1075
+ glob: { type: 'string', description: 'Only files whose name matches this glob, e.g. *.ts (optional)' },
1076
+ ignoreCase: { type: 'boolean', description: 'Case-insensitive search (default true)' },
1077
+ },
1078
+ output: {
1079
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
1080
+ render: (_a, a) => [{ type: 'text', text: a.text }],
1081
+ },
1082
+ async execute(args) {
1083
+ const pattern = String(args.pattern || '')
1084
+ if (!pattern) throw new Error('rw_search: pattern is required')
1085
+ const ws = wsPath()
1086
+ const dir = args.path ? normalizeRemotePath(String(args.path)) : (ws || '')
1087
+ if (!dir) throw new Error('rw_search: no path and no remote workspace set')
1088
+ const flags = 'r' + (args.ignoreCase === false ? '' : 'i') + 'nE'
1089
+ const name = args.glob ? ` -name ${shq(String(args.glob))}` : ''
1090
+ // find … -exec grep {} + is POSIX (BSD + GNU): `grep -RInE` alone would
1091
+ // differ on BSD, and GNU-only --include is avoided in favor of find -name.
1092
+ const cmd = `find ${shq(dir)} -type f${name} -exec grep -${flags} -H -- ${shq(pattern)} {} + 2>/dev/null | head -n 2000`
1093
+ return { text: await run(cmd, { timeoutMs: Math.max(config.commandTimeoutMs, 30000) }) }
1094
+ },
1095
+ }),
1096
+
1097
+ defineTool({
1098
+ name: 'rw_download',
1099
+ description:
1100
+ 'Download a single remote file over SFTP into the local mirror of the current workspace (or to an explicit local path). Use when you need the actual file content locally, not just its text.',
1101
+ parameters: {
1102
+ path: { type: 'string', required: true, description: 'Absolute remote file path' },
1103
+ localPath: { type: 'string', description: 'Local destination (default: the workspace mirror, preserving the relative path)' },
1104
+ },
1105
+ output: {
1106
+ schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
1107
+ render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'downloaded' : 'failed') }],
1108
+ },
1109
+ async execute(args) {
1110
+ const p = normalizeRemotePath(String(args.path || ''))
1111
+ if (!p || p === '/') throw new Error('rw_download: a remote file path is required')
1112
+ let sftp
1113
+ try {
1114
+ sftp = await pool.sftp()
1115
+ } catch (err) {
1116
+ throw new Error('rw_download: sftp unavailable: ' + ((err && err.message) || err))
1117
+ }
1118
+ let local
1119
+ if (args.localPath) {
1120
+ local = path.resolve(String(args.localPath))
1121
+ } else {
1122
+ const ws = wsPath()
1123
+ if (!ws) throw new Error('rw_download: no remote workspace set — pass localPath explicitly')
1124
+ const base = mirrorDirFor(ws, config.host, config.username, config.port)
1125
+ const rel = p.startsWith(ws) ? p.slice(ws.length).replace(/^\/+/, '') : p.slice(1)
1126
+ local = path.join(base, rel)
1127
+ }
1128
+ mkdirSync(path.dirname(local), { recursive: true })
1129
+ const buf = await sftp.readFile(p)
1130
+ writeFileSync(local, buf)
1131
+ const bytes = buf.byteLength
1132
+ return { ok: true, bytes, text: `downloaded ${bytes} bytes from ${p} → ${local}` }
1133
+ },
1134
+ }),
1135
+
1136
+ defineTool({
1137
+ name: 'rw_upload',
1138
+ description:
1139
+ 'Upload a local file over SFTP to a path on the remote host (creating parent directories if needed). Use to push a local file directly, without a full rw_push of the whole mirror.',
1140
+ parameters: {
1141
+ localPath: { type: 'string', required: true, description: 'Absolute local file path' },
1142
+ path: { type: 'string', required: true, description: 'Absolute remote destination path' },
1143
+ },
1144
+ output: {
1145
+ schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
1146
+ render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'uploaded' : 'failed') }],
1147
+ },
1148
+ async execute(args) {
1149
+ const rp = normalizeRemotePath(String(args.path || ''))
1150
+ const lp = String(args.localPath || '')
1151
+ if (!rp || rp === '/' || !lp) throw new Error('rw_upload: both localPath and a remote path are required')
1152
+ if (!existsSync(lp)) throw new Error(`rw_upload: local file not found: ${lp}`)
1153
+ let sftp
1154
+ try {
1155
+ sftp = await pool.sftp()
1156
+ } catch (err) {
1157
+ throw new Error('rw_upload: sftp unavailable: ' + ((err && err.message) || err))
1158
+ }
1159
+ const segs = remoteDirname(rp).split('/').filter(Boolean)
1160
+ let cur = ''
1161
+ for (const s of segs) {
1162
+ cur += '/' + s
1163
+ try { await sftp.mkdir(cur) } catch { /* exists or no perms */ }
1164
+ }
1165
+ const buf = readFileSync(lp)
1166
+ await sftp.writeFile(rp, buf)
1167
+ const bytes = buf.byteLength
1168
+ return { ok: true, bytes, text: `uploaded ${bytes} bytes from ${lp} → ${rp}` }
1169
+ },
1170
+ }),
1171
+
753
1172
  defineTool({
754
1173
  name: 'rw_disconnect',
755
1174
  description:
@@ -780,7 +1199,7 @@ export async function apply(ctx, config) {
780
1199
  return (
781
1200
  '## Remote workspace\n' +
782
1201
  `Current remote workspace: ${config.username}@${config.host}:${w}\n` +
783
- 'Use the remote_* tools (rw_list_dir / rw_read_file / rw_exec) to inspect and act on files on the remote host. Treat this directory as the working root for this task.'
1202
+ 'Use the rw_* tools (rw_list_dir / rw_read_file / rw_write_file / rw_exec / rw_search) to inspect and act on files on the remote host. Treat this directory as the working root for this task.'
784
1203
  )
785
1204
  },
786
1205
  })
@@ -798,11 +1217,21 @@ export async function apply(ctx, config) {
798
1217
  text:
799
1218
  `Remote host: ${s.username}@${s.host || '<none>'} (connected: ${s.connected})\n` +
800
1219
  `Remote workspace: ${s.workspace || '(none)'}\n` +
801
- `\nUse tools: rw_list_dir / rw_read_file / rw_exec.` +
1220
+ `Host key: ${s.hostKeyKnown ? 'trusted ✓' : 'not yet trusted'} (mode=${s.hostKeyMode})\n` +
1221
+ (s.hostKeyKnown ? ` — if the key changed / was mistrusted, run /remote-forget-key\n` : '') +
1222
+ `\nUse tools: rw_list_dir / rw_read_file / rw_exec / rw_search.` +
802
1223
  (s.workspace ? `\nCurrently working in ${s.workspace}.` : ''),
803
1224
  }
804
1225
  },
805
1226
  })
1227
+ commands.register({
1228
+ name: 'remote-forget-key',
1229
+ description: 'Drop the trusted host-key record for the current machine so the next connect re-records it.',
1230
+ handler: () => {
1231
+ createHostKeyGuard(config).forgetHost()
1232
+ return { kind: 'success', text: `forgot host key for ${config.host || '<none>'}:${config.port} — the next connect will re-record it.` }
1233
+ },
1234
+ })
806
1235
  }
807
1236
 
808
1237
  // ── JSON endpoints for settings UI ─────────────────────────────────────────
@@ -814,13 +1243,63 @@ export async function apply(ctx, config) {
814
1243
  res.setHeader('Content-Type', 'application/json; charset=utf-8')
815
1244
  res.end(JSON.stringify(body))
816
1245
  }
1246
+ // Cap request bodies (1 MiB) so a malformed client cannot force us to buffer
1247
+ // unbounded memory on a local-only route.
1248
+ const MAX_BODY_BYTES = 1024 * 1024
817
1249
  const readBody = (req) =>
818
1250
  new Promise((resolve) => {
819
1251
  const chunks = []
820
- req.on('data', (c) => chunks.push(c))
1252
+ let total = 0
1253
+ req.on('data', (c) => {
1254
+ total += c.length
1255
+ if (total > MAX_BODY_BYTES) {
1256
+ req.removeAllListeners('data')
1257
+ resolve('{}')
1258
+ return
1259
+ }
1260
+ chunks.push(c)
1261
+ })
821
1262
  req.on('end', () => resolve(chunks.join('')))
822
1263
  })
823
1264
 
1265
+ // ── 本机目录选择器:DSH directoryPicker 服务优先,缺位/非原生则自持兜底 ──
1266
+ // 背景:web-app 的 `directory-picker` 行(-auto)在桌面启动路径里不物化成
1267
+ // loader 条目,`ctx.get('directoryPicker')` 实测为 null(沙箱 + 产品同路径)。
1268
+ // 因此插件自带原生选择器:macOS osascript / Linux zenity→kdialog,与 DSH
1269
+ // native 后端同一套调用约定(取消 → { cancelled },成功 → { path })。
1270
+ const PICK_TIMEOUT_MS = 120000
1271
+ const runPick = (bin, args) =>
1272
+ new Promise((resolve, reject) => {
1273
+ execFile(bin, args, { timeout: PICK_TIMEOUT_MS, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
1274
+ if (err) {
1275
+ const code = err.code
1276
+ const msg = String(stderr || '')
1277
+ // 用户在对话框里点了取消:osascript 退出码 1 + "User canceled"/-128
1278
+ if (code === 1 && /(?:user canceled|-128)/i.test(msg)) return resolve({ cancelled: true })
1279
+ if (err.signal || err.killed) return reject(new Error('目录选择已超时,请重试或直接在输入框填本地路径'))
1280
+ if (code === 'ENOENT') return reject(Object.assign(new Error('未找到目录选择器程序 ' + bin), { code }))
1281
+ return reject(new Error((msg.trim() || (err && err.message) || '无法打开系统文件夹选择器').split('\n')[0]))
1282
+ }
1283
+ const p = String(stdout || '').replace(/[\r\n]+$/, '').trim()
1284
+ resolve(p ? { path: p } : { cancelled: true })
1285
+ })
1286
+ })
1287
+ const pickLocalNative = async () => {
1288
+ const platform = process.platform
1289
+ if (platform === 'darwin') {
1290
+ return runPick('osascript', ['-e', 'set selectedFolder to choose folder with prompt "Select Workspace Directory"', '-e', 'POSIX path of selectedFolder'])
1291
+ }
1292
+ if (platform === 'linux') {
1293
+ try {
1294
+ return await runPick('zenity', ['--file-selection', '--directory', '--title=Select Workspace Directory'])
1295
+ } catch (err) {
1296
+ if (err && err.code === 'ENOENT') return runPick('kdialog', ['--getexistingdirectory', '.', '--title', 'Select Workspace Directory'])
1297
+ throw err
1298
+ }
1299
+ }
1300
+ throw new Error('当前系统不支持自动打开目录选择器,请在输入框直接填本地路径')
1301
+ }
1302
+
824
1303
  const routes = [
825
1304
  {
826
1305
  kind: 'exact',
@@ -857,8 +1336,10 @@ export async function apply(ctx, config) {
857
1336
  path: '/dsh-remote/ls',
858
1337
  handler: async (req, res) => {
859
1338
  try {
860
- const m = (req.url || '').match(/path=([^&]*)/)
861
- const p = m ? decodeURIComponent(m[1]) : wsPath()
1339
+ // Proper URL parsing so `+` in a path decodes to a literal space
1340
+ // (the old regex missed it).
1341
+ const q = new URL(req.url, 'http://localhost').searchParams
1342
+ const p = q.get('path') ? decodeURIComponent(q.get('path')) : wsPath()
862
1343
  const out = await listDirStructured(p || '/')
863
1344
  return sendJson(res, 200, { path: p, items: out.items })
864
1345
  } catch (err) {
@@ -877,7 +1358,7 @@ export async function apply(ctx, config) {
877
1358
  if (!p || p === '/') return sendJson(res, 400, { error: 'path must be an absolute directory' })
878
1359
  const r = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
879
1360
  if (r.stdout.trim() !== 'DIR') return sendJson(res, 400, { ok: false, error: `not a directory: ${p}` })
880
- config.workspace = p
1361
+ persistWorkspace(p)
881
1362
  const local = ensureMirror(p, config.host, config.username, config.port)
882
1363
  return sendJson(res, 200, { ok: true, workspace: p, localMirror: local, ...status() })
883
1364
  } catch (err) {
@@ -895,13 +1376,12 @@ export async function apply(ctx, config) {
895
1376
  const p = normalizeRemotePath(String(payload.path || ''))
896
1377
  if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path must be an absolute directory' })
897
1378
  if (!config.host) return sendJson(res, 400, { ok: false, error: 'no remote host configured/connected — connect first' })
898
- // optional: verify it's a directory over SSH when connected
899
- if (pool.client) {
900
- const r = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
901
- if (r.stdout.trim() !== 'DIR') return sendJson(res, 400, { ok: false, error: `not a directory (or unreachable): ${p}` })
902
- }
1379
+ // Always verify over SSH (connecting if needed) so an unconnected or
1380
+ // wrong host can never silently mint a mirror for a bogus path.
1381
+ const r = await pool.exec(`if [ -d ${shq(p)} ]; then echo DIR; else echo NOTDIR; fi`)
1382
+ if (r.stdout.trim() !== 'DIR') return sendJson(res, 400, { ok: false, error: `not a directory (or unreachable): ${p}` })
903
1383
  const local = ensureMirror(p, config.host, config.username, config.port)
904
- config.workspace = p
1384
+ persistWorkspace(p)
905
1385
  return sendJson(res, 200, { ok: true, path: p, localMirror: local, ...status() })
906
1386
  } catch (err) {
907
1387
  return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
@@ -914,16 +1394,38 @@ export async function apply(ctx, config) {
914
1394
  handler: async (req, res) => {
915
1395
  if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
916
1396
  try {
917
- const dp = (ctx && ctx.get && ctx.get('directoryPicker')) || (ctx && ctx.directoryPicker) || null
918
- if (!dp || typeof dp.capability !== 'function') return sendJson(res, 400, { ok: false, error: '本地目录选择器服务不可用(缺少 DSH directory-picker backend)' })
919
- const cap = await Promise.resolve(dp.capability())
920
- if (!cap || cap.kind !== 'native' || typeof cap.pick !== 'function') return sendJson(res, 400, { ok: false, error: '本地目录选择器不可用(当前为非原生/浏览后端,请在输入框手动填本地路径)' })
921
- const pickAbort = new AbortController()
922
- const signal = pickAbort.signal || null
923
- const picked = await Promise.resolve(cap.pick(signal))
924
- pickAbort.abort()
925
- if (!picked || typeof picked !== 'string') return sendJson(res, 200, { ok: true, cancelled: true })
926
- return sendJson(res, 200, { ok: true, path: picked })
1397
+ // 1) 优先用 DSH directoryPicker 服务(native 后端)。必须用
1398
+ // ctx.get() 按名取:属性形式 ctx.directoryPicker 会抛 cordis
1399
+ // "cannot get property ... without inject"(该名字未在本插件
1400
+ // inject 里声明),ctx.get() 无此要求、缺失返回 null。
1401
+ // 2) 服务缺位或非 native(实测 web-app 的 -auto 行在桌面启动路径
1402
+ // 不物化后端)→ 自持 osascript/zenity/kdialog 兜底。
1403
+ let outcome = null
1404
+ let via = 'service'
1405
+ const dp = (ctx && typeof ctx.get === 'function') ? (ctx.get('directoryPicker') || null) : null
1406
+ if (dp && typeof dp.capability === 'function') {
1407
+ try {
1408
+ const cap = await Promise.resolve(dp.capability())
1409
+ if (cap && cap.kind === 'native' && typeof cap.pick === 'function') {
1410
+ const pickAbort = new AbortController()
1411
+ const p = await Promise.resolve(cap.pick(pickAbort.signal || null))
1412
+ pickAbort.abort()
1413
+ outcome = (typeof p === 'string' && p) ? { path: p } : { cancelled: true }
1414
+ }
1415
+ } catch (err) {
1416
+ outcome = null // DSH 服务出错 → 落到自持兜底
1417
+ }
1418
+ }
1419
+ if (!outcome) {
1420
+ via = 'own'
1421
+ try {
1422
+ outcome = await pickLocalNative()
1423
+ } catch (err) {
1424
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) + ' — 可直接在输入框填本地路径' })
1425
+ }
1426
+ }
1427
+ if (outcome.cancelled) return sendJson(res, 200, { ok: true, cancelled: true, via })
1428
+ return sendJson(res, 200, { ok: true, path: outcome.path, via })
927
1429
  } catch (err) {
928
1430
  return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
929
1431
  }
@@ -1020,6 +1522,18 @@ export async function apply(ctx, config) {
1020
1522
  }
1021
1523
  },
1022
1524
  },
1525
+ {
1526
+ kind: 'exact',
1527
+ path: '/dsh-remote/forget-key',
1528
+ handler: async (req, res) => {
1529
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
1530
+ // Drop the trusted host-key record for the CURRENT target so the next
1531
+ // connect re-records it (use after an intentional host reinstall or a
1532
+ // false MITM alarm).
1533
+ createHostKeyGuard(config).forgetHost()
1534
+ return sendJson(res, 200, { ok: true, ...status() })
1535
+ },
1536
+ },
1023
1537
  ]
1024
1538
 
1025
1539
  const disposers = routes.map((r) => webServer.register(r))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote",
3
- "version": "0.5.10",
3
+ "version": "0.6.7",
4
4
  "description": "Remote-work assistant for DeepSeek Harness: connect SSH (password or key), pick a remote workspace, operate on it with rw_pick_workspace / rw_list_dir / rw_read_file / rw_exec / rw_sync tools, and mirror it to a real local directory (SFTP) so the DSH native workspace can adopt it.",
5
5
  "license": "MIT",
6
6
  "author": "flymysql <flyphp@outlook.com>",