dsh-remote 0.8.18 → 0.8.21

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 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** (`.dsh-remote-ignore` under `remote-workspaces`, defaults cover `.git/node_modules/target/dist/build/…`).
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 better-sidebar remote file tab is now **editable**: click **编辑** → edit → **保存到远程** with an mtime optimistic lock (409 + "重新读取" on concurrent change). The explorer rows show file sizes and have a **right-click menu** (下载到本地镜像 / 重命名 / 删除 / 新建目录).
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
- Validation so far covers Host startup, Desktop IPC JSON requests, read-only
81
- SSH connection/list/read, and opening the settings/import UI. Native file-tab
82
- navigation, editing/sync, concurrent sessions on different machines, and the
83
- full legacy Web UI still need end-to-end acceptance before release. In
84
- particular, existing sidebar file endpoints use the active-machine pool;
85
- session-scoped tab addresses alone do **not** make those endpoints
86
- session-bound. This is not a claim of production-ready multi-machine Desktop
87
- support.
88
-
89
- Desktop's package installer may also require an explicit policy for the
90
- optional `ssh2` / `cpu-features` build scripts. The isolated transport test
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 配置的导入。文件标签的完整 UI 操作、编辑/同步、多机器并行会话,以及旧 Web
21
- 版完整 UI 回归仍需在发布前验收。尤其是现有侧栏文件接口仍使用“当前机器”的连接池,
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 同步(增量)** —— `rw_sync`(远程→镜像)、`rw_push`(镜像→远程),本地镜像改动可回传机器。两者都会**跳过 size+mtime 未变化的文件**,并有**单文件大小上限**防误拉大二进制;目录遍历带**有界并发**。
64
- - **模型工具** —— `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`。
65
- - **直接写远程文件** —— `rw_write_file` 直接创建/覆盖远程文件(自动建父目录),单个文件改动不必绕本地镜像来回同步;`rw_download` / `rw_upload` 可单文件双向取真字节。
66
- - **连接体检** —— 设置页提供「测试连接」按钮,在保存机器之前先验证 host/user/密码/私钥是否可用。
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
- - **远端跨平台** —— 命令全部用可移植 POSIX 写法(`ls -la` / `sed -n` / `find … -exec grep`),macOS/BSD 与 GNU/Linux 远端都能用。
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(path, content)` —— 直接创建或覆盖远程文件
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
- - `rw_sync` / `rw_push` —— 拉取/推送本地镜像 <-> 远程
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
+ }