dsh-remote 0.8.18 → 0.8.20
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/README.md +13 -15
- package/README.zh.md +18 -12
- package/lib/binding.js +69 -0
- package/lib/client.js +369 -136
- package/lib/hostkey.js +60 -0
- package/lib/index.js +104 -924
- package/lib/pool.js +511 -0
- package/lib/remote-fs.js +143 -0
- package/lib/routes-fs.js +221 -0
- package/lib/search.js +59 -1
- package/lib/sync.js +15 -10
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -41,10 +41,10 @@ Real capture (host scrubbed to a placeholder):
|
|
|
41
41
|
- **远程 / Remote** — the picker is a **centered modal**. Pick a **machine** → on Windows hosts the root shows a **"This PC" drive view** (`C:\`, `D:\`, `E:\`… instead of the Git Bash MSYS root) and the path field live **autocompletes** directories (accepts `C:\Users\…` or `/c/Users/…` — Windows paths are rewritten to the Git Bash form underneath); selecting a directory immediately lists its next level. A **浏览…** floating browser (Windows-aware breadcrumb `此电脑 / C:\ / Users / dev`, drive rows, size + mtime, dirs first, follows symlinks) fills the field without committing; the **回上一级** button works at any depth (even when the browser was opened at the path bar's value). **最近 workspaces** quick-pick, **`~` 主目录** shortcut and **新建目录** are one click away. On confirm it creates a **real local mirror** under `$DSH_HOME/remote-workspaces/<host>-<user>-<port>/<base>` that passes `fs.realpath` → the harness adopts it as a real workspace while dsh-remote keeps it synced over SFTP.
|
|
42
42
|
- **Git Bash default terminal (Windows remotes)** — the remote platform is auto-detected (`cmd /c ver`, plus an `uname -s` MINGW/MSYS probe as fallback); on Windows the plugin locates Git Bash (`config.shell` can pin a path or `native` disables wrapping) and pipes every command to `bash -s` over the exec channel, so quoting/backslash escaping is never an issue regardless of the SSH default shell. `rw_exec` runs with a Git Bash cwd (`/c/Users/…` form). `/dsh-remote/status`, `rw_info` and the 测试连接 button report the detected platform + shell.
|
|
43
43
|
- **Windows path auto-conversion** — typing `C:\Users\dev\project` (or `C:/…`, `/c/…`, `/C:/…`) is normalized underneath to the Git Bash form `/c/Users/dev/project` for shell commands, while workspaces are stored and shown Windows-style (`C:\Users\dev\project`). All model tools accept and report both forms; SFTP access uses the Win32-OpenSSH `/D:/…` form (see `toSftpPath`).
|
|
44
|
-
- **Bidirectional SFTP sync, conflict-aware** — `rw_sync` (remote → mirror) and `rw_push` (mirror → remote) are **three-way** (remote vs local vs last-synced snapshot): files changed on both sides are **reported as conflicts and never silently overwritten** (`force=true` overrides). Both support **dry-run**, **background tasks**, and honor **gitignore-style ignore rules
|
|
44
|
+
- **Bidirectional SFTP sync, conflict-aware** — `rw_sync` (remote → mirror) and `rw_push` (mirror → remote) are **three-way** (remote vs local vs last-synced snapshot): files changed on both sides are **reported as conflicts and never silently overwritten** (`force=true` overrides). Defaults are **depth 8 / 2000 files**; hitting a cap is reported as **`TRUNCATED`**. Both support **dry-run**, **background tasks**, and honor **gitignore-style ignore rules**.
|
|
45
45
|
- **Model tools** — 20 tools, all Windows/POSIX portable via SFTP: `rw_info`, `rw_connect` (with `save`), `rw_pick_workspace`, `rw_list_dir` (size+mtime), `rw_stat`, `rw_read_file` (encoding-aware: utf-8/gbk), `rw_write_file`, **`rw_edit`** (literal replace + mtime optimistic lock), `rw_append`, `rw_mkdir`, `rw_remove` (recursive, bounded), `rw_move`, `rw_exec` (pty/env), **`rw_search`** (SFTP tree walk — works on Windows too, honors ignore rules, context lines), `rw_download`/`rw_upload` (streaming fastGet/fastPut + size caps), **`rw_forward`** (SSH tunnels), `rw_sync`, `rw_push`, `rw_disconnect`.
|
|
46
46
|
- **Port forwarding panel** — create/start/stop/remove **local** (`127.0.0.1:port → remote`) and **reverse** (`remote → local`) tunnels in the Settings page or via `rw_forward`; definitions persist, auto-restart on reconnect when enabled, all tunnels stop on disconnect.
|
|
47
|
-
- **Sidebar remote editing** — the
|
|
47
|
+
- **Sidebar remote editing** — the remote file tab is **editable**: click **编辑** → edit → **保存到远程** with an mtime optimistic lock (409 + "重新读取" on concurrent change). File ops are **session-bound** (v0.8.19): the explorer sends `sessionId` so two conversations on different hosts do not share the active-machine pool. The explorer rows show file sizes and have a **right-click menu** (下载到本地镜像 / 重命名 / 删除 / 新建目录).
|
|
48
48
|
- **Command audit log** — every `rw_exec`/write/remove/move/forward is appended to `$DSH_HOME/remote-workspaces/audit.log` (time · user@host · op · exit code · command); the Settings page shows the last 30.
|
|
49
49
|
- **Async long tasks** — `rw_sync`/`rw_push` with `async: true` return a `taskId`; progress/result/cancel via `/dsh-remote/task` (single-flight queue).
|
|
50
50
|
- **Connection health** — a **「测试连接」** button validates host/user/key/password (with per-category error hints: auth / network / host key / timeout) before you save a machine; latency is cached on the machine record.
|
|
@@ -77,19 +77,17 @@ core or require a listening Web server:
|
|
|
77
77
|
- `dsh-better-sidebar` is not bundled. Web hosts may install it separately;
|
|
78
78
|
official Desktop uses the native right-sidebar integration instead.
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
disabled those optional scripts; this change does not loosen an application's
|
|
92
|
-
build allowlist or automatically approve dependency scripts.
|
|
80
|
+
Since **v0.8.19**, sidebar `/ls` `/read` `/write` `/fs` resolve the session's
|
|
81
|
+
mirror binding (same path as `rw_*`) when the client sends `sessionId`. Two
|
|
82
|
+
sessions on different hosts no longer share the active-machine pool for file
|
|
83
|
+
ops. Host-side tests cover that routing plus the editor 409/re-read/save path.
|
|
84
|
+
|
|
85
|
+
Official Desktop's native file-tab GUI, failed/cancelled dialogs, non-macOS
|
|
86
|
+
hosts, and a full legacy Web UI pass are still experimental. Desktop's package
|
|
87
|
+
installer may also require an explicit policy for the optional `ssh2` /
|
|
88
|
+
`cpu-features` build scripts. The isolated transport test disabled those
|
|
89
|
+
optional scripts; this change does not loosen an application's build allowlist
|
|
90
|
+
or automatically approve dependency scripts.
|
|
93
91
|
|
|
94
92
|
### Published Web bundle
|
|
95
93
|
|
package/README.zh.md
CHANGED
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
原生右侧栏集成。
|
|
18
18
|
|
|
19
19
|
已验证 Host 启动、IPC 请求、真实 SSH 的只读连接/目录列表/文本读取,以及设置页和
|
|
20
|
-
测试 SSH
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
测试 SSH 配置的导入。**v0.8.19** 起侧栏 `/ls` `/read` `/write` `/fs` 在请求带
|
|
21
|
+
`sessionId` 时按该会话的镜像绑定选机(与 `rw_*` 同一套),不再落到「当前机器」
|
|
22
|
+
连接池;宿主侧测试覆盖双机会话路由与编辑 409/重读/保存。原生文件标签的完整 GUI、
|
|
23
|
+
失败/取消交互、非 macOS 宿主以及旧 Web 版完整 UI 回归仍属实验性。
|
|
23
24
|
|
|
24
25
|
Desktop 安装器还可能要求明确配置 `ssh2` / `cpu-features` 可选构建脚本策略。
|
|
25
26
|
隔离验证中禁用了这些可选脚本;本改动不放宽应用的构建白名单,也不自动批准脚本。
|
|
@@ -54,18 +55,21 @@ DSH 的 Web 界面刻意只监听 `127.0.0.1`(CLI 为安全拒绝 `--host 0.0.
|
|
|
54
55
|
|
|
55
56
|
## 功能
|
|
56
57
|
|
|
57
|
-
- **多机 SSH** —— 可存任意多台主机(host/port/user +
|
|
58
|
+
- **多机 SSH** —— 可存任意多台主机(host/port/user + **私钥**或**密码**)。密码只存在本地,界面不回显;在设置里一键切当前机。每机可配 passphrase / 主机指纹策略 / SSH agent / keyboard-interactive(OTP)/ 跳板机,以及可选的 **系统钥匙串加密密码**。
|
|
59
|
+
- **`~/.ssh/config` 导入** —— 设置页列出 Host 别名,一点填入表单(只引用路径,不读密钥材料)。
|
|
58
60
|
- **双 tab 工作区选择器**(填充原生「Add workspace」流程):
|
|
59
61
|
- **本机** —— 走 **host 端原生系统文件夹对话框**选本地目录(或直接输入本地路径)→ 直接成为普通 DSH 本地工作区(与本地工作区共存)。优先用 DSH 的 `directoryPicker` 服务,服务缺失时**回退到插件自持的原生选择器**(macOS `osascript` / Linux `zenity`→`kdialog`)——桌面启动路径上框架服务不注册也能用。
|
|
60
62
|
- **远程** —— 选择器是**居中弹窗**(窄侧边栏也不会被挤压)。先**选机器** → Windows 主机根级显示 **「此电脑」多盘视图**(`C:\`、`D:\`、`E:\`…,而不是 Git Bash 的 MSYS 根),路径框**实时补全**目录(支持 `C:\Users\…` 或 `/c/Users/…` 任意写法,Windows 路径在底层自动改写为 Git Bash 形式);**选中一个目录立即列出它下一级**(OS/VSCode 式级联)。另有 **「浏览…」文件选择式浮层**(Windows 面包屑 `此电脑 / C:\ / Users / dev` 可点击跳级、驱动器行、大小/时间、跟随软链),选中**回填输入框不提交**,你复核/修改后再确定;「回上一级」任意深度可用(包括浮层直接打开在路径栏当前路径时)。**最近工作区**快捷入口、**`~` 主目录**、**新建目录**一键可达。确定会创建**真实本地镜像**(`$DSH_HOME/remote-workspaces/<host>-<user>-<port>/<basename>`;仅当同主机上**别的远端路径**已占用同名 basename 时才追加短路径 hash)→ harness 把它当真实工作区收养,同时 dsh-remote 通过 SFTP 保持同步。所选工作区会**持久化到该机器**,重启不丢。
|
|
61
63
|
- **Git Bash 默认终端(Windows 主机)** —— 自动探测远程平台(`cmd /c ver`,附 `uname -s` 的 MINGW/MSYS 探测兜底);Windows 机器自动定位 Git Bash(`config.shell` 可显式指定或 `native` 关闭),所有命令经 `bash -s` 从 SSH 通道 stdin 管道执行,不依赖 cmd/PowerShell,也不受引号/反斜杠转义困扰;`rw_exec` 默认在 Git Bash 形式的 cwd(`/c/Users/…`)下执行。`/dsh-remote/status`、`rw_info`、设置页「测试连接」都会报告检测到的平台与 shell。
|
|
62
64
|
- **Windows 路径自动改写** —— 用户输入 `C:\Users\dev\project`(或 `C:/…`、`/c/…`、`/C:/…`)时底层自动规范为 Git Bash 形式 `/c/Users/dev/project` 执行;工作区存储与展示为 Windows 形式 `C:\Users\dev\project`。模型工具全部接受并展示两种写法;SFTP 访问使用 Win32-OpenSSH 的 `/D:/…` 形式(见 `toSftpPath`)。
|
|
63
|
-
- **双向 SFTP
|
|
64
|
-
-
|
|
65
|
-
-
|
|
66
|
-
-
|
|
65
|
+
- **双向 SFTP 同步(三路冲突检测)** —— `rw_sync`(远程→镜像)、`rw_push`(镜像→远程)。两边都改过的文件会列出冲突、绝不静默覆盖(`force=true` 覆盖)。默认 **深度 8 / 2000 文件**,触顶会标明 **`TRUNCATED`**。支持 dry-run、后台任务、gitignore 风格 ignore 规则。
|
|
66
|
+
- **模型工具(20 个)** —— `rw_info`、`rw_connect`、`rw_pick_workspace`、`rw_list_dir`、`rw_stat`、`rw_read_file`(utf-8/gbk)、`rw_write_file`、`rw_edit`(字面替换 + mtime 乐观锁)、`rw_append`、`rw_mkdir`、`rw_remove`、`rw_move`、`rw_exec`、`rw_search`(POSIX 优先 rg/grep,否则 SFTP 遍历)、`rw_download`/`rw_upload`、`rw_forward`、`rw_sync`、`rw_push`、`rw_disconnect`。
|
|
67
|
+
- **端口转发面板** —— 设置页或 `rw_forward` 创建/启停本地与反向隧道。
|
|
68
|
+
- **侧栏远程编辑** —— 远程文件 tab 可编辑并保存到远端(mtime 乐观锁)。**v0.8.19** 起文件操作按会话绑定机器。
|
|
69
|
+
- **命令审计** —— `rw_exec`/写/删/移动/转发写入 `audit.log`;设置页显示最近 30 条。
|
|
70
|
+
- **连接体检** —— 设置页「测试连接」按类别提示(认证 / 网络 / 主机指纹 / 超时)。
|
|
67
71
|
- 当前 `user@host:/path` 会注入每次系统提示,让 Agent 明确自己的工作根。
|
|
68
|
-
- **远端跨平台** ——
|
|
72
|
+
- **远端跨平台** —— 文件访问走 SFTP 协议(不依赖 POSIX shell),Linux/macOS/Windows 远端都能列/读/写/搜索/同步。
|
|
69
73
|
- **主机指纹校验(TOFU)** —— 每次 SSH 连接都校验主机密钥(`hostKeyMode: accept-new`):首次连接记录,之后**密钥一旦变化立即拒绝**(防中间人)。`verify` 模式还会拒绝从未见过的机器;`off` 关闭校验。指纹存于 `$DSH_HOME/remote-workspaces/known_hosts.json`;误判可用 `/remote forget-key` 重置。
|
|
70
74
|
- **数据跟随 Harness 根目录** —— 机器清单与镜像放在 `$DSH_HOME/remote-workspaces`(桌面版即 `userData/harness` 下);0.6 之前落在 `~/.dsh/remote-workspaces` 的数据**首次启动自动迁移**,不丢失。
|
|
71
75
|
- **不改任何 `dsh-workspace` 官方代码** —— 全部作为普通插件实现(client 半以 `priority -100` 填充 directory-flow holes)。
|
|
@@ -108,11 +112,13 @@ dsh plugin add dsh-better-sidebar
|
|
|
108
112
|
- **本机** → 系统文件夹选择(或输入本地路径)→ 本地工作区。
|
|
109
113
|
- **远程** → 选机器 → 浏览到远程目录(或输入 `/path`)→ 「设为远程工作区」⇒ 创建并收养一个本地镜像工作区。
|
|
110
114
|
3. **让 Agent 工作** —— 把它当普通工作区用:
|
|
111
|
-
- `rw_list_dir(path?)` / `rw_read_file` —— 查看远程文件
|
|
112
|
-
- `rw_write_file
|
|
115
|
+
- `rw_list_dir(path?)` / `rw_read_file` / `rw_stat` —— 查看远程文件
|
|
116
|
+
- `rw_write_file` / `rw_edit` / `rw_append` —— 创建、补丁、追加远程文件
|
|
117
|
+
- `rw_mkdir` / `rw_remove` / `rw_move` —— 管理远程路径
|
|
113
118
|
- `rw_search(pattern, path?)` —— 远程 grep
|
|
114
119
|
- `rw_exec(command, cwd?)` —— 在远程执行命令(默认在工作区目录)
|
|
115
|
-
- `
|
|
120
|
+
- `rw_forward` —— SSH 隧道
|
|
121
|
+
- `rw_sync` / `rw_push` —— 冲突感知的镜像拉取/推送
|
|
116
122
|
|
|
117
123
|
> **Remote context 是 session 级的(v0.8.8+)**:system prompt 只会在**当前 session 的
|
|
118
124
|
> cwd 位于某个远程 mirror 内**(即你把远程目录选成了这个 session 的工作区)时注入
|
package/lib/binding.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
15
15
|
import path from 'node:path'
|
|
16
|
+
import zlib from 'node:zlib'
|
|
16
17
|
|
|
17
18
|
/** Strip trailing separators so a mirror root and a path inside it compare cleanly. */
|
|
18
19
|
const norm = (p) => path.resolve(String(p || '')).replace(/[\\/]+$/, '') || ''
|
|
@@ -95,3 +96,71 @@ export function resolveMirror(local, root) {
|
|
|
95
96
|
export function poolKey(m) {
|
|
96
97
|
return `${(m && m.username) || ''}@${(m && m.host) || ''}:${Number(m && m.port) || 22}`
|
|
97
98
|
}
|
|
99
|
+
|
|
100
|
+
/** Safe path segment for a session id (mirrors DSH's encodeSegment). */
|
|
101
|
+
export function encodeSegmentSafe(id) {
|
|
102
|
+
const s = String(id || '').replace(/[^A-Za-z0-9._-]/g, '_')
|
|
103
|
+
return s || 'session'
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Read the cwd from a session log header (JSONL / .gz / zstd first frame). */
|
|
107
|
+
export function readSessionHeaderCwd(file) {
|
|
108
|
+
try {
|
|
109
|
+
let buf = readFileSync(file)
|
|
110
|
+
if (/\.zstd$/.test(file)) buf = zlib.zstdDecompressSync(buf)
|
|
111
|
+
else if (/\.gz$/.test(file)) buf = zlib.gunzipSync(buf)
|
|
112
|
+
const nl = buf.indexOf(10)
|
|
113
|
+
if (nl < 0) return ''
|
|
114
|
+
const head = JSON.parse(buf.subarray(0, nl).toString('utf8'))
|
|
115
|
+
return typeof head.cwd === 'string' ? head.cwd : ''
|
|
116
|
+
} catch {
|
|
117
|
+
return ''
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Resolve a session's workspace cwd from the live sessions service, then from
|
|
123
|
+
* durable session logs under `$DSH_HOME/sessions`.
|
|
124
|
+
*/
|
|
125
|
+
export function lookupSessionCwd(sessionId, { sessions, dshHome } = {}) {
|
|
126
|
+
if (!sessionId) return ''
|
|
127
|
+
try {
|
|
128
|
+
if (sessions && typeof sessions.get === 'function') {
|
|
129
|
+
const s = sessions.get(sessionId)
|
|
130
|
+
const cwd = s && s.header && s.header.cwd
|
|
131
|
+
if (cwd) return String(cwd)
|
|
132
|
+
}
|
|
133
|
+
} catch { /* sessions service unavailable */ }
|
|
134
|
+
if (!dshHome) return ''
|
|
135
|
+
try {
|
|
136
|
+
const sessionsRoot = path.join(dshHome, 'sessions')
|
|
137
|
+
if (!existsSync(sessionsRoot)) return ''
|
|
138
|
+
const targetDir = encodeSegmentSafe(sessionId)
|
|
139
|
+
for (const projDir of readdirSync(sessionsRoot)) {
|
|
140
|
+
const projPath = path.join(sessionsRoot, projDir)
|
|
141
|
+
if (!statSync(projPath, { throwIfNoEntry: false })?.isDirectory?.()) continue
|
|
142
|
+
const sessDir = path.join(projPath, targetDir)
|
|
143
|
+
if (!statSync(sessDir, { throwIfNoEntry: false })?.isDirectory?.()) continue
|
|
144
|
+
const logFile = ['session.jsonl.zstd', 'session.jsonl', 'session.jsonl.gz']
|
|
145
|
+
.map((n) => path.join(sessDir, n)).find((p) => existsSync(p))
|
|
146
|
+
if (!logFile) continue
|
|
147
|
+
const cwd = readSessionHeaderCwd(logFile)
|
|
148
|
+
if (cwd) return cwd
|
|
149
|
+
}
|
|
150
|
+
} catch { /* session log scan failed */ }
|
|
151
|
+
return ''
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Pull sessionId / local from a JSON HTTP request (query string + body). */
|
|
155
|
+
export function requestSessionHint(req, body = {}) {
|
|
156
|
+
let sessionId = ''
|
|
157
|
+
let local = ''
|
|
158
|
+
try {
|
|
159
|
+
const q = new URL(req.url || '', 'http://localhost').searchParams
|
|
160
|
+
sessionId = q.get('sessionId') ? decodeURIComponent(q.get('sessionId')) : ''
|
|
161
|
+
local = q.get('local') ? decodeURIComponent(q.get('local')) : ''
|
|
162
|
+
} catch { /* malformed url */ }
|
|
163
|
+
if (!sessionId && body && body.sessionId) sessionId = String(body.sessionId)
|
|
164
|
+
if (!local && body && body.local) local = String(body.local)
|
|
165
|
+
return { sessionId: String(sessionId || '').trim(), local: String(local || '').trim() }
|
|
166
|
+
}
|