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 +21 -21
- package/README.md +49 -7
- package/README.zh.md +39 -7
- package/cordis.patch.yml +6 -6
- package/lib/client.js +5 -5
- package/lib/index.js +616 -102
- package/package.json +1 -1
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**
|
|
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
|
-
- `
|
|
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 (
|
|
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 式级联)。另有
|
|
39
|
-
- **双向 SFTP
|
|
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
|
-
- `
|
|
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 | `''` |
|
|
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((
|
|
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) } }
|
|
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 {
|
|
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(
|
|
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)
|
|
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
|
-
|
|
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
|
-
/**
|
|
129
|
-
async function
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
|
276
|
+
})
|
|
277
|
+
return stats
|
|
161
278
|
}
|
|
162
279
|
|
|
163
|
-
/** Recursively upload a local mirror tree → remote SFTP dir. Bounded by files
|
|
164
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
188
|
-
|
|
315
|
+
stats.files++
|
|
316
|
+
stats.pushed.push(rp)
|
|
189
317
|
} catch {
|
|
190
318
|
/* skip unreadable / unwritable */
|
|
191
319
|
}
|
|
192
|
-
}
|
|
193
|
-
return
|
|
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(
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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.
|
|
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.
|
|
449
|
-
*
|
|
450
|
-
*
|
|
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
|
|
454
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
614
|
-
|
|
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,
|
|
642
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
861
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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
|
-
|
|
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
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
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.
|
|
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>",
|