From ba90685710ed085cd26e5aa4c8105c320b810d92 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 02:46:41 +0000 Subject: [PATCH] Bind sidebar file routes to the session machine (0.8.19). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar /ls /read /write /fs now take sessionId (or a mirror local path) and use the same mirror binding as rw_* tools, so two conversations on different hosts no longer share the active-machine pool. Picker requests without a session hint keep the current-machine pool. Also: SFTP range reads for oversized previews, higher sync caps with an explicit TRUNCATED signal, POSIX rg/grep search fallback, extract SshPool and FS routes out of index.js, and align README.zh / PUBLISH.md / tool counts. Co-authored-by: 兰州小红鸡 --- CHANGELOG.md | 13 + PUBLISH.md | 65 ++- README.md | 28 +- README.zh.md | 30 +- check.mjs | 21 +- docs/promote.md | 20 +- lib/binding.js | 69 +++ lib/client.js | 51 +- lib/hostkey.js | 60 +++ lib/index.js | 1028 ++++----------------------------------- lib/pool.js | 511 +++++++++++++++++++ lib/remote-fs.js | 143 ++++++ lib/routes-fs.js | 221 +++++++++ lib/search.js | 60 ++- lib/sync.js | 25 +- package.json | 4 +- test/desktop-fs.test.js | 100 ++++ test/helpers.js | 4 + test/remote-fs.test.js | 35 ++ test/search.test.js | 30 +- test/session-fs.test.js | 152 ++++++ test/sync.test.js | 11 + 22 files changed, 1648 insertions(+), 1033 deletions(-) create mode 100644 lib/pool.js create mode 100644 lib/remote-fs.js create mode 100644 lib/routes-fs.js create mode 100644 test/desktop-fs.test.js create mode 100644 test/remote-fs.test.js create mode 100644 test/session-fs.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 1954598..7d3a7cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to **dsh-remote**. +## 0.8.19 — 2026-09-17 +### 侧栏文件接口按会话绑定机器(Desktop 多机阻断项) + +- `/dsh-remote/ls`、`/read`、`/write`、`/fs` 接受 `sessionId`(或 `local=` 镜像路径),走与 `rw_*` 相同的 mirror binding;本地会话返回 403,不再落到「当前机器」连接池。 +- 工作区选择器仍不带 `sessionId`,继续使用当前机器(设为当前后再浏览)。 +- 客户端 explorer / 文件 tab / 原生右侧栏都会把 `sessionId` 附在请求上;保存使用 `expectedMtime` 乐观锁。 +- 大文件预览改为 SFTP `readPartial` 范围读,不再整文件 `fastGet` 到可预测的临时路径。 +- 同步默认 `depth=8` / `maxFiles=2000`,结果带明确 `TRUNCATED`;POSIX 远端 `rw_search` 优先 `rg`/`grep -R`,失败再 SFTP walk。 +- 拆出 `lib/pool.js`、`lib/routes-fs.js`、`lib/remote-fs.js`;host-key TOFU 守卫进 `lib/hostkey.js`。 +- 文档:中英文 README / `package.json` 工具数(20)/ `PUBLISH.md` 对齐当前功能。 + +**验证**:`npm test`;新增 session-fs / desktop-fs / remote-fs 回归。Desktop 完整 GUI 仍标实验性,但侧栏选机阻断项已修。 + ## 0.8.18 — 2026-09-16 ### 变更:解除 dsh-better-sidebar 硬绑定 diff --git a/PUBLISH.md b/PUBLISH.md index eb9ff11..8692fea 100644 --- a/PUBLISH.md +++ b/PUBLISH.md @@ -1,57 +1,54 @@ # Publish Guide — dsh-remote -## 1. Create the GitHub repository +Current product: a **remote-work assistant** for DeepSeek Harness (multi-machine +SSH, remote workspace picker, 20 `rw_*` tools, conflict-aware SFTP sync, port +forwarding, optional sidebar editor). This is **not** the early “print SSH +tunnel commands” plugin. -https://github.com/new → name **`dsh-remote`**, Public, description: +## 1. Version and changelog -> Remote-access assistant for DeepSeek Harness: /remote command and a settings page that print the exact SSH tunnel / reverse-tunnel / reverse-proxy commands (harness intentionally binds loopback only). +Bump `package.json` `version`, add a section to `CHANGELOG.md`, keep +`README.md` / `README.zh.md` in sync (tool list, Desktop notes). -Add topic **`dsh-plugin`** (plus `deepseek-harness`, `remote`, `ssh`). - -## 2. Push +## 2. Checks ```bash -cd dsh-remote -git init -b main && git add -A -git commit -m "feat: dsh-remote — remote-access assistant for DeepSeek Harness - -- /remote slash command printing exact tunnel commands -- Settings page (远程访问) with live port, LAN IPs, copy buttons -- local-forward / autossh / reverse-tunnel / reverse-proxy guidance -- respects the harness safety design (loopback-only, no 0.0.0.0 hack)" -git branch -M main -git remote add origin https://github.com/flymysql/dsh-remote.git -git push -u origin main +for f in lib/*.js; do node --check "$f"; done +node check.mjs +npm test ``` +Optional: `scripts/boot-smoke.sh` if a desktop harness is installed. + ## 3. Publish to npm ```bash -npm publish # needs a Granular Access Token with Bypass-2FA (npm 2026 policy) +npm publish # Granular Access Token with Bypass-2FA (npm 2026 policy) +``` + +GitHub: tag `vX.Y.Z` and paste the CHANGELOG section into the release notes. + +## 4. Topics / discovery + +Repo **About → Topics**: + +``` +dsh-plugin deepseek-harness remote ssh tunnel plugin ``` -## 4. Community submissions +README must reference `docs/cover.png` with a **relative** path so GitHub Topics +can show a card image. -Open issues in the awesome lists with this template (see the dsh-memory run: https://github.com/flymysql/dsh-memory): +## 5. Install blurb (awesome lists) ```markdown ## dsh-remote -Remote-access assistant for DeepSeek Harness: the harness web GUI intentionally -binds loopback only (--host 0.0.0.0 is rejected for safety), so remote access is a -tunneling workflow. This plugin makes it copy-paste easy. +Remote-work assistant for DeepSeek Harness: connect to SSH machines, pick a +remote workspace, and let the agent operate there (list/read/edit/exec/sync) +without exposing the harness on `0.0.0.0`. - **Repo**: https://github.com/flymysql/dsh-remote - **npm**: https://www.npmjs.com/package/dsh-remote -- **Topic**: dsh-plugin -- **Category**: productivity / remote - -### What it does -`/remote [user@host]` prints the exact commands: SSH local forward, autossh -keepalive, reverse tunnel (NAT-friendly), reverse-proxy with --trusted-host. -Settings → 远程访问 shows the live port, LAN IPs, trusted hosts and one-click -copy. Respects the official safety design — no 0.0.0.0 hack. - -### Install -npm install dsh-remote, then add `{ id: dsh-remote, name: dsh-remote }` to cordis.yml. +- **Install**: `dsh plugin add dsh-remote` ``` diff --git a/README.md b/README.md index 456d002..12c21aa 100644 --- a/README.md +++ b/README.md @@ -41,10 +41,10 @@ Real capture (host scrubbed to a placeholder): - **远程 / 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/--/` that passes `fs.realpath` → the harness adopts it as a real workspace while dsh-remote keeps it synced over SFTP. - **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. - **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`). -- **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/…`). +- **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**. - **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`. - **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. -- **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** (下载到本地镜像 / 重命名 / 删除 / 新建目录). +- **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** (下载到本地镜像 / 重命名 / 删除 / 新建目录). - **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. - **Async long tasks** — `rw_sync`/`rw_push` with `async: true` return a `taskId`; progress/result/cancel via `/dsh-remote/task` (single-flight queue). - **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: - `dsh-better-sidebar` is not bundled. Web hosts may install it separately; official Desktop uses the native right-sidebar integration instead. -Validation so far covers Host startup, Desktop IPC JSON requests, read-only -SSH connection/list/read, and opening the settings/import UI. Native file-tab -navigation, editing/sync, concurrent sessions on different machines, and the -full legacy Web UI still need end-to-end acceptance before release. In -particular, existing sidebar file endpoints use the active-machine pool; -session-scoped tab addresses alone do **not** make those endpoints -session-bound. This is not a claim of production-ready multi-machine Desktop -support. - -Desktop's package installer may also require an explicit policy for the -optional `ssh2` / `cpu-features` build scripts. The isolated transport test -disabled those optional scripts; this change does not loosen an application's -build allowlist or automatically approve dependency scripts. +Since **v0.8.19**, sidebar `/ls` `/read` `/write` `/fs` resolve the session's +mirror binding (same path as `rw_*`) when the client sends `sessionId`. Two +sessions on different hosts no longer share the active-machine pool for file +ops. Host-side tests cover that routing plus the editor 409/re-read/save path. + +Official Desktop's native file-tab GUI, failed/cancelled dialogs, non-macOS +hosts, and a full legacy Web UI pass are still experimental. Desktop's package +installer may also require an explicit policy for the optional `ssh2` / +`cpu-features` build scripts. The isolated transport test disabled those +optional scripts; this change does not loosen an application's build allowlist +or automatically approve dependency scripts. ### Published Web bundle diff --git a/README.zh.md b/README.zh.md index fd67d91..569ae1b 100644 --- a/README.zh.md +++ b/README.zh.md @@ -17,9 +17,10 @@ 原生右侧栏集成。 已验证 Host 启动、IPC 请求、真实 SSH 的只读连接/目录列表/文本读取,以及设置页和 -测试 SSH 配置的导入。文件标签的完整 UI 操作、编辑/同步、多机器并行会话,以及旧 Web -版完整 UI 回归仍需在发布前验收。尤其是现有侧栏文件接口仍使用“当前机器”的连接池, -不能把会话级标签地址误认为后端已实现会话级机器绑定;本分支尚不代表生产级多机器支持。 +测试 SSH 配置的导入。**v0.8.19** 起侧栏 `/ls` `/read` `/write` `/fs` 在请求带 +`sessionId` 时按该会话的镜像绑定选机(与 `rw_*` 同一套),不再落到「当前机器」 +连接池;宿主侧测试覆盖双机会话路由与编辑 409/重读/保存。原生文件标签的完整 GUI、 +失败/取消交互、非 macOS 宿主以及旧 Web 版完整 UI 回归仍属实验性。 Desktop 安装器还可能要求明确配置 `ssh2` / `cpu-features` 可选构建脚本策略。 隔离验证中禁用了这些可选脚本;本改动不放宽应用的构建白名单,也不自动批准脚本。 @@ -54,18 +55,21 @@ DSH 的 Web 界面刻意只监听 `127.0.0.1`(CLI 为安全拒绝 `--host 0.0. ## 功能 -- **多机 SSH** —— 可存任意多台主机(host/port/user + **私钥**或**密码**)。密码只存在本地,界面不回显;在设置里一键切当前机。 +- **多机 SSH** —— 可存任意多台主机(host/port/user + **私钥**或**密码**)。密码只存在本地,界面不回显;在设置里一键切当前机。每机可配 passphrase / 主机指纹策略 / SSH agent / keyboard-interactive(OTP)/ 跳板机,以及可选的 **系统钥匙串加密密码**。 +- **`~/.ssh/config` 导入** —— 设置页列出 Host 别名,一点填入表单(只引用路径,不读密钥材料)。 - **双 tab 工作区选择器**(填充原生「Add workspace」流程): - **本机** —— 走 **host 端原生系统文件夹对话框**选本地目录(或直接输入本地路径)→ 直接成为普通 DSH 本地工作区(与本地工作区共存)。优先用 DSH 的 `directoryPicker` 服务,服务缺失时**回退到插件自持的原生选择器**(macOS `osascript` / Linux `zenity`→`kdialog`)——桌面启动路径上框架服务不注册也能用。 - **远程** —— 选择器是**居中弹窗**(窄侧边栏也不会被挤压)。先**选机器** → Windows 主机根级显示 **「此电脑」多盘视图**(`C:\`、`D:\`、`E:\`…,而不是 Git Bash 的 MSYS 根),路径框**实时补全**目录(支持 `C:\Users\…` 或 `/c/Users/…` 任意写法,Windows 路径在底层自动改写为 Git Bash 形式);**选中一个目录立即列出它下一级**(OS/VSCode 式级联)。另有 **「浏览…」文件选择式浮层**(Windows 面包屑 `此电脑 / C:\ / Users / dev` 可点击跳级、驱动器行、大小/时间、跟随软链),选中**回填输入框不提交**,你复核/修改后再确定;「回上一级」任意深度可用(包括浮层直接打开在路径栏当前路径时)。**最近工作区**快捷入口、**`~` 主目录**、**新建目录**一键可达。确定会创建**真实本地镜像**(`$DSH_HOME/remote-workspaces/--/`;仅当同主机上**别的远端路径**已占用同名 basename 时才追加短路径 hash)→ harness 把它当真实工作区收养,同时 dsh-remote 通过 SFTP 保持同步。所选工作区会**持久化到该机器**,重启不丢。 - **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。 - **Windows 路径自动改写** —— 用户输入 `C:\Users\dev\project`(或 `C:/…`、`/c/…`、`/C:/…`)时底层自动规范为 Git Bash 形式 `/c/Users/dev/project` 执行;工作区存储与展示为 Windows 形式 `C:\Users\dev\project`。模型工具全部接受并展示两种写法;SFTP 访问使用 Win32-OpenSSH 的 `/D:/…` 形式(见 `toSftpPath`)。 -- **双向 SFTP 同步(增量)** —— `rw_sync`(远程→镜像)、`rw_push`(镜像→远程),本地镜像改动可回传机器。两者都会**跳过 size+mtime 未变化的文件**,并有**单文件大小上限**防误拉大二进制;目录遍历带**有界并发**。 -- **模型工具** —— `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`。 -- **直接写远程文件** —— `rw_write_file` 直接创建/覆盖远程文件(自动建父目录),单个文件改动不必绕本地镜像来回同步;`rw_download` / `rw_upload` 可单文件双向取真字节。 -- **连接体检** —— 设置页提供「测试连接」按钮,在保存机器之前先验证 host/user/密码/私钥是否可用。 +- **双向 SFTP 同步(三路冲突检测)** —— `rw_sync`(远程→镜像)、`rw_push`(镜像→远程)。两边都改过的文件会列出冲突、绝不静默覆盖(`force=true` 覆盖)。默认 **深度 8 / 2000 文件**,触顶会标明 **`TRUNCATED`**。支持 dry-run、后台任务、gitignore 风格 ignore 规则。 +- **模型工具(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`。 +- **端口转发面板** —— 设置页或 `rw_forward` 创建/启停本地与反向隧道。 +- **侧栏远程编辑** —— 远程文件 tab 可编辑并保存到远端(mtime 乐观锁)。**v0.8.19** 起文件操作按会话绑定机器。 +- **命令审计** —— `rw_exec`/写/删/移动/转发写入 `audit.log`;设置页显示最近 30 条。 +- **连接体检** —— 设置页「测试连接」按类别提示(认证 / 网络 / 主机指纹 / 超时)。 - 当前 `user@host:/path` 会注入每次系统提示,让 Agent 明确自己的工作根。 -- **远端跨平台** —— 命令全部用可移植 POSIX 写法(`ls -la` / `sed -n` / `find … -exec grep`),macOS/BSD 与 GNU/Linux 远端都能用。 +- **远端跨平台** —— 文件访问走 SFTP 协议(不依赖 POSIX shell),Linux/macOS/Windows 远端都能列/读/写/搜索/同步。 - **主机指纹校验(TOFU)** —— 每次 SSH 连接都校验主机密钥(`hostKeyMode: accept-new`):首次连接记录,之后**密钥一旦变化立即拒绝**(防中间人)。`verify` 模式还会拒绝从未见过的机器;`off` 关闭校验。指纹存于 `$DSH_HOME/remote-workspaces/known_hosts.json`;误判可用 `/remote forget-key` 重置。 - **数据跟随 Harness 根目录** —— 机器清单与镜像放在 `$DSH_HOME/remote-workspaces`(桌面版即 `userData/harness` 下);0.6 之前落在 `~/.dsh/remote-workspaces` 的数据**首次启动自动迁移**,不丢失。 - **不改任何 `dsh-workspace` 官方代码** —— 全部作为普通插件实现(client 半以 `priority -100` 填充 directory-flow holes)。 @@ -108,11 +112,13 @@ dsh plugin add dsh-better-sidebar - **本机** → 系统文件夹选择(或输入本地路径)→ 本地工作区。 - **远程** → 选机器 → 浏览到远程目录(或输入 `/path`)→ 「设为远程工作区」⇒ 创建并收养一个本地镜像工作区。 3. **让 Agent 工作** —— 把它当普通工作区用: - - `rw_list_dir(path?)` / `rw_read_file` —— 查看远程文件 - - `rw_write_file(path, content)` —— 直接创建或覆盖远程文件 + - `rw_list_dir(path?)` / `rw_read_file` / `rw_stat` —— 查看远程文件 + - `rw_write_file` / `rw_edit` / `rw_append` —— 创建、补丁、追加远程文件 + - `rw_mkdir` / `rw_remove` / `rw_move` —— 管理远程路径 - `rw_search(pattern, path?)` —— 远程 grep - `rw_exec(command, cwd?)` —— 在远程执行命令(默认在工作区目录) - - `rw_sync` / `rw_push` —— 拉取/推送本地镜像 <-> 远程 + - `rw_forward` —— SSH 隧道 + - `rw_sync` / `rw_push` —— 冲突感知的镜像拉取/推送 > **Remote context 是 session 级的(v0.8.8+)**:system prompt 只会在**当前 session 的 > cwd 位于某个远程 mirror 内**(即你把远程目录选成了这个 session 的工作区)时注入 diff --git a/check.mjs b/check.mjs index 494b634..4a943e1 100644 --- a/check.mjs +++ b/check.mjs @@ -8,13 +8,19 @@ // 本脚本是纯文本静态检查(不 import 模块,无需依赖即可运行),只查那些 // 违反会让 boot 崩溃的"框架注册约束"。语法问题由 node --check 兜底, // 真正的启动冒烟测试见 scripts/boot-smoke.sh。 -import { readFileSync } from 'node:fs' +import { readFileSync, readdirSync } from 'node:fs' import { fileURLToPath } from 'node:url' import path from 'node:path' const here = path.dirname(fileURLToPath(import.meta.url)) const src = readFileSync(path.join(here, 'lib', 'index.js'), 'utf8').replace(/\r\n/g, '\n') const file = path.relative(process.cwd(), path.join(here, 'lib', 'index.js')) +const libSrc = readdirSync(path.join(here, 'lib')) + .filter((n) => n.endsWith('.js')) + .map((n) => ({ + file: path.relative(process.cwd(), path.join(here, 'lib', n)), + src: readFileSync(path.join(here, 'lib', n), 'utf8').replace(/\r\n/g, '\n'), + })) let fail = 0 @@ -58,11 +64,14 @@ console.log(` tool-name lint: ${tTotal} defineTool(s) checked`) const ROUTE_PREFIX = /^\/dsh-remote\//u const routeRe = /path:\s*'(\/[^']*)'/g let rTotal = 0 -while ((m = routeRe.exec(src))) { - rTotal++ - if (!ROUTE_PREFIX.test(m[1])) { - fail++ - console.log(` ✗ ${file}:${lineOf(src, m.index)}: route path '${m[1]}' must start with /dsh-remote/`) +for (const { file: rf, src: rs } of libSrc) { + if (!/index\.js$|routes-/.test(rf)) continue + while ((m = routeRe.exec(rs))) { + rTotal++ + if (!ROUTE_PREFIX.test(m[1])) { + fail++ + console.log(` ✗ ${rf}:${lineOf(rs, m.index)}: route path '${m[1]}' must start with /dsh-remote/`) + } } } console.log(` route-prefix lint: ${rTotal} route(s) checked`) diff --git a/docs/promote.md b/docs/promote.md index 62716d7..75fe3d1 100644 --- a/docs/promote.md +++ b/docs/promote.md @@ -5,20 +5,18 @@ This file is for the author; nothing here is shown in the README install flow. --- -## 0. Status checklist (2026-08-15) +## 0. Status checklist (2026-09-17) | Channel | Status | |---|---| -| npm `dsh-remote` | ✅ latest 0.5.6, full history 0.1.0→0.5.6 | -| GitHub topics (`dsh-plugin`, `deepseek-harness`, `remote`, `ssh`, `tunnel`, `plugin`) | ✅ set | -| GitHub Releases | ✅ v0.5.2 / v0.5.4 / v0.5.5 | -| awesome-dsh-plugin/awesome-dsh-plugin | ✅ listed (EN+zh), PR #323 **merged** | -| AdamPlatin123/awesome-dsh-plugins (auto radar) | ✅ auto-listed (snapshot lags; auto-refreshes) | -| Blog posts | ✅ docs/blog/showcase-{en,zh}.md | -| Official Discussions showcase | ⏳ **TODO (manual, no public API)** | -| README / npm previews | ✅ in-repo relative paths (`docs/cover.png`) so GitHub Topics can show a card image | -| GitHub Topics card | ⏳ after this lands on `main`; optional extra: Settings → Social preview upload | -| WhaleHarness audit (issue #1) | ✅ fixed in 0.5.5; awaiting re-review | +| npm `dsh-remote` | latest **0.8.19** (history 0.1.0→0.8.19) | +| GitHub topics (`dsh-plugin`, `deepseek-harness`, `remote`, `ssh`, `tunnel`, `plugin`) | set | +| GitHub Releases | keep in sync with CHANGELOG on each npm publish | +| awesome-dsh-plugin | listed (EN+zh) | +| Blog posts | docs/blog/showcase-{en,zh}.md | +| Official Discussions showcase | TODO (manual, no public API) | +| README / npm previews | in-repo relative paths (`docs/cover.png`) | +| WhaleHarness audit (issue #1) | fixed in 0.5.5 | ## 0. GitHub repo — set Topics diff --git a/lib/binding.js b/lib/binding.js index 028f2b8..3a8d755 100644 --- a/lib/binding.js +++ b/lib/binding.js @@ -13,6 +13,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' import path from 'node:path' +import zlib from 'node:zlib' /** Strip trailing separators so a mirror root and a path inside it compare cleanly. */ const norm = (p) => path.resolve(String(p || '')).replace(/[\\/]+$/, '') || '' @@ -95,3 +96,71 @@ export function resolveMirror(local, root) { export function poolKey(m) { return `${(m && m.username) || ''}@${(m && m.host) || ''}:${Number(m && m.port) || 22}` } + +/** Safe path segment for a session id (mirrors DSH's encodeSegment). */ +export function encodeSegmentSafe(id) { + const s = String(id || '').replace(/[^A-Za-z0-9._-]/g, '_') + return s || 'session' +} + +/** Read the cwd from a session log header (JSONL / .gz / zstd first frame). */ +export function readSessionHeaderCwd(file) { + try { + let buf = readFileSync(file) + if (/\.zstd$/.test(file)) buf = zlib.zstdDecompressSync(buf) + else if (/\.gz$/.test(file)) buf = zlib.gunzipSync(buf) + const nl = buf.indexOf(10) + if (nl < 0) return '' + const head = JSON.parse(buf.subarray(0, nl).toString('utf8')) + return typeof head.cwd === 'string' ? head.cwd : '' + } catch { + return '' + } +} + +/** + * Resolve a session's workspace cwd from the live sessions service, then from + * durable session logs under `$DSH_HOME/sessions`. + */ +export function lookupSessionCwd(sessionId, { sessions, dshHome } = {}) { + if (!sessionId) return '' + try { + if (sessions && typeof sessions.get === 'function') { + const s = sessions.get(sessionId) + const cwd = s && s.header && s.header.cwd + if (cwd) return String(cwd) + } + } catch { /* sessions service unavailable */ } + if (!dshHome) return '' + try { + const sessionsRoot = path.join(dshHome, 'sessions') + if (!existsSync(sessionsRoot)) return '' + const targetDir = encodeSegmentSafe(sessionId) + for (const projDir of readdirSync(sessionsRoot)) { + const projPath = path.join(sessionsRoot, projDir) + if (!statSync(projPath, { throwIfNoEntry: false })?.isDirectory?.()) continue + const sessDir = path.join(projPath, targetDir) + if (!statSync(sessDir, { throwIfNoEntry: false })?.isDirectory?.()) continue + const logFile = ['session.jsonl.zstd', 'session.jsonl', 'session.jsonl.gz'] + .map((n) => path.join(sessDir, n)).find((p) => existsSync(p)) + if (!logFile) continue + const cwd = readSessionHeaderCwd(logFile) + if (cwd) return cwd + } + } catch { /* session log scan failed */ } + return '' +} + +/** Pull sessionId / local from a JSON HTTP request (query string + body). */ +export function requestSessionHint(req, body = {}) { + let sessionId = '' + let local = '' + try { + const q = new URL(req.url || '', 'http://localhost').searchParams + sessionId = q.get('sessionId') ? decodeURIComponent(q.get('sessionId')) : '' + local = q.get('local') ? decodeURIComponent(q.get('local')) : '' + } catch { /* malformed url */ } + if (!sessionId && body && body.sessionId) sessionId = String(body.sessionId) + if (!local && body && body.local) local = String(body.local) + return { sessionId: String(sessionId || '').trim(), local: String(local || '').trim() } +} diff --git a/lib/client.js b/lib/client.js index 8f9332a..4a6c9fa 100644 --- a/lib/client.js +++ b/lib/client.js @@ -525,6 +525,19 @@ window.__ModuleLoader__.load({ ? '/api' + path : path } + function sessionQuery(sessionId) { + return sessionId ? 'sessionId=' + encodeURIComponent(sessionId) : '' + } + function withSessionQuery(path, sessionId) { + const q = sessionQuery(sessionId) + if (!q) return path + return path + (path.includes('?') ? '&' : '?') + q + } + function withSessionBody(body, sessionId) { + if (!sessionId) return body + return Object.assign({}, body || {}, { sessionId }) + } + async function api(method, path, body) { const opts = { method, headers: {} } if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body) } @@ -1425,8 +1438,8 @@ window.__ModuleLoader__.load({ const SIDEBAR_EXPLORER_ID = 'dsh-remote:explorer' const SIDEBAR_FILE_ID = 'dsh-remote:file' - function fetchRemoteStatus() { - return api('GET', '/dsh-remote/status').catch(() => null) + function fetchRemoteStatus(sessionId) { + return api('GET', withSessionQuery('/dsh-remote/status', sessionId)).catch(() => null) } // Resolve the remote path this session shows. Issue #13: the ONLY source @@ -1458,14 +1471,14 @@ window.__ModuleLoader__.load({ .catch(() => '') } - function readRemoteFile(path, maxBytes) { - return api('POST', '/dsh-remote/read', { path, maxBytes }).catch((e) => { + function readRemoteFile(path, maxBytes, sessionId) { + return api('POST', '/dsh-remote/read', withSessionBody({ path, maxBytes }, sessionId)).catch((e) => { throw new Error(tr('file.readFail', { msg: ((e && e.message) || e) })) }) } - function listRemoteDir(path) { - return api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(path || '')).catch((e) => { + function listRemoteDir(path, sessionId) { + return api('GET', withSessionQuery('/dsh-remote/ls?path=' + encodeURIComponent(path || ''), sessionId)).catch((e) => { throw new Error(tr('file.listFail', { msg: ((e && e.message) || e) })) }) } @@ -1676,6 +1689,8 @@ window.__ModuleLoader__.load({ /** Row whose path was just copied — shows a brief「已复制」label. */ const [copiedPath, setCopiedPath] = React.useState('') const expanded = Array.isArray(props.expanded) ? props.expanded : [] + const scopeCwd = props.scope && props.scope.cwd + const sessionId = props.scope && props.scope.sessionId const storeLevel = (path, level) => { dataRef.current = { ...dataRef.current, [path]: level } @@ -1691,7 +1706,7 @@ window.__ModuleLoader__.load({ if (existing && !existing.loading && opts && opts.keep) return if (cur[dir] !== undefined && !cur[dir].loading) return storeLevel(dir, { loading: true }) - listRemoteDir(dir) + listRemoteDir(dir, sessionId) .then((res) => { const items = Array.isArray(res && res.items) ? res.items : [] const sorted = items.slice().sort((a, b) => { @@ -1712,14 +1727,12 @@ window.__ModuleLoader__.load({ storeLevel(dir, { error: String((e && e.message) || e) }) } }) - }, []) + }, [sessionId]) // Resolve this session's remote root, then load it and every expanded dir. // Re-runs whenever the session scope changes (switching conversations, or // the async sessionCwd fetch landing), so the sidebar follows the session's // own workspace dir. - const scopeCwd = props.scope && props.scope.cwd - const sessionId = props.scope && props.scope.sessionId const rootRef = React.useRef('') const refreshStatus = React.useCallback((opts) => { const applyRoot = (r, connected) => { @@ -1739,14 +1752,17 @@ window.__ModuleLoader__.load({ // Not connected (e.g. freshly restarted, machine no longer active): // show a clear disconnected state instead of firing ls requests // that all fail with AggregateError / 500 in a loop. - if (!connected) { + // A bound remote session lists on demand even if this process has + // not yet opened the SSH socket; only an unbound/disconnected + // machine-scoped view should show the empty "未连接" state. + if (!connected && !sessionId) { storeLevel(r, { error: tr('explorer.notConnected') }) return } if (!sameRoot) { storeLevel(r, { loading: true }) } - listRemoteDir(r) + listRemoteDir(r, sessionId) .then((res) => { const items = Array.isArray(res && res.items) ? res.items : [] const sorted = items.slice().sort((a, b) => { @@ -1779,7 +1795,7 @@ window.__ModuleLoader__.load({ return Promise.resolve('') } resolveRoot().then((r) => { - fetchRemoteStatus().then((s) => { + fetchRemoteStatus(sessionId).then((s) => { setStatus(s) // Issue #13: NEVER fall back to the machine-level workspace here — // a session whose cwd is not a mirror is a LOCAL session and shows @@ -1839,7 +1855,7 @@ window.__ModuleLoader__.load({ const doFs = (op, payload, refreshParent) => { setBusy(op) - return api('POST', '/dsh-remote/fs', { op, ...payload }) + return api('POST', '/dsh-remote/fs', withSessionBody({ op, ...payload }, sessionId)) .then(() => { setErr('') if (refreshParent) { @@ -2008,6 +2024,7 @@ window.__ModuleLoader__.load({ function RemoteFileTab(props) { const { tab, scope } = props const path = tab.path || '' + const fileSessionId = scope && scope.sessionId const [data, setData] = React.useState(null) const [err, setErr] = React.useState('') const [loading, setLoading] = React.useState(true) @@ -2019,7 +2036,7 @@ window.__ModuleLoader__.load({ if (!path) { setLoading(false); return } let cancelled = false setLoading(true); setErr(''); setData(null); setEdit(false) - readRemoteFile(path, 256 * 1024) + readRemoteFile(path, 256 * 1024, fileSessionId) .then((d) => { if (!cancelled) { setData(d); setLoading(false) } }) .catch((e) => { if (!cancelled) { setErr(String((e && e.message) || e)); setLoading(false) } }) return () => { cancelled = true } @@ -2033,7 +2050,7 @@ window.__ModuleLoader__.load({ } const saveRemote = () => { setSaving(true); setErr('') - apiRaw('POST', '/dsh-remote/write', { path, content: draft }) + apiRaw('POST', '/dsh-remote/write', withSessionBody({ path, content: draft, expectedMtime: data && data.mtime }, fileSessionId)) .then((r) => { if (r.status === 409) { setErr(String((r.data && r.data.error) || tr('file.conflict'))) @@ -2048,7 +2065,7 @@ window.__ModuleLoader__.load({ } const downloadMirror = () => { setSaving(true); setErr('') - api('POST', '/dsh-remote/fs', { op: 'download', path }) + api('POST', '/dsh-remote/fs', withSessionBody({ op: 'download', path }, fileSessionId)) .then((r) => setErr(tr('file.downloaded', { local: (r && r.local ? tr('file.downloadedAt', { path: r.local }) : '') }))) .catch((e) => setErr(String((e && e.message) || e))) .finally(() => setSaving(false)) diff --git a/lib/hostkey.js b/lib/hostkey.js index 481cfe4..014a1bc 100644 --- a/lib/hostkey.js +++ b/lib/hostkey.js @@ -3,6 +3,8 @@ // ssh2 v1.17) is unit-tested against the REAL wire shape — the v0.6.7 bug // class ("mock object hid a contract drift") must not regress. import { createHash } from 'node:crypto' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' /** * Extract the SSH host-key algorithm name from a raw SSH host-key blob @@ -34,6 +36,64 @@ export function keyFingerprint(key) { return createHash('sha256').update(blob).digest('base64') } +export function loadKnownHosts(file) { + if (!file) return {} + try { + const j = JSON.parse(readFileSync(file, 'utf8')) + if (j && typeof j === 'object' && !Array.isArray(j)) return j + } catch {} + return {} +} + +export function isHostKeyKnown(file, host, port) { + return Object.prototype.hasOwnProperty.call(loadKnownHosts(file), `${host}:${port}`) +} + +/** Build an ssh2 `hostVerifier` bound to the current config + known_hosts file. */ +export function createHostKeyGuard(config, knownHostsFile) { + const id = () => `${config.host}:${config.port}` + const mode = config.hostKeyMode === 'verify' || config.hostKeyMode === 'off' + ? config.hostKeyMode + : 'accept-new' + const read = () => loadKnownHosts(knownHostsFile) + const write = (kh) => { + if (!knownHostsFile) return + try { mkdirSync(path.dirname(knownHostsFile), { recursive: true }) } catch {} + writeFileSync(knownHostsFile, JSON.stringify(kh, null, 2)) + } + const guard = { + mode, + lastError: null, + knownHosts: read, + forgetHost() { + const kh = read() + delete kh[id()] + write(kh) + }, + verifier(key) { + if (mode === 'off') return true + const fp = keyFingerprint(key) + const kh = read() + const stored = kh[id()] + if (stored) { + if (stored.fingerprint === fp) return true + guard.lastError = + `host key for ${id()} CHANGED (stored ${stored.fingerprint}, received ${fp}) — ` + + 'possible man-in-the-middle; run /remote-forget-key to re-trust if this is expected' + return false + } + if (mode === 'verify') { + guard.lastError = `unknown host key for ${id()} (hostKeyMode=verify) — trust it first with accept-new` + return false + } + kh[id()] = { algo: blobAlgorithm(key) || (key && key.algo) || 'unknown', fingerprint: fp, firstSeen: new Date().toISOString() } + write(kh) + return true + }, + } + return guard +} + /** Build a fake-but-wire-shaped host-key blob for tests: * `string(algo) string(32 bytes)`. */ export function makeKeyBlob(algo, seed) { diff --git a/lib/index.js b/lib/index.js index cd6b41b..9c0444c 100644 --- a/lib/index.js +++ b/lib/index.js @@ -20,33 +20,32 @@ // Plugin Config MUST be a schemastery schema (zod rejects the undefined row config). import z from '@deepseek-ai/schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' -import ssh2 from 'ssh2' import { execFile } from 'node:child_process' -import zlib from 'node:zlib' -import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, copyFileSync, utimesSync, appendFileSync, unlinkSync, watch } from 'node:fs' +import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, copyFileSync, appendFileSync, watch } from 'node:fs' import { homedir } from 'node:os' import path from 'node:path' import iconv from 'iconv-lite' import { shq, normalizeRemotePath, joinRemotePath, remoteDirname, mkdirRemoteDirs, - toSftpPath, toShellPath, toDisplayPath, remotePathBase, truncate, shortHash, relPathUnder, + toShellPath, toDisplayPath, remotePathBase, truncate, shortHash, } from './paths.js' -import { blobAlgorithm, keyFingerprint } from './hostkey.js' +import { createHostKeyGuard, isHostKeyKnown as _isHostKeyKnown } from './hostkey.js' import { compileIgnore, DEFAULT_IGNORE } from './ignore.js' import { friendlyMessage } from './errors.js' import { importableEntries, sshConfigPath, readSshConfigText } from './sshconfig.js' import { getSecret, deleteSecret, platformBackend, persistPassword } from './credential.js' import { syncTree, pushTree, loadSyncState, saveSyncState, pushOneFile } from './sync.js' -import { searchTree } from './search.js' -import { resolveMirror, poolKey } from './binding.js' +import { searchRemote } from './search.js' +import { resolveMirror, poolKey, lookupSessionCwd, encodeSegmentSafe, readSessionHeaderCwd, requestSessionHint } from './binding.js' import { TaskManager } from './tasks.js' import { ForwardManager } from './forwards.js' import { selfDir, readVersion, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode } from './update.js' import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeMachine as _sanitizeMachine, applyMachine as _applyMachine, machineId as _machineId } from './registry.js' import { registerHttpTransports } from './http-transport.js' - -const { Client } = ssh2 +import { SshPool } from './pool.js' +import { createFsRoutes } from './routes-fs.js' +import { removeRemoteTree } from './remote-fs.js' export const name = 'dsh-remote' @@ -132,33 +131,6 @@ function remoteWorkspacesRoot() { return path.join(dshBase(), 'remote-workspaces') } -/** Safe path segment for a session id (mirrors DSH's encodeSegment: alnum + _- .). - * Session dirs keep the id mostly verbatim; sanitize anything exotic. */ -function encodeSegmentSafe(id) { - const s = String(id || '').replace(/[^A-Za-z0-9._-]/g, '_') - return s || 'session' -} - -/** Read the cwd from a session log header. Handles plain JSONL, .gz, and - * multi-frame zstd (decompress only the first frame, which holds the header). - * Returns the header cwd string, or '' when unreadable/absent. */ -function readSessionHeaderCwd(file) { - try { - let buf = readFileSync(file) - if (/\.zstd$/.test(file)) { - buf = zlib.zstdDecompressSync(buf) - } else if (/\.gz$/.test(file)) { - buf = zlib.gunzipSync(buf) - } - const nl = buf.indexOf(10) - if (nl < 0) return '' - const head = JSON.parse(buf.subarray(0, nl).toString('utf8')) - return typeof head.cwd === 'string' ? head.cwd : '' - } catch { - return '' - } -} - /** Local mirrors of one remote host. */ function mirrorRootFor(host, user, port) { const tag = [host, user, port].filter(Boolean).join('-').replace(/[^a-zA-Z0-9._-]/g, '_') @@ -247,550 +219,7 @@ const machineId = _machineId // ── host-key registry (TOFU) ────────────────────────────────────────────── const KNOWN_HOSTS_FILE = 'known_hosts.json' const knownHostsFile = () => path.join(remoteWorkspacesRoot(), KNOWN_HOSTS_FILE) - -function loadKnownHosts() { - try { - const j = JSON.parse(readFileSync(knownHostsFile(), 'utf8')) - if (j && typeof j === 'object' && !Array.isArray(j)) return j - } catch {} - return {} -} - -/** Build an ssh2 `hostVerifier` bound to the current config. */ -function createHostKeyGuard(config) { - const id = () => `${config.host}:${config.port}` - const mode = config.hostKeyMode === 'verify' || config.hostKeyMode === 'off' - ? config.hostKeyMode - : 'accept-new' - const guard = { - mode, - lastError: null, - knownHosts: loadKnownHosts, - forgetHost() { - const kh = loadKnownHosts() - delete kh[id()] - try { mkdirSync(path.dirname(knownHostsFile()), { recursive: true }) } catch {} - writeFileSync(knownHostsFile(), JSON.stringify(kh, null, 2)) - }, - verifier(key) { - if (mode === 'off') return true - const fp = keyFingerprint(key) - const kh = loadKnownHosts() - const stored = kh[id()] - if (stored) { - if (stored.fingerprint === fp) return true - guard.lastError = - `host key for ${id()} CHANGED (stored ${stored.fingerprint}, received ${fp}) — ` + - 'possible man-in-the-middle; run /remote-forget-key to re-trust if this is expected' - return false - } - if (mode === 'verify') { - guard.lastError = `unknown host key for ${id()} (hostKeyMode=verify) — trust it first with accept-new` - return false - } - kh[id()] = { algo: blobAlgorithm(key) || (key && key.algo) || 'unknown', fingerprint: fp, firstSeen: new Date().toISOString() } - try { mkdirSync(path.dirname(knownHostsFile()), { recursive: true }) } catch {} - writeFileSync(knownHostsFile(), JSON.stringify(kh, null, 2)) - return true - }, - } - return guard -} - -/** Whether the current target's key has been recorded/trusted before. */ -function isHostKeyKnown(host, port) { - return Object.prototype.hasOwnProperty.call(loadKnownHosts(), `${host}:${port}`) -} - -// ── SSH pool (key / password / agent / keyboard-interactive / proxy) ────── - -class SshPool { - constructor(config) { - this.config = config - this.client = null - this.connecting = null - this.proxyPool = null - // Generational token: bumped on every target change / close so a stale - // in-flight connect can never hand this pool a connection to an old host. - this.epoch = 0 - /** Auto-detected remote platform: unknown | windows | posix (per target). */ - this.platform = 'unknown' - /** Resolved Git Bash bash.exe path on Windows remotes ('' when none). */ - this.gitBashPath = '' - /** Resolved terminal strategy: native | git-bash. */ - this.shellMode = 'native' - /** In-flight platform detection promise (cached). */ - this._detecting = null - /** Optional async resolver for a machine-stored (keychain) password. */ - this.passwordResolver = null - /** Optional hook called with the live client after a successful connect. */ - this.onReady = null - /** Optional hook called when the pool closes. */ - this.onCloseHook = null - } - - resolveKeyPath() { - const p = this.config.privateKeyPath - if (!p) return '' - if (p.startsWith('~/') || p === '~') return path.join(homedir(), p.slice(1)) - return p - } - - setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode }) { - if (host !== undefined) this.config.host = String(host) - if (port !== undefined && Number(port)) this.config.port = Number(port) - if (username !== undefined) this.config.username = String(username) - if (password !== undefined && password !== null) this.config.password = String(password) - if (privateKeyPath !== undefined) this.config.privateKeyPath = String(privateKeyPath) - if (passphrase !== undefined) this.config.passphrase = String(passphrase) - if (workspace !== undefined) this.config.workspace = String(workspace) - if (useAgent !== undefined) this.config.useAgent = !!useAgent - if (keyboardInteractive !== undefined) this.config.keyboardInteractive = !!keyboardInteractive - if (proxy !== undefined) this.config.proxy = proxy - if (hostKeyMode !== undefined) this.config.hostKeyMode = String(hostKeyMode) - // the new target may be a different OS — re-detect on the next command - this.platform = 'unknown' - this.gitBashPath = '' - this.shellMode = 'native' - this._detecting = null - this.close() - return this - } - - connect() { - if (this.client) return Promise.resolve(this.client) - if (this.connecting) return this.connecting - const epoch = this.epoch - const pending = this._doConnect(epoch) - this.connecting = pending - const clear = () => { - if (this.epoch === epoch && this.connecting === pending) this.connecting = null - } - pending.then(clear, clear) - return pending - } - - async _doConnect(epoch) { - const isCurrent = () => this.epoch === epoch - const guard = createHostKeyGuard(this.config) - const client = new Client() - let settled = false - const fail = (err) => { - if (settled) return - settled = true - if (isCurrent() && this.client === client) this.client = null - throw guard.lastError ? new Error(guard.lastError) : err - } - - // Proxy jump: SSH to the bastion first, then tunnel to the target through it. - let sock = null - const proxyCfg = this.config.proxy - if (proxyCfg && proxyCfg.host) { - try { - this.proxyPool = new SshPool({ - ...this.config, - host: proxyCfg.host, - port: Number(proxyCfg.port) || 22, - username: proxyCfg.username || this.config.username || 'root', - password: proxyCfg.password || '', - privateKeyPath: proxyCfg.privateKeyPath || '', - passphrase: proxyCfg.passphrase || '', - proxy: undefined, - }) - const pclient = await this.proxyPool.connect() - if (!isCurrent()) throw new Error('ssh target changed during proxy connect') - sock = await new Promise((res, rej) => { - pclient.forwardOut('127.0.0.1', 0, this.config.host, this.config.port, (e, ch) => (e ? rej(new Error('proxy forward to target failed: ' + ((e && e.message) || e))) : res(ch))) - }) - } catch (err) { - return fail(err) - } - } - - return new Promise((resolve, reject) => { - const rejectOnce = (err) => { - if (settled) return - settled = true - if (isCurrent() && this.client === client) this.client = null - reject(guard.lastError ? new Error(guard.lastError) : err) - } - client.on('ready', () => { - if (settled) return - settled = true - if (!isCurrent()) { - try { client.end() } catch {} - reject(new Error('ssh target changed during connect')) - return - } - this.client = client - resolve(client) - if (this.onReady) { try { this.onReady(client) } catch {} } - }) - client.on('error', (e) => rejectOnce(e)) - client.on('close', () => { - if (isCurrent() && this.client === client) this.client = null - rejectOnce(new Error('ssh connection closed')) - }) - - const buildOpts = async () => { - const opts = { - host: this.config.host, - port: this.config.port, - username: this.config.username, - readyTimeout: this.config.connectTimeoutMs, - keepaliveInterval: 15000, - keepaliveCountMax: 3, - hostVerifier: (key) => guard.verifier(key), - } - if (sock) opts.sock = sock - if (this.config.useAgent) { - const sockPath = process.env.SSH_AUTH_SOCK - if (sockPath) opts.agent = sockPath - } - let password = this.config.password || '' - if (!password && this.passwordResolver) { - try { password = (await this.passwordResolver()) || '' } catch {} - } - if (password) { - opts.password = password - opts.tryKeyboard = true - } else if (this.config.keyboardInteractive && !this.config.privateKeyPath) { - opts.tryKeyboard = true - } - if (this.config.privateKeyPath) { - const keyPath = this.resolveKeyPath() - if (!keyPath) { - throw new Error('no credentials: set a password or a privateKeyPath to connect') - } - let key - try { - key = readFileSync(keyPath) - } catch (err) { - throw new Error(`cannot read private key "${keyPath}": ${err && err.message}`) - } - opts.privateKey = key - opts.passphrase = this.config.passphrase || undefined - } else if (!password && !opts.agent) { - throw new Error('no credentials: set a password, a privateKeyPath, or enable useAgent to connect') - } - return opts - } - - buildOpts().then( - (opts) => { - if (opts.tryKeyboard) { - client.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => { - finish(prompts.map(() => this.config.password || '')) - }) - } - client.connect(opts) - }, - (err) => rejectOnce(err), - ) - }) - } - - /** Detect the remote platform + locate Git Bash once; cached per target. */ - detect() { - if (this.platform !== 'unknown') return Promise.resolve() - if (this._detecting) return this._detecting - this._detecting = this._detect().finally(() => { - this._detecting = null - }) - return this._detecting - } - - async _detect() { - let res - try { - // `cmd /c ver` works under cmd.exe, PowerShell AND Git Bash (all print - // "Microsoft Windows …"); on POSIX hosts `cmd` simply doesn't exist. - res = await this._execRaw('cmd /c ver', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) }) - } catch (err) { - this.platform = 'unknown' - this.shellMode = 'native' - return - } - const out = String(res.stdout || '') + '\n' + String(res.stderr || '') - if (res.code === 0 && /microsoft windows/i.test(out)) { - this.platform = 'windows' - await this._resolveGitBash() - return - } - // inconclusive — probe for a Git-Bash/MSYS remote (uname prints MINGW64_NT…) - try { - const u = await this._execRaw('uname -s', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) }) - if (/mingw|msys|cygwin/i.test(String(u.stdout || ''))) { - this.platform = 'windows' - await this._resolveGitBash() - return - } - } catch {} - this.platform = 'posix' - this.shellMode = 'native' - this.gitBashPath = '' - } - - /** On a Windows remote, locate Git Bash (config path → PATH → common installs). */ - async _resolveGitBash() { - const cfg = String(this.config.shell || '').trim() - if (cfg && cfg !== 'git-bash' && cfg !== 'native') { - if (await this._cmdExists(cfg)) { - this.gitBashPath = cfg - this.shellMode = 'git-bash' - return - } - } - if (cfg === 'native') { - this.shellMode = 'native' - this.gitBashPath = '' - return - } - try { - const r = await this._execRaw('cmd /c where bash', { timeoutMs: 8000 }) - const first = String(r.stdout || '').trim().split(/\r?\n/)[0].trim() - if (first) { - this.gitBashPath = first - this.shellMode = 'git-bash' - return - } - } catch {} - const candidates = [ - 'C:\\Program Files\\Git\\bin\\bash.exe', - 'C:\\Program Files (x86)\\Git\\bin\\bash.exe', - ...(process.env.LOCALAPPDATA ? [process.env.LOCALAPPDATA + '\\Programs\\Git\\bin\\bash.exe'] : []), - '%LOCALAPPDATA%\\Programs\\Git\\bin\\bash.exe', - ] - for (const c of candidates) { - if (await this._cmdExists(c)) { - this.gitBashPath = c - this.shellMode = 'git-bash' - return - } - } - this.shellMode = 'git-bash' // wanted but not found — commands fall back to raw - this.gitBashPath = '' - } - - async _cmdExists(p) { - try { - const r = await this._execRaw(`cmd /c if exist "${p}" (echo Y) else (echo N)`, { timeoutMs: 8000 }) - return /Y/.test(String(r.stdout || '')) - } catch { - return false - } - } - - /** Run one remote command; resolves { code, signal, stdout, stderr }. - * On Windows remotes with Git Bash the script is piped to `bash -s` over the - * exec-channel stdin — no shell quoting round-trip, so any content (quotes, - * backslashes, newlines) survives verbatim. */ - exec(command, timeoutMsOrOpts) { - const opts = timeoutMsOrOpts && typeof timeoutMsOrOpts === 'object' ? timeoutMsOrOpts : { timeoutMs: timeoutMsOrOpts } - return this.detect().then(() => { - if (this.platform === 'windows' && this.gitBashPath) { - const script = String(command) - return this._execRaw(`"${this.gitBashPath}" -s`, { ...opts, timeoutMs: opts.timeoutMs || this.config.commandTimeoutMs }, (stream) => { - try { stream.end(script) } catch {} - }) - } - return this._execRaw(command, opts) - }) - } - - /** Raw exec (no detection / no wrapper). Optional stdinWriter(stream) feeds - * the remote process stdin (used by the Git Bash `-s` mode). */ - _execRaw(command, opts, stdinWriter) { - const timeoutMs = (opts && opts.timeoutMs) || this.config.commandTimeoutMs - return this.connect().then( - (client) => - new Promise((resolve, reject) => { - let retried = false - const runOn = (c) => { - const execOpts = {} - if (opts && opts.pty) execOpts.pty = true - if (opts && opts.env && typeof opts.env === 'object') execOpts.env = opts.env - c.exec(command, execOpts, (err, stream) => { - if (err) { - // Channel-open failure — or a session termination — usually - // means the pooled connection died server-side (idle timeout / - // network reset) while keepalive hadn't noticed. Drop it and - // retry ONCE on a fresh connection. - if (!retried && /channel open failure|open failed|unexpected .* session termination|session termination|disconnect/i.test(String((err && err.message) || err))) { - retried = true - this.invalidate() - return this.connect().then( - (fresh) => runOn(fresh), - (e2) => reject(new Error('ssh exec failed (reconnect): ' + ((e2 && e2.message) || e2))), - ) - } - return reject(new Error('ssh exec failed: ' + ((err && err.message) || err))) - } - let stdout = '' - let stderr = '' - let settled = false - let exitCode = null - let exitSignal = null - const hardCap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024) - const settle = () => { - if (settled) return - settled = true - clearTimeout(timer) - resolve({ - code: exitCode, - signal: exitSignal, - stdout: truncate(stdout, this.config.maxOutputChars), - stderr: truncate(stderr, this.config.maxOutputChars), - }) - } - const timer = setTimeout(() => { - if (settled) return - exitCode = -1 - exitSignal = 'TIMEOUT' - // Kill the remote command (SIGTERM) rather than just dropping the - // channel, so a runaway process cannot keep running and holding - // the SSH connection after we've given up on its output. - try { - if (typeof stream.signal === 'function') stream.signal('SIGTERM') - } catch {} - const hardClose = setTimeout(() => { - try { stream.close() } catch {} - }, 800) - if (typeof hardClose.unref === 'function') hardClose.unref() - settle() - }, timeoutMs) - stream.on('close', (code, signal) => { - if (settled) return - exitCode = code - exitSignal = signal - settle() - }) - stream.on('data', (d) => { - if (stdout.length < hardCap) stdout += d - }) - stream.stderr.on('data', (d) => { - if (stderr.length < hardCap) stderr += d - }) - stream.on('error', (e) => { - if (settled) return - settled = true - clearTimeout(timer) - reject(new Error('ssh stream error: ' + ((e && e.message) || e))) - }) - if (stdinWriter) { - try { stdinWriter(stream) } catch {} - } - }) - } - runOn(client) - }), - ) - } - - /** Resolve a promisified SFTP client. All paths normalized via toSftpPath(). */ - sftp() { - return this.connect().then( - (client) => - new Promise((resolve, reject) => { - let retried = false - const runOn = (c) => { - c.sftp((err, sftp) => { - if (err) { - // Same dead-connection recovery as exec(): a channel open - // failure — or a session termination (remote closed the - // SFTP subchannel, e.g. transient network blip / sshd idle - // drop) — means the pooled connection is stale: drop it and - // retry ONCE on a fresh connection. - if (!retried && /channel open failure|open failed|unexpected sftp session termination|session termination|disconnect/i.test(String((err && err.message) || err))) { - retried = true - this.invalidate() - return this.connect().then( - (fresh) => runOn(fresh), - (e2) => reject(new Error('ssh sftp failed (reconnect): ' + ((e2 && e2.message) || e2))), - ) - } - return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err))) - } - const withTimeout = (fn) => (...args) => - new Promise((r2, j2) => { - const timer = setTimeout(() => j2(new Error('sftp operation timed out')), this.config.commandTimeoutMs) - const done = (e, v) => { - clearTimeout(timer) - e ? j2(e) : r2(v) - } - try { fn(...args, done) } catch (e) { clearTimeout(timer); j2(e) } - }) - const P = (p) => toSftpPath(p) - resolve({ - readdir: (dir) => withTimeout((d, cb) => sftp.readdir(d, cb))(P(dir)), - stat: (p) => withTimeout((d, cb) => sftp.stat(d, cb))(P(p)), - lstat: (p) => withTimeout((d, cb) => sftp.lstat(d, cb))(P(p)), - mkdir: (dir) => withTimeout((d, cb) => sftp.mkdir(d, cb))(P(dir)), - rmdir: (dir) => withTimeout((d, cb) => sftp.rmdir(d, cb))(P(dir)), - unlink: (p) => withTimeout((d, cb) => sftp.unlink(d, cb))(P(p)), - rename: (p, d) => withTimeout((a, b, cb) => sftp.rename(a, b, cb))(P(p), P(d)), - realpath: (p) => withTimeout((d, cb) => sftp.realpath(d, cb))(P(p)), - readFile: (p) => withTimeout((d, cb) => sftp.readFile(d, cb))(P(p)), - writeFile: (p, data) => withTimeout((d, data2, cb) => sftp.writeFile(d, data2, cb))(P(p), data), - fastGet: (p, lp) => withTimeout((d, l, cb) => sftp.fastGet(d, l, cb))(P(p), lp), - fastPut: (lp, p) => withTimeout((l, d, cb) => sftp.fastPut(l, d, cb))(lp, P(p)), - }) - }) - } - runOn(client) - }), - ) - } - - /** - * Drop the cached client and force a fresh connection on the next call. - * Called when a channel open fails (e.g. "Channel open failure: open - * failed") — the pooled SSH connection is usually dead server-side while - * keepalive has not yet noticed, and reusing it keeps failing. The epoch - * bump orphans any in-flight connect; the client is ended so ssh2 frees - * its sockets. - */ - invalidate() { - this.epoch++ - const client = this.client - this.client = null - const pending = this.connecting - this.connecting = null - if (pending && typeof pending.catch === 'function') { - try { pending.catch(() => {}) } catch {} - } - if (this.proxyPool) { - try { this.proxyPool.close() } catch {} - this.proxyPool = null - } - if (client) { - try { client.end() } catch {} - } - } - - close() { - this.epoch++ - const client = this.client - this.client = null - const pending = this.connecting - this.connecting = null - if (pending && typeof pending.catch === 'function') { - try { pending.catch(() => {}) } catch {} - } - if (this.proxyPool) { - try { this.proxyPool.close() } catch {} - this.proxyPool = null - } - if (client) { - try { - client.end() - } catch {} - } - if (this.onCloseHook) { - try { this.onCloseHook() } catch {} - } - } -} +const isHostKeyKnown = (host, port) => _isHostKeyKnown(knownHostsFile(), host, port) // ── encoding helpers ─────────────────────────────────────────────────────── @@ -806,7 +235,7 @@ function encodeText(s, enc) { // ── apply ───────────────────────────────────────────────────────────────── export async function apply(ctx, config) { - const pool = new SshPool(config) + const pool = new SshPool(config, { knownHostsFile }) ctx.effect(() => () => pool.close(), 'dsh-remote.close') migrateLegacyData() @@ -953,7 +382,7 @@ export async function apply(ctx, config) { // session passes its own mirror-resolved remote path explicitly. workspace: '', }) - const created = new SshPool(poolConfig) + const created = new SshPool(poolConfig, { knownHostsFile }) // Keychain-backed passwords resolve against THIS pool's machine record. created.passwordResolver = async () => machinePassword(rec || { id: '', password: poolConfig.password }) machinePools.set(key, created) @@ -1126,7 +555,7 @@ export async function apply(ctx, config) { const state = loadSyncState(localDir) const next = { ...state } if (rels.includes('*')) { - const r = await pushTree(sftp, localDir, ws, { maxFiles: 500, maxFileBytes: config.maxFileBytes, isIgnored: matcher, state: next }) + const r = await pushTree(sftp, localDir, ws, { maxFiles: 2000, maxFileBytes: config.maxFileBytes, isIgnored: matcher, state: next }) Object.assign(next, r.nextState) for (const c of r.stats.conflicts) audit('auto-push-conflict', `push ${c.path}`, 1, machine) } else { @@ -1182,88 +611,6 @@ export async function apply(ctx, config) { return text } - /** Structured listing: name + type + size + mtime + mode (SFTP protocol-level, - * works on any remote: POSIX / cmd.exe / PowerShell). */ - const listDirStructured = async (p, sshPool = pool) => { - const target = normalizeRemotePath(p || '/') - let sftp - try { - sftp = await sshPool.sftp() - } catch (err) { - throw new Error('browse failed: ' + ((err && err.message) || err)) - } - let list - try { - list = await sftp.readdir(target) - } catch (err) { - // Missing directory / deleted folder: not an internal error. Report it - // distinctly so the UI can show「目录不存在」without treating it as a - // hard failure (500) that collapses the whole tree. - const msg = String((err && err.message) || err) - if (/no such file|not found|does not exist|ENOENT/i.test(msg)) { - return { path: target, items: [], missing: true } - } - throw new Error('browse failed: ' + msg) - } - const items = [] - const symIdx = [] - for (const e of list) { - const name = String(e.filename) - if (name === '.' || name === '..' || !name) continue - const a = e.attrs || {} - let type - if (a.isSymbolicLink && a.isSymbolicLink()) { - type = 'symlink' - symIdx.push(items.length) - } else if (a.isDirectory && a.isDirectory()) { - type = 'dir' - } else { - type = 'file' - } - items.push({ - type, - name, - size: typeof a.size === 'number' ? a.size : 0, - mtime: typeof a.mtime === 'number' ? a.mtime : 0, - mode: typeof a.mode === 'number' ? a.mode.toString(8) : '', - }) - } - // Resolve symlink-to-dir vs symlink-to-file (bounded, failure-tolerant). - if (symIdx.length) { - await Promise.all(symIdx.map(async (i) => { - const full = joinRemotePath(target, items[i].name) - try { - const st = await sftp.lstat(full) - items[i].type = st && st.isDirectory && st.isDirectory() ? 'dir' : 'file' - } catch { /* degrade to file */ } - })) - } - return { path: target, items } - } - - /** Windows drive letters as display-form dir entries (the "This PC" root view). - * `cmd /c fsutil fsinfo drives` works under cmd/PowerShell/Git Bash and prints - * e.g. "Drives: C:\\ D:\\ E:\\"; falls back to listing Git-Bash mount points - * (/c /d …). Returns [] on POSIX hosts or when enumeration fails. */ - const listRemoteDrives = async () => { - let letters = [] - try { - const res = await pool.exec('cmd /c fsutil fsinfo drives', { timeoutMs: Math.min(config.commandTimeoutMs, 8000) }) - letters = [...String(res.stdout || '').matchAll(/([a-zA-Z]):\\?/g)].map((mm) => mm[1].toUpperCase()) - } catch {} - if (!letters.length) { - try { - const res = await pool.exec('ls -d /[a-z] 2>/dev/null', { timeoutMs: Math.min(config.commandTimeoutMs, 8000) }) - letters = String(res.stdout || '') - .split(/\s+/) - .map((s) => s.replace(/^\/+|\/+$/g, '')) - .filter((s) => /^[a-zA-Z]$/.test(s)) - .map((s) => s.toUpperCase()) - } catch {} - } - return [...new Set(letters)].sort().map((l) => ({ name: l + ':\\', path: l + ':\\', type: 'dir', drive: true, size: 0, mtime: 0 })) - } - const isRemoteDir = async (p, sshPool = pool) => { const target = normalizeRemotePath(p) try { @@ -1275,30 +622,6 @@ export async function apply(ctx, config) { } } - /** Recursive remote delete (bounded): unlink files bottom-up, then rmdir. */ - const removeRemoteTree = async (sftp, p, maxFiles = 2000) => { - let removed = 0 - const walk = async (dir) => { - if (removed >= maxFiles) return - let entries = [] - try { entries = (await sftp.readdir(dir)) || [] } catch { return } - for (const e of entries) { - if (removed >= maxFiles) return - const name = String(e.filename) - if (name === '.' || name === '..') continue - const fp = joinRemotePath(dir, name) - const isDir = !!(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()) - if (isDir) await walk(fp) - else { - try { await sftp.unlink(fp); removed++ } catch {} - } - } - try { await sftp.rmdir(dir); removed++ } catch { /* already gone or non-empty */ } - } - await walk(p) - return removed - } - // ── remote workspace state ──────────────────────────────────────────────── const wsPath = () => (config.workspace || '').trim() /** Map a LOCAL path to the remote path its mirror represents ('' when the @@ -1323,22 +646,66 @@ export async function apply(ctx, config) { * with several live sessions the "first" one is arbitrary and may belong to * another machine. Tool calls must never route through this — they resolve * their own session through `bindingFor()`, which is authoritative. */ - const sessionRemotePath = (sessionId) => { - let cwd = '' + const sessionsSvc = () => (ctx && typeof ctx.get === 'function' ? ctx.get('sessions') : null) + const sessionCwd = (sessionId) => { + const sessions = sessionsSvc() + if (sessionId) return lookupSessionCwd(sessionId, { sessions, dshHome: dshBase() }) try { - const sessions = ctx && typeof ctx.get === 'function' ? ctx.get('sessions') : null - if (sessions && typeof sessions.get === 'function' && sessionId) { - const s = sessions.get(sessionId) - if (s && s.header && s.header.cwd) cwd = String(s.header.cwd) - } else if (sessions && typeof sessions.list === 'function') { + if (sessions && typeof sessions.list === 'function') { const s = sessions.list()[0] - if (s && s.header && s.header.cwd) cwd = String(s.header.cwd) + if (s && s.header && s.header.cwd) return String(s.header.cwd) } } catch { /* sessions service unavailable */ } + return '' + } + const sessionRemotePath = (sessionId) => { + const cwd = sessionCwd(sessionId) if (!cwd) return '' return resolveMirrorForLocal(cwd).remotePath } + /** + * HTTP binding for sidebar file routes. A sessionId/local hint is authoritative: + * a local (non-mirror) session is refused rather than falling back to the + * active machine. Requests with no hint keep the picker/settings active pool. + */ + const resolveRequestBinding = async (req, body = {}) => { + const hint = requestSessionHint(req, body) + let cwd = hint.local + if (!cwd && hint.sessionId) cwd = lookupSessionCwd(hint.sessionId, { sessions: sessionsSvc(), dshHome: dshBase() }) + if (cwd) { + const { remotePath, machine, mirrorDir } = resolveMirrorForLocal(cwd) + if (remotePath && machine) { + return { + pool: poolForMachine(machine), + ws: remotePath, + host: machine.host, + username: machine.username, + port: machine.port, + mirrorDir, + bound: true, + local: false, + } + } + const err = new Error( + 'this session is LOCAL — its workspace is not a remote mirror, so there is no remote host to act on. ' + + 'Refusing rather than falling back to the active machine.', + ) + err.httpStatus = 403 + throw err + } + return { + pool, + ws: wsPath(), + host: config.host, + username: config.username, + port: config.port, + mirrorDir: wsPath() ? mirrorDirFor(wsPath(), config.host, config.username, config.port) : null, + bound: false, + local: false, + } + } + // ── session-bound remote binding (the authoritative tool path) ───────────── /** @@ -1634,8 +1001,8 @@ export async function apply(ctx, config) { description: 'Download the current remote workspace into its local mirror directory over SFTP (bounded, three-way conflict-aware). Makes the remote files visible/editable locally so the DSH native workspace / fs tools can operate on them. Conflicts (both sides modified) are reported and never overwritten; use force=true to override.', parameters: { - depth: { type: 'integer', description: 'Max directory depth to mirror (default 5)' }, - maxFiles: { type: 'integer', description: 'Max files to download (default 500)' }, + depth: { type: 'integer', description: 'Max directory depth to mirror (default 8)' }, + maxFiles: { type: 'integer', description: 'Max files to download (default 2000)' }, dryRun: { type: 'boolean', description: 'Compute the plan without downloading (default false)' }, force: { type: 'boolean', description: 'Overwrite conflicting files (default false)' }, async: { type: 'boolean', description: 'Run in the background and return a task id (default false)' }, @@ -1646,8 +1013,8 @@ export async function apply(ctx, config) { const p = b.ws const local = b.mirrorDir || mirrorDirFor(p, b.host, b.username, b.port) mkdirSync(local, { recursive: true }) - const depth = Math.min(Math.max(Number(args.depth) || 5, 1), 8) - const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000) + const depth = Math.min(Math.max(Number(args.depth) || 8, 1), 16) + const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 2000, 1), 20000) const isIgnored = ignoreMatcher() const body = { depth, maxFiles, dryRun: !!args.dryRun, force: !!args.force, isIgnored } const runSync = async () => { @@ -1660,7 +1027,8 @@ export async function apply(ctx, config) { const state = loadSyncState(local) const { stats, nextState } = await syncTree(sftp, p, local, { ...body, state, maxFileBytes: config.maxFileBytes }) if (!args.dryRun) saveSyncState(local, nextState) - let text = `${args.dryRun ? 'WOULD download' : 'Downloaded'} ${stats.files} file(s) from ${p} → ${local}${stats.files >= maxFiles ? ' (hit download cap)' : ''}.` + let text = `${args.dryRun ? 'WOULD download' : 'Downloaded'} ${stats.files} file(s) from ${p} → ${local}.` + if (stats.truncated || stats.files >= maxFiles) text += ` TRUNCATED (hit depth=${depth} or maxFiles=${maxFiles} cap; raise maxFiles/depth and re-run).` if (stats.skippedUnchanged) text += ` ${stats.skippedUnchanged} unchanged.` if (stats.skippedLarge) text += ` ${stats.skippedLarge} too large (over ${config.maxFileBytes} bytes).` if (stats.staleRemote) text += ` ${stats.staleRemote} remote entries gone (kept locally; use rw_push to mirror deletions).` @@ -1685,7 +1053,7 @@ export async function apply(ctx, config) { description: 'Upload the local mirror of the current remote workspace back to the remote host over SFTP (bounded, three-way conflict-aware). Use after editing files in the local mirror so the remote reflects your changes. Conflicts (both sides modified) are reported and never overwritten; use force=true to override.', parameters: { - maxFiles: { type: 'integer', description: 'Max files to upload (default 500)' }, + maxFiles: { type: 'integer', description: 'Max files to upload (default 2000)' }, dryRun: { type: 'boolean', description: 'Compute the plan without uploading (default false)' }, force: { type: 'boolean', description: 'Overwrite conflicting files (default false)' }, async: { type: 'boolean', description: 'Run in the background and return a task id (default false)' }, @@ -1696,7 +1064,7 @@ export async function apply(ctx, config) { const p = b.ws const local = b.mirrorDir || mirrorDirFor(p, b.host, b.username, b.port) if (!existsSync(local)) throw new Error(`rw_push: local mirror does not exist — run rw_sync first (${local})`) - const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000) + const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 2000, 1), 20000) const isIgnored = ignoreMatcher() const body = { maxFiles, dryRun: !!args.dryRun, force: !!args.force, isIgnored } const runPush = async () => { @@ -1710,6 +1078,7 @@ export async function apply(ctx, config) { const { stats, nextState } = await pushTree(sftp, local, p, { ...body, state, maxFileBytes: config.maxFileBytes }) if (!args.dryRun) saveSyncState(local, nextState) let text = `${args.dryRun ? 'WOULD upload' : 'Uploaded'} ${stats.files} file(s) from ${local} → ${p}.` + if (stats.truncated || stats.files >= maxFiles) text += ` TRUNCATED (hit maxFiles=${maxFiles} cap; raise maxFiles and re-run).` if (stats.skippedUnchanged) text += ` ${stats.skippedUnchanged} unchanged.` if (stats.skippedLarge) text += ` ${stats.skippedLarge} too large (over ${config.maxFileBytes} bytes).` if (stats.staleLocal) text += ` ${stats.staleLocal} local entries gone remotely (kept remotely; use rw_remove to mirror deletions).` @@ -2058,7 +1427,7 @@ export async function apply(ctx, config) { defineTool({ name: 'rw_search', description: - 'Search remote files for a pattern (recursive SFTP walk — works on ANY remote including Windows, honors ignore rules). Returns matching file:line rows; output is capped.', + 'Search remote files for a pattern. POSIX remotes try `rg` then `grep -R` first; Windows and fallbacks use a portable SFTP walk. Honors ignore rules. Returns matching file:line rows; output is capped.', parameters: { pattern: { type: 'string', required: true, description: 'Pattern to search for (extended regex)' }, path: { type: 'string', description: 'Directory to search (default: current remote workspace)' }, @@ -2081,16 +1450,18 @@ export async function apply(ctx, config) { } catch (err) { throw new Error('rw_search: bad pattern: ' + ((err && err.message) || err)) } - const sftp = await b.pool.sftp() const maxMatches = Math.min(Math.max(Number(args.maxMatches) || 500, 1), 2000) const matcher = ignoreMatcher() - const { matches, scanned, truncated } = await searchTree(sftp, dir, { + const { matches, scanned, truncated } = await searchRemote(b.pool, dir, { + pattern, regex, glob: args.glob, + ignoreCase: args.ignoreCase !== false, contextLines: Math.min(Math.max(Number(args.contextLines) || 0, 0), 10), maxMatches, maxScanBytes: Math.min(config.maxFileBytes || 1024 * 1024, 1024 * 1024), isIgnored: (name, isDir) => matcher(name, isDir), + timeoutMs: config.commandTimeoutMs, }) if (!matches.length) return { text: `no matches for /${pattern}/ in ${dir} (${scanned} files scanned)` } let text = matches.map((m) => `${m.path}:${m.line}: ${m.text}`).join('\n') @@ -2282,7 +1653,7 @@ export async function apply(ctx, config) { name: 'remote-forget-key', description: 'Drop the trusted host-key record for the current machine so the next connect re-records it.', handler: () => { - createHostKeyGuard(config).forgetHost() + createHostKeyGuard(config, knownHostsFile()).forgetHost() return { kind: 'success', text: `forgot host key for ${config.host || ''}:${config.port} — the next connect will re-record it.` } }, }) @@ -2426,7 +1797,25 @@ export async function apply(ctx, config) { const sessionId = q.get('sessionId') ? decodeURIComponent(q.get('sessionId')) : '' // Per-session remote context (issue #13): when a sessionId is given, // sessionMode reflects THAT session's cwd, not the machine default. - return sendJson(res, 200, sessionId ? { ...status(), sessionMode: sessionRemotePath(sessionId) ? 'remote' : 'local', sessionRemotePath: sessionRemotePath(sessionId) } : status()) + if (!sessionId) return sendJson(res, 200, status()) + const cwd = sessionCwd(sessionId) + const resolved = cwd ? resolveMirrorForLocal(cwd) : { remotePath: '', machine: null } + const extra = { + sessionMode: resolved.remotePath ? 'remote' : 'local', + sessionRemotePath: resolved.remotePath || '', + sessionBound: !!(resolved.machine && resolved.remotePath), + } + if (resolved.machine && resolved.remotePath) { + const p = poolForMachine(resolved.machine) + extra.host = resolved.machine.host + extra.username = resolved.machine.username + extra.port = resolved.machine.port + extra.workspace = toDisplayPath(resolved.remotePath, p.platform) + // Bound sessions are allowed to list/read even if this process has + // not yet opened a socket — /ls connects on demand. + extra.connected = true + } + return sendJson(res, 200, { ...status(), ...extra }) } sendJson(res, 405, { error: 'method not allowed' }) }, @@ -2557,219 +1946,10 @@ export async function apply(ctx, config) { } }, }, - { - kind: 'exact', - path: '/dsh-remote/ls', - handler: async (req, res) => { - try { - await pool.detect() - const q = new URL(req.url, 'http://localhost').searchParams - const raw = q.get('path') ? decodeURIComponent(q.get('path')) : wsPath() - const canon = normalizeRemotePath(raw) - const win = pool.platform === 'windows' - // Windows root → "This PC" drive view (C:\ D:\ E:\), not the Git-Bash - // MSYS root. When the platform probe was inconclusive we still try — a - // real Windows box answers fsutil, a POSIX box yields no letters and - // falls through to the normal root listing. - if (canon === '/' || raw === '' || raw === '/') { - if (win || pool.platform !== 'posix') { - const drives = await listRemoteDrives() - if (drives.length) return sendJson(res, 200, { path: '', platform: 'windows', items: drives }) - } - if (win) return sendJson(res, 200, { path: '', platform: 'windows', items: [] }) - } - const out = await listDirStructured(canon) - const items = out.items.map((it) => ({ - ...it, - // full display-form path so the client never joins paths itself - path: toDisplayPath(joinRemotePath(canon, it.name), pool.platform), - })) - return sendJson(res, 200, { path: toDisplayPath(out.path, pool.platform), platform: win ? 'windows' : 'posix', items }) - } catch (err) { - return sendJson(res, 500, { error: String((err && err.message) || err) }) - } - }, - }, - { - // Read a remote file over SFTP (live). Text returns as content (CRLF→LF, - // encoding-aware), binary is reported with size+head (base64). A stat - // happens first; oversized files are previewed from a streamed head chunk - // instead of being fully buffered. - kind: 'exact', - path: '/dsh-remote/read', - handler: async (req, res) => { - if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' }) - try { - const q = new URL(req.url, 'http://localhost').searchParams - let p = q.get('path') ? decodeURIComponent(q.get('path')) : '' - let encoding = '' - let maxBytes = 256 * 1024 - if (req.method === 'POST') { - const body = JSON.parse((await readBody(req)) || '{}') - if (!p) p = String(body.path || '') - if (body.encoding) encoding = String(body.encoding) - if (body.maxBytes) maxBytes = Number(body.maxBytes) - } - if (q.get('maxBytes')) maxBytes = Number(q.get('maxBytes')) - if (!p) return sendJson(res, 400, { ok: false, error: 'path is required' }) - const target = normalizeRemotePath(p) - maxBytes = Math.min(Math.max(Number(maxBytes) || 256 * 1024, 1024), 2 * 1024 * 1024) - let sftp - try { - sftp = await pool.sftp() - } catch (err) { - return sendJson(res, 500, { ok: false, error: 'sftp unavailable: ' + ((err && err.message) || err) }) - } - let st - try { st = await sftp.stat(target) } catch (err) { - return sendJson(res, 500, { ok: false, error: 'read failed: ' + ((err && err.message) || err) }) - } - if (st.size > maxBytes) { - // Head preview for oversized files: fastGet to a temp file, read the - // first maxBytes, delete the temp. Never buffers the whole file. - const tmp = path.join(dshBase(), '.dsh-remote-preview-' + process.pid) - try { - await sftp.fastGet(target, tmp) - const { open } = await import('node:fs/promises') - const fd = await open(tmp, 'r') - const head = Buffer.alloc(Math.min(maxBytes, st.size)) - await fd.read(head, 0, head.length, 0) - await fd.close() - try { unlinkSync(tmp) } catch {} - const content = decodeBuf(head, encoding || config.encoding).replace(/\r\n/g, '\n') - return sendJson(res, 200, { ok: true, binary: false, content, truncated: true, size: st.size }) - } catch (err) { - return sendJson(res, 500, { ok: false, error: 'read failed: ' + ((err && err.message) || err) }) - } - } - let buf - try { - buf = await sftp.readFile(target) - } catch (err) { - return sendJson(res, 500, { ok: false, error: 'read failed: ' + ((err && err.message) || err) }) - } - const headN = Math.min(buf.length, 8192) - let binary = false - for (let i = 0; i < headN; i++) { - if (buf[i] === 0) { binary = true; break } - } - if (binary) { - return sendJson(res, 200, { ok: true, binary: true, size: buf.length, head: buf.slice(0, Math.min(buf.length, 4096)).toString('base64') }) - } - let content = decodeBuf(buf, encoding || config.encoding).replace(/\r\n/g, '\n') - const truncated = content.length > maxBytes - if (truncated) content = content.slice(0, maxBytes) + `\n…[truncated: ${buf.length - maxBytes} more bytes]` - return sendJson(res, 200, { ok: true, binary: false, content, truncated }) - } catch (err) { - return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) }) - } - }, - }, - { - // Write a remote file (sidebar editor save). `expectedMtime` is an - // optimistic lock: a 409 is returned when the remote changed meanwhile. - kind: 'exact', - path: '/dsh-remote/write', - handler: async (req, res) => { - if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' }) - try { - const body = JSON.parse((await readBody(req)) || '{}') - const p = normalizeRemotePath(String(body.path || '')) - if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path is required' }) - const sftp = await pool.sftp() - let st = null - try { st = await sftp.stat(p) } catch { /* new file */ } - if (body.expectedMtime != null && st && st.mtime !== Number(body.expectedMtime)) { - return sendJson(res, 409, { ok: false, error: `远端文件已变化(mtime ${st.mtime} ≠ ${body.expectedMtime}),已放弃保存——请重新读取后再编辑` }) - } - const buf = encodeText(String(body.content ?? ''), body.encoding || config.encoding) - if (!st) await mkdirRemoteDirs(sftp, remoteDirname(p)) - await sftp.writeFile(p, buf) - audit('write', `write ${p}`, 0) - const st2 = await sftp.stat(p).catch(() => null) - return sendJson(res, 200, { ok: true, bytes: buf.byteLength, mtime: st2 ? st2.mtime : Math.floor(Date.now() / 1000) }) - } catch (err) { - return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) }) - } - }, - }, - { - // Generic remote fs ops used by the sidebar context menu + picker: - // mkdir / rename / remove (recursive) / write / append. - kind: 'exact', - path: '/dsh-remote/fs', - handler: async (req, res) => { - if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' }) - try { - const body = JSON.parse((await readBody(req)) || '{}') - const op = String(body.op || '') - const sftp = await pool.sftp() - if (op === 'mkdir') { - const p = normalizeRemotePath(String(body.path || '')) - if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' }) - await mkdirRemoteDirs(sftp, p) - audit('mkdir', `mkdir ${p}`, 0) - return sendJson(res, 200, { ok: true }) - } - if (op === 'rename') { - const p = normalizeRemotePath(String(body.path || '')) - const d = normalizeRemotePath(String(body.dest || '')) - if (!p || !d) return sendJson(res, 400, { ok: false, error: 'path and dest required' }) - await sftp.rename(p, d) - audit('move', `move ${p} → ${d}`, 0) - return sendJson(res, 200, { ok: true }) - } - if (op === 'remove') { - const p = normalizeRemotePath(String(body.path || '')) - if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' }) - const st = await sftp.stat(p).catch(() => null) - if (!st) return sendJson(res, 404, { ok: false, error: 'not found' }) - if (st.isDirectory && st.isDirectory()) { - await removeRemoteTree(sftp, p) - } else { - await sftp.unlink(p) - } - audit('remove', `remove ${p}`, 0) - return sendJson(res, 200, { ok: true }) - } - if (op === 'write' || op === 'append') { - const p = normalizeRemotePath(String(body.path || '')) - if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' }) - let content = '' - if (op === 'append') { - try { content = decodeBuf(await sftp.readFile(p), body.encoding || config.encoding) } catch { /* new */ } - } - const buf = encodeText(content + String(body.content ?? ''), body.encoding || config.encoding) - if (op === 'write' && !(await sftp.stat(p).catch(() => null))) await mkdirRemoteDirs(sftp, remoteDirname(p)) - await sftp.writeFile(p, buf) - audit(op, `${op} ${p}`, 0) - return sendJson(res, 200, { ok: true, bytes: buf.byteLength }) - } - if (op === 'download') { - // Download a remote file into the current workspace mirror. - const p = normalizeRemotePath(String(body.path || '')) - const ws = wsPath() - if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' }) - if (!ws) return sendJson(res, 400, { ok: false, error: 'no remote workspace set' }) - const st = await sftp.stat(p).catch(() => null) - if (!st) return sendJson(res, 404, { ok: false, error: 'not found' }) - if (config.maxFileBytes > 0 && st.size > config.maxFileBytes) { - return sendJson(res, 400, { ok: false, error: `file is ${st.size} bytes (over ${config.maxFileBytes} cap)` }) - } - const base = mirrorDirFor(ws, config.host, config.username, config.port) - const rel = p.startsWith(ws) ? p.slice(ws.length).replace(/^\/+/, '') : p.slice(1) - const local = path.join(base, rel) - mkdirSync(path.dirname(local), { recursive: true }) - await sftp.fastGet(p, local) - audit('download', `download ${p}`, 0) - return sendJson(res, 200, { ok: true, bytes: st.size, local }) - } - return sendJson(res, 400, { ok: false, error: 'unknown op' }) - } catch (err) { - return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) }) - } - }, - }, + ...createFsRoutes({ + sendJson, readBody, resolveRequestBinding, decodeBuf, encodeText, audit, config, mirrorDirFor, + }), + { kind: 'exact', path: '/dsh-remote/workspace', @@ -3046,7 +2226,7 @@ export async function apply(ctx, config) { } : undefined, connectTimeoutMs: Math.min(Math.max(Number(body.connectTimeoutMs) || config.connectTimeoutMs, 2000), 30000), commandTimeoutMs: 10000, - }) + }, { knownHostsFile }) const started = Date.now() await probe.connect() await probe.exec('true', { timeoutMs: 10000 }) @@ -3091,7 +2271,7 @@ export async function apply(ctx, config) { path: '/dsh-remote/forget-key', handler: async (req, res) => { if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' }) - createHostKeyGuard(config).forgetHost() + createHostKeyGuard(config, knownHostsFile()).forgetHost() return sendJson(res, 200, { ok: true, ...status() }) }, }, diff --git a/lib/pool.js b/lib/pool.js new file mode 100644 index 0000000..f4212ca --- /dev/null +++ b/lib/pool.js @@ -0,0 +1,511 @@ +// Persistent SSH/SFTP connection pool (one identity → one live client). +import ssh2 from 'ssh2' +import { readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import path from 'node:path' +import { toSftpPath, truncate } from './paths.js' +import { createHostKeyGuard } from './hostkey.js' + +const { Client } = ssh2 + +export class SshPool { + constructor(config, opts = {}) { + this.config = config + this._knownHostsFile = opts.knownHostsFile + this.client = null + this.connecting = null + this.proxyPool = null + // Generational token: bumped on every target change / close so a stale + // in-flight connect can never hand this pool a connection to an old host. + this.epoch = 0 + /** Auto-detected remote platform: unknown | windows | posix (per target). */ + this.platform = 'unknown' + /** Resolved Git Bash bash.exe path on Windows remotes ('' when none). */ + this.gitBashPath = '' + /** Resolved terminal strategy: native | git-bash. */ + this.shellMode = 'native' + /** In-flight platform detection promise (cached). */ + this._detecting = null + /** Optional async resolver for a machine-stored (keychain) password. */ + this.passwordResolver = null + /** Optional hook called with the live client after a successful connect. */ + this.onReady = null + /** Optional hook called when the pool closes. */ + this.onCloseHook = null + } + + resolveKeyPath() { + const p = this.config.privateKeyPath + if (!p) return '' + if (p.startsWith('~/') || p === '~') return path.join(homedir(), p.slice(1)) + return p + } + + setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode }) { + if (host !== undefined) this.config.host = String(host) + if (port !== undefined && Number(port)) this.config.port = Number(port) + if (username !== undefined) this.config.username = String(username) + if (password !== undefined && password !== null) this.config.password = String(password) + if (privateKeyPath !== undefined) this.config.privateKeyPath = String(privateKeyPath) + if (passphrase !== undefined) this.config.passphrase = String(passphrase) + if (workspace !== undefined) this.config.workspace = String(workspace) + if (useAgent !== undefined) this.config.useAgent = !!useAgent + if (keyboardInteractive !== undefined) this.config.keyboardInteractive = !!keyboardInteractive + if (proxy !== undefined) this.config.proxy = proxy + if (hostKeyMode !== undefined) this.config.hostKeyMode = String(hostKeyMode) + // the new target may be a different OS — re-detect on the next command + this.platform = 'unknown' + this.gitBashPath = '' + this.shellMode = 'native' + this._detecting = null + this.close() + return this + } + + connect() { + if (this.client) return Promise.resolve(this.client) + if (this.connecting) return this.connecting + const epoch = this.epoch + const pending = this._doConnect(epoch) + this.connecting = pending + const clear = () => { + if (this.epoch === epoch && this.connecting === pending) this.connecting = null + } + pending.then(clear, clear) + return pending + } + + async _doConnect(epoch) { + const isCurrent = () => this.epoch === epoch + const knownHostsFile = typeof this._knownHostsFile === 'function' ? this._knownHostsFile() : this._knownHostsFile + const guard = createHostKeyGuard(this.config, knownHostsFile) + const client = new Client() + let settled = false + const fail = (err) => { + if (settled) return + settled = true + if (isCurrent() && this.client === client) this.client = null + throw guard.lastError ? new Error(guard.lastError) : err + } + + // Proxy jump: SSH to the bastion first, then tunnel to the target through it. + let sock = null + const proxyCfg = this.config.proxy + if (proxyCfg && proxyCfg.host) { + try { + this.proxyPool = new SshPool({ + ...this.config, + host: proxyCfg.host, + port: Number(proxyCfg.port) || 22, + username: proxyCfg.username || this.config.username || 'root', + password: proxyCfg.password || '', + privateKeyPath: proxyCfg.privateKeyPath || '', + passphrase: proxyCfg.passphrase || '', + proxy: undefined, + }, { knownHostsFile: this._knownHostsFile }) + const pclient = await this.proxyPool.connect() + if (!isCurrent()) throw new Error('ssh target changed during proxy connect') + sock = await new Promise((res, rej) => { + pclient.forwardOut('127.0.0.1', 0, this.config.host, this.config.port, (e, ch) => (e ? rej(new Error('proxy forward to target failed: ' + ((e && e.message) || e))) : res(ch))) + }) + } catch (err) { + return fail(err) + } + } + + return new Promise((resolve, reject) => { + const rejectOnce = (err) => { + if (settled) return + settled = true + if (isCurrent() && this.client === client) this.client = null + reject(guard.lastError ? new Error(guard.lastError) : err) + } + client.on('ready', () => { + if (settled) return + settled = true + if (!isCurrent()) { + try { client.end() } catch {} + reject(new Error('ssh target changed during connect')) + return + } + this.client = client + resolve(client) + if (this.onReady) { try { this.onReady(client) } catch {} } + }) + client.on('error', (e) => rejectOnce(e)) + client.on('close', () => { + if (isCurrent() && this.client === client) this.client = null + rejectOnce(new Error('ssh connection closed')) + }) + + const buildOpts = async () => { + const opts = { + host: this.config.host, + port: this.config.port, + username: this.config.username, + readyTimeout: this.config.connectTimeoutMs, + keepaliveInterval: 15000, + keepaliveCountMax: 3, + hostVerifier: (key) => guard.verifier(key), + } + if (sock) opts.sock = sock + if (this.config.useAgent) { + const sockPath = process.env.SSH_AUTH_SOCK + if (sockPath) opts.agent = sockPath + } + let password = this.config.password || '' + if (!password && this.passwordResolver) { + try { password = (await this.passwordResolver()) || '' } catch {} + } + if (password) { + opts.password = password + opts.tryKeyboard = true + } else if (this.config.keyboardInteractive && !this.config.privateKeyPath) { + opts.tryKeyboard = true + } + if (this.config.privateKeyPath) { + const keyPath = this.resolveKeyPath() + if (!keyPath) { + throw new Error('no credentials: set a password or a privateKeyPath to connect') + } + let key + try { + key = readFileSync(keyPath) + } catch (err) { + throw new Error(`cannot read private key "${keyPath}": ${err && err.message}`) + } + opts.privateKey = key + opts.passphrase = this.config.passphrase || undefined + } else if (!password && !opts.agent) { + throw new Error('no credentials: set a password, a privateKeyPath, or enable useAgent to connect') + } + return opts + } + + buildOpts().then( + (opts) => { + if (opts.tryKeyboard) { + client.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => { + finish(prompts.map(() => this.config.password || '')) + }) + } + client.connect(opts) + }, + (err) => rejectOnce(err), + ) + }) + } + + /** Detect the remote platform + locate Git Bash once; cached per target. */ + detect() { + if (this.platform !== 'unknown') return Promise.resolve() + if (this._detecting) return this._detecting + this._detecting = this._detect().finally(() => { + this._detecting = null + }) + return this._detecting + } + + async _detect() { + let res + try { + // `cmd /c ver` works under cmd.exe, PowerShell AND Git Bash (all print + // "Microsoft Windows …"); on POSIX hosts `cmd` simply doesn't exist. + res = await this._execRaw('cmd /c ver', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) }) + } catch (err) { + this.platform = 'unknown' + this.shellMode = 'native' + return + } + const out = String(res.stdout || '') + '\n' + String(res.stderr || '') + if (res.code === 0 && /microsoft windows/i.test(out)) { + this.platform = 'windows' + await this._resolveGitBash() + return + } + // inconclusive — probe for a Git-Bash/MSYS remote (uname prints MINGW64_NT…) + try { + const u = await this._execRaw('uname -s', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) }) + if (/mingw|msys|cygwin/i.test(String(u.stdout || ''))) { + this.platform = 'windows' + await this._resolveGitBash() + return + } + } catch {} + this.platform = 'posix' + this.shellMode = 'native' + this.gitBashPath = '' + } + + /** On a Windows remote, locate Git Bash (config path → PATH → common installs). */ + async _resolveGitBash() { + const cfg = String(this.config.shell || '').trim() + if (cfg && cfg !== 'git-bash' && cfg !== 'native') { + if (await this._cmdExists(cfg)) { + this.gitBashPath = cfg + this.shellMode = 'git-bash' + return + } + } + if (cfg === 'native') { + this.shellMode = 'native' + this.gitBashPath = '' + return + } + try { + const r = await this._execRaw('cmd /c where bash', { timeoutMs: 8000 }) + const first = String(r.stdout || '').trim().split(/\r?\n/)[0].trim() + if (first) { + this.gitBashPath = first + this.shellMode = 'git-bash' + return + } + } catch {} + const candidates = [ + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Program Files (x86)\\Git\\bin\\bash.exe', + ...(process.env.LOCALAPPDATA ? [process.env.LOCALAPPDATA + '\\Programs\\Git\\bin\\bash.exe'] : []), + '%LOCALAPPDATA%\\Programs\\Git\\bin\\bash.exe', + ] + for (const c of candidates) { + if (await this._cmdExists(c)) { + this.gitBashPath = c + this.shellMode = 'git-bash' + return + } + } + this.shellMode = 'git-bash' // wanted but not found — commands fall back to raw + this.gitBashPath = '' + } + + async _cmdExists(p) { + try { + const r = await this._execRaw(`cmd /c if exist "${p}" (echo Y) else (echo N)`, { timeoutMs: 8000 }) + return /Y/.test(String(r.stdout || '')) + } catch { + return false + } + } + + /** Run one remote command; resolves { code, signal, stdout, stderr }. + * On Windows remotes with Git Bash the script is piped to `bash -s` over the + * exec-channel stdin — no shell quoting round-trip, so any content (quotes, + * backslashes, newlines) survives verbatim. */ + exec(command, timeoutMsOrOpts) { + const opts = timeoutMsOrOpts && typeof timeoutMsOrOpts === 'object' ? timeoutMsOrOpts : { timeoutMs: timeoutMsOrOpts } + return this.detect().then(() => { + if (this.platform === 'windows' && this.gitBashPath) { + const script = String(command) + return this._execRaw(`"${this.gitBashPath}" -s`, { ...opts, timeoutMs: opts.timeoutMs || this.config.commandTimeoutMs }, (stream) => { + try { stream.end(script) } catch {} + }) + } + return this._execRaw(command, opts) + }) + } + + /** Raw exec (no detection / no wrapper). Optional stdinWriter(stream) feeds + * the remote process stdin (used by the Git Bash `-s` mode). */ + _execRaw(command, opts, stdinWriter) { + const timeoutMs = (opts && opts.timeoutMs) || this.config.commandTimeoutMs + return this.connect().then( + (client) => + new Promise((resolve, reject) => { + let retried = false + const runOn = (c) => { + const execOpts = {} + if (opts && opts.pty) execOpts.pty = true + if (opts && opts.env && typeof opts.env === 'object') execOpts.env = opts.env + c.exec(command, execOpts, (err, stream) => { + if (err) { + // Channel-open failure — or a session termination — usually + // means the pooled connection died server-side (idle timeout / + // network reset) while keepalive hadn't noticed. Drop it and + // retry ONCE on a fresh connection. + if (!retried && /channel open failure|open failed|unexpected .* session termination|session termination|disconnect/i.test(String((err && err.message) || err))) { + retried = true + this.invalidate() + return this.connect().then( + (fresh) => runOn(fresh), + (e2) => reject(new Error('ssh exec failed (reconnect): ' + ((e2 && e2.message) || e2))), + ) + } + return reject(new Error('ssh exec failed: ' + ((err && err.message) || err))) + } + let stdout = '' + let stderr = '' + let settled = false + let exitCode = null + let exitSignal = null + const hardCap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024) + const settle = () => { + if (settled) return + settled = true + clearTimeout(timer) + resolve({ + code: exitCode, + signal: exitSignal, + stdout: truncate(stdout, this.config.maxOutputChars), + stderr: truncate(stderr, this.config.maxOutputChars), + }) + } + const timer = setTimeout(() => { + if (settled) return + exitCode = -1 + exitSignal = 'TIMEOUT' + // Kill the remote command (SIGTERM) rather than just dropping the + // channel, so a runaway process cannot keep running and holding + // the SSH connection after we've given up on its output. + try { + if (typeof stream.signal === 'function') stream.signal('SIGTERM') + } catch {} + const hardClose = setTimeout(() => { + try { stream.close() } catch {} + }, 800) + if (typeof hardClose.unref === 'function') hardClose.unref() + settle() + }, timeoutMs) + stream.on('close', (code, signal) => { + if (settled) return + exitCode = code + exitSignal = signal + settle() + }) + stream.on('data', (d) => { + if (stdout.length < hardCap) stdout += d + }) + stream.stderr.on('data', (d) => { + if (stderr.length < hardCap) stderr += d + }) + stream.on('error', (e) => { + if (settled) return + settled = true + clearTimeout(timer) + reject(new Error('ssh stream error: ' + ((e && e.message) || e))) + }) + if (stdinWriter) { + try { stdinWriter(stream) } catch {} + } + }) + } + runOn(client) + }), + ) + } + + /** Resolve a promisified SFTP client. All paths normalized via toSftpPath(). */ + sftp() { + return this.connect().then( + (client) => + new Promise((resolve, reject) => { + let retried = false + const runOn = (c) => { + c.sftp((err, sftp) => { + if (err) { + // Same dead-connection recovery as exec(): a channel open + // failure — or a session termination (remote closed the + // SFTP subchannel, e.g. transient network blip / sshd idle + // drop) — means the pooled connection is stale: drop it and + // retry ONCE on a fresh connection. + if (!retried && /channel open failure|open failed|unexpected sftp session termination|session termination|disconnect/i.test(String((err && err.message) || err))) { + retried = true + this.invalidate() + return this.connect().then( + (fresh) => runOn(fresh), + (e2) => reject(new Error('ssh sftp failed (reconnect): ' + ((e2 && e2.message) || e2))), + ) + } + return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err))) + } + const withTimeout = (fn) => (...args) => + new Promise((r2, j2) => { + const timer = setTimeout(() => j2(new Error('sftp operation timed out')), this.config.commandTimeoutMs) + const done = (e, v) => { + clearTimeout(timer) + e ? j2(e) : r2(v) + } + try { fn(...args, done) } catch (e) { clearTimeout(timer); j2(e) } + }) + const P = (p) => toSftpPath(p) + resolve({ + readdir: (dir) => withTimeout((d, cb) => sftp.readdir(d, cb))(P(dir)), + stat: (p) => withTimeout((d, cb) => sftp.stat(d, cb))(P(p)), + lstat: (p) => withTimeout((d, cb) => sftp.lstat(d, cb))(P(p)), + mkdir: (dir) => withTimeout((d, cb) => sftp.mkdir(d, cb))(P(dir)), + rmdir: (dir) => withTimeout((d, cb) => sftp.rmdir(d, cb))(P(dir)), + unlink: (p) => withTimeout((d, cb) => sftp.unlink(d, cb))(P(p)), + rename: (p, d) => withTimeout((a, b, cb) => sftp.rename(a, b, cb))(P(p), P(d)), + realpath: (p) => withTimeout((d, cb) => sftp.realpath(d, cb))(P(p)), + readFile: (p) => withTimeout((d, cb) => sftp.readFile(d, cb))(P(p)), + writeFile: (p, data) => withTimeout((d, data2, cb) => sftp.writeFile(d, data2, cb))(P(p), data), + fastGet: (p, lp) => withTimeout((d, l, cb) => sftp.fastGet(d, l, cb))(P(p), lp), + fastPut: (lp, p) => withTimeout((l, d, cb) => sftp.fastPut(l, d, cb))(lp, P(p)), + // Range read for oversized previews: never downloads the whole file. + readPartial: (p, offset, length) => withTimeout((d, off, len, cb) => { + sftp.open(d, 'r', (e, handle) => { + if (e) return cb(e) + const buf = Buffer.alloc(len) + sftp.read(handle, buf, 0, len, off, (e2, bytesRead, data) => { + sftp.close(handle, () => {}) + if (e2) return cb(e2) + cb(null, Buffer.isBuffer(data) ? data.subarray(0, bytesRead) : buf.subarray(0, bytesRead)) + }) + }) + })(P(p), offset, length), + }) + }) + } + runOn(client) + }), + ) + } + + /** + * Drop the cached client and force a fresh connection on the next call. + * Called when a channel open fails (e.g. "Channel open failure: open + * failed") — the pooled SSH connection is usually dead server-side while + * keepalive has not yet noticed, and reusing it keeps failing. The epoch + * bump orphans any in-flight connect; the client is ended so ssh2 frees + * its sockets. + */ + invalidate() { + this.epoch++ + const client = this.client + this.client = null + const pending = this.connecting + this.connecting = null + if (pending && typeof pending.catch === 'function') { + try { pending.catch(() => {}) } catch {} + } + if (this.proxyPool) { + try { this.proxyPool.close() } catch {} + this.proxyPool = null + } + if (client) { + try { client.end() } catch {} + } + } + + close() { + this.epoch++ + const client = this.client + this.client = null + const pending = this.connecting + this.connecting = null + if (pending && typeof pending.catch === 'function') { + try { pending.catch(() => {}) } catch {} + } + if (this.proxyPool) { + try { this.proxyPool.close() } catch {} + this.proxyPool = null + } + if (client) { + try { + client.end() + } catch {} + } + if (this.onCloseHook) { + try { this.onCloseHook() } catch {} + } + } +} diff --git a/lib/remote-fs.js b/lib/remote-fs.js new file mode 100644 index 0000000..b017a31 --- /dev/null +++ b/lib/remote-fs.js @@ -0,0 +1,143 @@ +// Shared remote SFTP helpers used by both rw_* tools and the sidebar HTTP routes. +import { joinRemotePath, normalizeRemotePath } from './paths.js' + +/** Structured listing: name + type + size + mtime + mode (SFTP protocol-level). */ +export async function listDirStructured(sftp, p) { + const target = normalizeRemotePath(p || '/') + let list + try { + list = await sftp.readdir(target) + } catch (err) { + const msg = String((err && err.message) || err) + if (/no such file|not found|does not exist|ENOENT/i.test(msg)) { + return { path: target, items: [], missing: true } + } + throw new Error('browse failed: ' + msg) + } + const items = [] + const symIdx = [] + for (const e of list) { + const name = String(e.filename) + if (name === '.' || name === '..' || !name) continue + const a = e.attrs || {} + let type + if (a.isSymbolicLink && a.isSymbolicLink()) { + type = 'symlink' + symIdx.push(items.length) + } else if (a.isDirectory && a.isDirectory()) { + type = 'dir' + } else { + type = 'file' + } + items.push({ + type, + name, + size: typeof a.size === 'number' ? a.size : 0, + mtime: typeof a.mtime === 'number' ? a.mtime : 0, + mode: typeof a.mode === 'number' ? a.mode.toString(8) : '', + }) + } + if (symIdx.length) { + await Promise.all(symIdx.map(async (i) => { + const full = joinRemotePath(target, items[i].name) + try { + const st = await sftp.lstat(full) + items[i].type = st && st.isDirectory && st.isDirectory() ? 'dir' : 'file' + } catch { /* degrade to file */ } + })) + } + return { path: target, items } +} + +/** Windows drive letters as display-form dir entries ("This PC" root view). */ +export async function listRemoteDrives(sshPool) { + let letters = [] + try { + const res = await sshPool.exec('cmd /c fsutil fsinfo drives', { timeoutMs: Math.min(sshPool.config.commandTimeoutMs || 8000, 8000) }) + letters = [...String(res.stdout || '').matchAll(/([a-zA-Z]):\\?/g)].map((mm) => mm[1].toUpperCase()) + } catch {} + if (!letters.length) { + try { + const res = await sshPool.exec('ls -d /[a-z] 2>/dev/null', { timeoutMs: Math.min(sshPool.config.commandTimeoutMs || 8000, 8000) }) + letters = String(res.stdout || '') + .split(/\s+/) + .map((s) => s.replace(/^\/+|\/+$/g, '')) + .filter((s) => /^[a-zA-Z]$/.test(s)) + .map((s) => s.toUpperCase()) + } catch {} + } + return [...new Set(letters)].sort().map((l) => ({ name: l + ':\\', path: l + ':\\', type: 'dir', drive: true, size: 0, mtime: 0 })) +} + +/** Recursive remote delete (bounded): unlink files bottom-up, then rmdir. */ +export async function removeRemoteTree(sftp, p, maxFiles = 2000) { + let removed = 0 + const walk = async (dir) => { + if (removed >= maxFiles) return + let entries = [] + try { entries = (await sftp.readdir(dir)) || [] } catch { return } + for (const e of entries) { + if (removed >= maxFiles) return + const name = String(e.filename) + if (name === '.' || name === '..') continue + const fp = joinRemotePath(dir, name) + const isDir = !!(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()) + if (isDir) await walk(fp) + else { + try { await sftp.unlink(fp); removed++ } catch {} + } + } + try { await sftp.rmdir(dir); removed++ } catch { /* already gone or non-empty */ } + } + await walk(p) + return removed +} + +function looksBinary(buf) { + const n = Math.min(buf.length, 8192) + for (let i = 0; i < n; i++) if (buf[i] === 0) return true + return false +} + +/** + * Read a remote file for the sidebar/editor. Oversized files use a range read + * (`readPartial`) so we never `fastGet` the whole blob onto disk. + */ +export async function previewRemoteFile(sftp, target, opts = {}) { + const { + maxBytes = 256 * 1024, + encoding = 'utf-8', + decodeBuf = (buf) => buf.toString('utf8'), + } = opts + const cap = Math.min(Math.max(Number(maxBytes) || 256 * 1024, 1024), 2 * 1024 * 1024) + const st = await sftp.stat(target) + const mtime = st && typeof st.mtime === 'number' ? st.mtime : 0 + const size = st && typeof st.size === 'number' ? st.size : 0 + let buf + let truncated = false + if (size > cap) { + truncated = true + if (typeof sftp.readPartial === 'function') { + buf = await sftp.readPartial(target, 0, cap) + } else { + const full = await sftp.readFile(target) + buf = full.subarray(0, cap) + } + } else { + buf = await sftp.readFile(target) + } + if (looksBinary(buf)) { + return { + ok: true, binary: true, size, mtime, + head: buf.slice(0, Math.min(buf.length, 4096)).toString('base64'), + truncated, + } + } + let content = decodeBuf(buf, encoding).replace(/\r\n/g, '\n') + if (!truncated && content.length > cap) { + truncated = true + content = content.slice(0, cap) + } + if (truncated) content += `\n…[truncated: ${Math.max(0, size - cap)} more bytes]` + return { ok: true, binary: false, content, truncated, size, mtime } +} diff --git a/lib/routes-fs.js b/lib/routes-fs.js new file mode 100644 index 0000000..da47b00 --- /dev/null +++ b/lib/routes-fs.js @@ -0,0 +1,221 @@ +// Sidebar file routes: /ls /read /write /fs. +// +// Machine-scoped callers (workspace picker, settings) omit sessionId and use +// the active-machine pool. Session-scoped callers (explorer / file tab) MUST +// pass sessionId (or local=mirror cwd); those requests resolve through the +// same mirror binding as rw_* tools and never fall back to the active machine. +import { mkdirSync } from 'node:fs' +import path from 'node:path' +import { + normalizeRemotePath, joinRemotePath, remoteDirname, mkdirRemoteDirs, toDisplayPath, +} from './paths.js' +import { requestSessionHint } from './binding.js' +import { listDirStructured, listRemoteDrives, removeRemoteTree, previewRemoteFile } from './remote-fs.js' + +export function createFsRoutes({ + sendJson, + readBody, + resolveRequestBinding, + decodeBuf, + encodeText, + audit, + config, + mirrorDirFor, +}) { + const bindOrReply = async (req, res, body = {}) => { + try { + return await resolveRequestBinding(req, body) + } catch (err) { + sendJson(res, (err && err.httpStatus) || 500, { ok: false, error: String((err && err.message) || err) }) + return null + } + } + + return [ + { + kind: 'exact', + path: '/dsh-remote/ls', + handler: async (req, res) => { + try { + const hint = requestSessionHint(req) + const b = await bindOrReply(req, res, hint) + if (!b) return + await b.pool.detect() + const q = new URL(req.url, 'http://localhost').searchParams + const raw = q.get('path') ? decodeURIComponent(q.get('path')) : (b.ws || '') + const canon = normalizeRemotePath(raw) + const win = b.pool.platform === 'windows' + if (canon === '/' || raw === '' || raw === '/') { + if (win || b.pool.platform !== 'posix') { + const drives = await listRemoteDrives(b.pool) + if (drives.length) return sendJson(res, 200, { path: '', platform: 'windows', items: drives, bound: b.bound }) + } + if (win) return sendJson(res, 200, { path: '', platform: 'windows', items: [], bound: b.bound }) + } + const sftp = await b.pool.sftp() + const out = await listDirStructured(sftp, canon) + const items = out.items.map((it) => ({ + ...it, + path: toDisplayPath(joinRemotePath(canon, it.name), b.pool.platform), + })) + return sendJson(res, 200, { + path: toDisplayPath(out.path, b.pool.platform), + platform: win ? 'windows' : 'posix', + items, + missing: !!out.missing, + bound: b.bound, + }) + } catch (err) { + return sendJson(res, 500, { error: String((err && err.message) || err) }) + } + }, + }, + { + kind: 'exact', + path: '/dsh-remote/read', + handler: async (req, res) => { + if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' }) + try { + const q = new URL(req.url, 'http://localhost').searchParams + let p = q.get('path') ? decodeURIComponent(q.get('path')) : '' + let encoding = '' + let maxBytes = 256 * 1024 + let body = {} + if (req.method === 'POST') { + body = JSON.parse((await readBody(req)) || '{}') + if (!p) p = String(body.path || '') + if (body.encoding) encoding = String(body.encoding) + if (body.maxBytes) maxBytes = Number(body.maxBytes) + } + if (q.get('maxBytes')) maxBytes = Number(q.get('maxBytes')) + if (!p) return sendJson(res, 400, { ok: false, error: 'path is required' }) + const b = await bindOrReply(req, res, body) + if (!b) return + const target = normalizeRemotePath(p) + let sftp + try { + sftp = await b.pool.sftp() + } catch (err) { + return sendJson(res, 500, { ok: false, error: 'sftp unavailable: ' + ((err && err.message) || err) }) + } + try { + const preview = await previewRemoteFile(sftp, target, { + maxBytes, + encoding: encoding || config.encoding, + decodeBuf, + }) + return sendJson(res, 200, preview) + } catch (err) { + return sendJson(res, 500, { ok: false, error: 'read failed: ' + ((err && err.message) || err) }) + } + } catch (err) { + return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) }) + } + }, + }, + { + kind: 'exact', + path: '/dsh-remote/write', + handler: async (req, res) => { + if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' }) + try { + const body = JSON.parse((await readBody(req)) || '{}') + const p = normalizeRemotePath(String(body.path || '')) + if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path is required' }) + const b = await bindOrReply(req, res, body) + if (!b) return + const sftp = await b.pool.sftp() + let st = null + try { st = await sftp.stat(p) } catch { /* new file */ } + if (body.expectedMtime != null && st && st.mtime !== Number(body.expectedMtime)) { + return sendJson(res, 409, { ok: false, error: `远端文件已变化(mtime ${st.mtime} ≠ ${body.expectedMtime}),已放弃保存——请重新读取后再编辑` }) + } + const buf = encodeText(String(body.content ?? ''), body.encoding || config.encoding) + if (!st) await mkdirRemoteDirs(sftp, remoteDirname(p)) + await sftp.writeFile(p, buf) + audit('write', `write ${p}`, 0, b) + const st2 = await sftp.stat(p).catch(() => null) + return sendJson(res, 200, { ok: true, bytes: buf.byteLength, mtime: st2 ? st2.mtime : Math.floor(Date.now() / 1000) }) + } catch (err) { + return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) }) + } + }, + }, + { + kind: 'exact', + path: '/dsh-remote/fs', + handler: async (req, res) => { + if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' }) + try { + const body = JSON.parse((await readBody(req)) || '{}') + const op = String(body.op || '') + const b = await bindOrReply(req, res, body) + if (!b) return + const sftp = await b.pool.sftp() + if (op === 'mkdir') { + const p = normalizeRemotePath(String(body.path || '')) + if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' }) + await mkdirRemoteDirs(sftp, p) + audit('mkdir', `mkdir ${p}`, 0, b) + return sendJson(res, 200, { ok: true }) + } + if (op === 'rename') { + const p = normalizeRemotePath(String(body.path || '')) + const d = normalizeRemotePath(String(body.dest || '')) + if (!p || !d) return sendJson(res, 400, { ok: false, error: 'path and dest required' }) + await sftp.rename(p, d) + audit('move', `move ${p} → ${d}`, 0, b) + return sendJson(res, 200, { ok: true }) + } + if (op === 'remove') { + const p = normalizeRemotePath(String(body.path || '')) + if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' }) + const st = await sftp.stat(p).catch(() => null) + if (!st) return sendJson(res, 404, { ok: false, error: 'not found' }) + if (st.isDirectory && st.isDirectory()) { + await removeRemoteTree(sftp, p) + } else { + await sftp.unlink(p) + } + audit('remove', `remove ${p}`, 0, b) + return sendJson(res, 200, { ok: true }) + } + if (op === 'write' || op === 'append') { + const p = normalizeRemotePath(String(body.path || '')) + if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' }) + let content = '' + if (op === 'append') { + try { content = decodeBuf(await sftp.readFile(p), body.encoding || config.encoding) } catch { /* new */ } + } + const buf = encodeText(content + String(body.content ?? ''), body.encoding || config.encoding) + if (op === 'write' && !(await sftp.stat(p).catch(() => null))) await mkdirRemoteDirs(sftp, remoteDirname(p)) + await sftp.writeFile(p, buf) + audit(op, `${op} ${p}`, 0, b) + return sendJson(res, 200, { ok: true, bytes: buf.byteLength }) + } + if (op === 'download') { + const p = normalizeRemotePath(String(body.path || '')) + const ws = b.ws + if (!p || p === '/') return sendJson(res, 400, { ok: false, error: 'path required' }) + if (!ws) return sendJson(res, 400, { ok: false, error: 'no remote workspace set' }) + const st = await sftp.stat(p).catch(() => null) + if (!st) return sendJson(res, 404, { ok: false, error: 'not found' }) + if (config.maxFileBytes > 0 && st.size > config.maxFileBytes) { + return sendJson(res, 400, { ok: false, error: `file is ${st.size} bytes (over ${config.maxFileBytes} cap)` }) + } + const base = b.mirrorDir || mirrorDirFor(ws, b.host, b.username, b.port) + const rel = p.startsWith(ws) ? p.slice(ws.length).replace(/^\/+/, '') : p.slice(1) + const local = path.join(base, rel) + mkdirSync(path.dirname(local), { recursive: true }) + await sftp.fastGet(p, local) + audit('download', `download ${p}`, 0, b) + return sendJson(res, 200, { ok: true, bytes: st.size, local }) + } + return sendJson(res, 400, { ok: false, error: 'unknown op' }) + } catch (err) { + return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) }) + } + }, + }, + ] +} diff --git a/lib/search.js b/lib/search.js index a99ae9f..876b92d 100644 --- a/lib/search.js +++ b/lib/search.js @@ -2,7 +2,7 @@ // Replaces `find … -exec grep` (which needs a POSIX shell and dies on // Windows remotes). Walks the remote tree over SFTP readdir/stat/readFile with // the same bounded-parallelism + ignore rules as the mirror sync. -import { joinRemotePath } from './paths.js' +import { joinRemotePath, shq } from './paths.js' function mapLimit(items, limit, fn) { const limitN = Math.max(1, Math.min(Math.floor(limit) || 1, items.length)) @@ -123,5 +123,63 @@ export async function searchTree(sftp, rootDir, opts = {}) { } await walk(rootDir, maxDepth) + if (scanned >= maxFiles) truncated = true return { matches, scanned, truncated } } + +function parseGrepLines(stdout, maxMatches) { + const matches = [] + let truncated = false + for (const line of String(stdout || '').split('\n')) { + if (!line) continue + const m = line.match(/^(.*?):(\d+):(.*)$/) + if (!m) continue + if (matches.length >= maxMatches) { truncated = true; break } + matches.push({ path: m[1], line: Number(m[2]), text: m[3] }) + } + return { matches, truncated } +} + +/** + * POSIX fast path: `rg` then `grep -R`. Returns null when the remote has + * neither, so the caller can fall back to an SFTP walk (Windows / no grep). + */ +export async function searchViaShell(pool, rootDir, opts = {}) { + const { + pattern, + ignoreCase = true, + glob, + contextLines = 0, + maxMatches = 500, + } = opts + const ic = ignoreCase ? ' -i' : '' + const globFlag = glob ? ` --glob ${shq(glob)}` : '' + const ctx = contextLines > 0 ? ` -C ${Number(contextLines)}` : '' + const rg = `rg -n --no-heading -I${ic}${globFlag}${ctx} --max-count ${maxMatches} -- ${shq(pattern)} ${shq(rootDir)}` + const grepGlob = glob ? ` --include=${shq(glob)}` : '' + const grep = `grep -R -n -I -E${ic}${grepGlob}${ctx} -- ${shq(pattern)} ${shq(rootDir)}` + const script = `if command -v rg >/dev/null 2>&1; then ${rg}; elif command -v grep >/dev/null 2>&1; then ${grep}; else exit 127; fi` + const res = await pool.exec(script, { timeoutMs: opts.timeoutMs }) + // 0 = hits, 1 = no hits; other codes mean the tools are missing / failed. + if (res.signal === 'TIMEOUT') throw new Error('search timed out') + if (res.code !== 0 && res.code !== 1) throw new Error(res.stderr || `search exit ${res.code}`) + const parsed = parseGrepLines(res.stdout, maxMatches) + return { + matches: parsed.matches, + scanned: parsed.matches.length, + truncated: parsed.truncated || /truncated|max-count/i.test(String(res.stderr || '')), + via: /rg/.test(String(res.stderr || '')) ? 'rg' : 'grep', + } +} + +/** Prefer a POSIX grep/rg, fall back to the portable SFTP walk. */ +export async function searchRemote(pool, rootDir, opts = {}) { + if (pool && pool.platform !== 'windows') { + try { + const fast = await searchViaShell(pool, rootDir, opts) + if (fast && Array.isArray(fast.matches)) return fast + } catch { /* missing rg/grep or Windows-like remote → SFTP walk */ } + } + const sftp = await pool.sftp() + return searchTree(sftp, rootDir, opts) +} diff --git a/lib/sync.js b/lib/sync.js index 9c85f37..fd918d2 100644 --- a/lib/sync.js +++ b/lib/sync.js @@ -43,15 +43,15 @@ const remoteKey = (st) => (st ? keyOf(st.size, st.mtime) : null) /** Recursively pull remote → local mirror (three-way, ignore-aware). */ export async function syncTree(sftp, remoteDir, localDir, opts = {}) { const { - maxDepth = 5, - maxFiles = 500, + maxDepth = 8, + maxFiles = 2000, maxFileBytes = 0, isIgnored = () => false, dryRun = false, force = false, state = {}, } = opts - const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, conflicts: [], staleRemote: 0, touched: [] } + const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, conflicts: [], staleRemote: 0, touched: [], truncated: false } const nextState = { ...state } const walk = async (rDir, lDir, depth) => { @@ -62,7 +62,10 @@ export async function syncTree(sftp, remoteDir, localDir, opts = {}) { if (name === '.' || name === '..') continue const isDir = !!(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()) if (!isDir) continue - if (depth <= 0 || stats.files >= maxFiles) continue + if (depth <= 0 || stats.files >= maxFiles) { + stats.truncated = true + continue + } const rp = joinRemotePath(rDir, name) const lp = path.join(lDir, name) const rel = relPathUnder(remoteDir, rp) || name @@ -72,14 +75,14 @@ export async function syncTree(sftp, remoteDir, localDir, opts = {}) { stats.dirs++ if (stats.files >= maxFiles) break } - if (stats.files >= maxFiles) return + if (stats.files >= maxFiles) { stats.truncated = true; return } const files = entries.filter( (e) => !(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()) && String(e.filename) !== '.' && String(e.filename) !== '..', ) await mapLimit(files, 4, async (e) => { - if (stats.files >= maxFiles) return + if (stats.files >= maxFiles) { stats.truncated = true; return } const name = String(e.filename) const rp = joinRemotePath(rDir, name) const lp = path.join(lDir, name) @@ -122,6 +125,7 @@ export async function syncTree(sftp, remoteDir, localDir, opts = {}) { } await walk(remoteDir, localDir, maxDepth) + if (stats.files >= maxFiles) stats.truncated = true // Informational: remote entries in the snapshot that no longer exist remotely. for (const rel of Object.keys(state)) { if (state[rel] && !nextState[rel]) stats.staleRemote++ @@ -132,14 +136,14 @@ export async function syncTree(sftp, remoteDir, localDir, opts = {}) { /** Recursively push local mirror → remote (three-way, ignore-aware). */ export async function pushTree(sftp, localDir, remoteDir, opts = {}) { const { - maxFiles = 500, + maxFiles = 2000, maxFileBytes = 0, isIgnored = () => false, dryRun = false, force = false, state = {}, } = opts - const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, conflicts: [], staleLocal: 0, pushed: [] } + const stats = { files: 0, dirs: 0, skippedUnchanged: 0, skippedLarge: 0, conflicts: [], staleLocal: 0, pushed: [], truncated: false } const nextState = { ...state } const walk = async (lDir, rDir) => { @@ -157,11 +161,11 @@ export async function pushTree(sftp, localDir, remoteDir, opts = {}) { await walk(path.join(lDir, e.name), rp) if (stats.files >= maxFiles) break } - if (stats.files >= maxFiles) return + if (stats.files >= maxFiles) { stats.truncated = true; return } const files = entries.filter((e) => !e.isDirectory()) await mapLimit(files, 4, async (e) => { - if (stats.files >= maxFiles) return + if (stats.files >= maxFiles) { stats.truncated = true; return } const lp = path.join(lDir, e.name) const rp = joinRemotePath(rDir, e.name) const rel = relPathUnder(remoteDir, rp) || e.name @@ -208,6 +212,7 @@ export async function pushTree(sftp, localDir, remoteDir, opts = {}) { } await walk(localDir, remoteDir) + if (stats.files >= maxFiles) stats.truncated = true for (const rel of Object.keys(state)) { if (state[rel] && !nextState[rel]) stats.staleLocal++ } diff --git a/package.json b/package.json index dc6bb11..8857d6b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dsh-remote", - "version": "0.8.18", - "description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with 21 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.", + "version": "0.8.19", + "description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with 20 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.", "license": "MIT", "author": "flymysql ", "homepage": "https://github.com/flymysql/dsh-remote", diff --git a/test/desktop-fs.test.js b/test/desktop-fs.test.js new file mode 100644 index 0000000..dd2ea90 --- /dev/null +++ b/test/desktop-fs.test.js @@ -0,0 +1,100 @@ +// Desktop native file-tab contract: session-scoped addresses + save/reload/409. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { Readable } from 'node:stream' +import { readFileSync } from 'node:fs' +import { createFsRoutes } from '../lib/routes-fs.js' +import { MemFs, makeSftp, seed } from './helpers.js' + +const src = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8') + +function sendJson(res, status, body) { + res.statusCode = status + res.payload = JSON.stringify(body) +} +function readBody(req) { + return new Promise((resolve) => { + const chunks = [] + req.on('data', (c) => chunks.push(c)) + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) + }) +} + +async function call(route, { method = 'POST', url, body }) { + const payload = body === undefined ? '' : JSON.stringify(body) + const req = Readable.from([Buffer.from(payload)]) + req.method = method + req.url = url || route.path + const res = { statusCode: 0, payload: '', setHeader() {}, end(chunk) { this.payload += chunk == null ? '' : String(chunk) } } + await route.handler(req, res) + return { status: res.statusCode, json: JSON.parse(res.payload || 'null') } +} + +function routesFor(sftp, binding) { + const pool = { + platform: 'posix', + config: { commandTimeoutMs: 5000, encoding: 'utf-8' }, + detect: async () => {}, + sftp: async () => sftp, + } + const list = createFsRoutes({ + sendJson, + readBody, + resolveRequestBinding: async () => ({ ...binding, pool }), + decodeBuf: (buf) => buf.toString('utf8'), + encodeText: (s) => Buffer.from(String(s), 'utf8'), + audit: () => {}, + config: { encoding: 'utf-8', maxFileBytes: 0 }, + mirrorDirFor: () => '/tmp/mirror', + }) + return Object.fromEntries(list.map((r) => [r.path, r])) +} + +test('native resource addresses are session-scoped', () => { + assert.match(src, /dsh-resource:\/\/dsh-remote\/' \+ encodeURIComponent\(sessionId\)/) + assert.match(src, /withSessionBody\(\{ path, content: draft/) + assert.match(src, /expectedMtime: data && data.mtime/) + assert.match(src, /withSessionQuery\('\/dsh-remote\/ls\?path='/) + assert.match(src, /withSessionQuery\('\/dsh-remote\/status'/) + assert.match(src, /scope: \{ sessionId: props\.sessionId \}/) +}) + +test('sidebar write 409 on mtime mismatch, then save after re-read (Desktop edit path)', async () => { + const fs = new MemFs() + seed(fs, { 'proj/a.txt': 'v1' }) + const sftp = makeSftp(fs) + const binding = { ws: '/proj', host: '10.0.0.1', username: 'dev', port: 22, bound: true, mirrorDir: '/tmp/m' } + const routes = routesFor(sftp, binding) + + const first = await call(routes['/dsh-remote/read'], { body: { path: '/proj/a.txt', sessionId: 's1' } }) + assert.equal(first.status, 200) + assert.equal(first.json.content, 'v1') + const mtime = first.json.mtime + + fs.writeFileSync('/proj/a.txt', 'v2-remote') + const conflict = await call(routes['/dsh-remote/write'], { + body: { path: '/proj/a.txt', content: 'v1-edited', expectedMtime: mtime, sessionId: 's1' }, + }) + assert.equal(conflict.status, 409) + assert.equal(fs.readFileSync('/proj/a.txt').toString(), 'v2-remote') + + const reread = await call(routes['/dsh-remote/read'], { body: { path: '/proj/a.txt', sessionId: 's1' } }) + const saved = await call(routes['/dsh-remote/write'], { + body: { path: '/proj/a.txt', content: 'merged', expectedMtime: reread.json.mtime, sessionId: 's1' }, + }) + assert.equal(saved.status, 200) + assert.equal(fs.readFileSync('/proj/a.txt').toString(), 'merged') +}) + +test('sidebar /ls returns session-bound listing from the resolved pool', async () => { + const fs = new MemFs() + seed(fs, { 'proj/src/a.ts': 'x' }) + fs.mkdirSync('/proj') + fs.mkdirSync('/proj/src') + const sftp = makeSftp(fs) + const routes = routesFor(sftp, { ws: '/proj', host: '10.0.0.1', username: 'dev', port: 22, bound: true }) + const r = await call(routes['/dsh-remote/ls'], { method: 'GET', url: '/dsh-remote/ls?path=/proj&sessionId=s1' }) + assert.equal(r.status, 200) + assert.equal(r.json.bound, true) + assert.ok(r.json.items.some((it) => it.name === 'src')) +}) diff --git a/test/helpers.js b/test/helpers.js index c4878ae..bb49c38 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -60,6 +60,10 @@ export function makeSftp(fs) { writeFile: (p, buf) => { fs.writeFileSync(p, buf); return Promise.resolve() }, fastGet: (p, lp) => { fs.writeFileSync('@local:' + lp, fs.readFileSync(p)); return Promise.resolve() }, fastPut: (lp, p) => { fs.writeFileSync(p, fs.readFileSync('@local:' + lp)); return Promise.resolve() }, + readPartial: (p, offset, length) => { + const buf = fs.readFileSync(p) + return Promise.resolve(buf.subarray(offset, offset + length)) + }, } } diff --git a/test/remote-fs.test.js b/test/remote-fs.test.js new file mode 100644 index 0000000..e2644c1 --- /dev/null +++ b/test/remote-fs.test.js @@ -0,0 +1,35 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { previewRemoteFile } from '../lib/remote-fs.js' +import { MemFs, makeSftp, seed } from './helpers.js' + +test('previewRemoteFile uses readPartial for oversized files (never full readFile)', async () => { + const fs = new MemFs() + seed(fs, { 'proj/big.bin': 'x'.repeat(20000) }) + const sftp = makeSftp(fs) + let readFileCalls = 0 + let partialCalls = 0 + const origRead = sftp.readFile + sftp.readFile = (...args) => { readFileCalls++; return origRead(...args) } + const origPartial = sftp.readPartial + sftp.readPartial = (...args) => { partialCalls++; return origPartial(...args) } + + const r = await previewRemoteFile(sftp, '/proj/big.bin', { maxBytes: 1024 }) + assert.equal(r.ok, true) + assert.equal(r.truncated, true) + assert.equal(r.size, 20000) + assert.ok(r.content.includes('truncated')) + assert.equal(partialCalls, 1) + assert.equal(readFileCalls, 0) +}) + +test('previewRemoteFile reports mtime for optimistic locks', async () => { + const fs = new MemFs() + seed(fs, { 'proj/a.txt': 'hello' }) + const sftp = makeSftp(fs) + const r = await previewRemoteFile(sftp, '/proj/a.txt', { maxBytes: 1024 }) + assert.equal(r.binary, false) + assert.equal(r.content, 'hello') + assert.equal(typeof r.mtime, 'number') + assert.ok(r.mtime > 0) +}) diff --git a/test/search.test.js b/test/search.test.js index 12f3b7c..11c2949 100644 --- a/test/search.test.js +++ b/test/search.test.js @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { searchTree, matchGlob } from '../lib/search.js' +import { searchTree, matchGlob, searchViaShell } from '../lib/search.js' import { MemFs, makeSftp, seed } from './helpers.js' const noIgnore = () => false @@ -64,3 +64,31 @@ test('searchTree honors ignore + glob + contextLines + caps', async () => { const r4 = await searchTree(sftp2, '/proj', { regex: /hit/, maxMatches: 3, isIgnored: noIgnore, maxScanBytes: 1024 }) assert.equal(r4.matches.length, 3) }) + +test('searchViaShell parses rg/grep -n output and honors maxMatches', async () => { + const stdout = '/proj/a.ts:2:TODO fix me\n/proj/b.ts:1:TODO later\n/proj/c.ts:4:TODO more\n' + const pool = { + platform: 'posix', + exec: async () => ({ code: 0, stdout, stderr: '', signal: null }), + } + const r = await searchViaShell(pool, '/proj', { pattern: 'TODO', maxMatches: 2 }) + assert.equal(r.matches.length, 2) + assert.equal(r.truncated, true) + assert.equal(r.matches[0].path, '/proj/a.ts') + assert.equal(r.matches[0].line, 2) +}) + +test('searchRemote falls back to SFTP when shell search is unavailable', async () => { + const { searchRemote } = await import('../lib/search.js') + const fs = new MemFs() + seed(fs, { 'proj/a.txt': 'hit me' }) + const sftp = makeSftp(fs) + const pool = { + platform: 'posix', + exec: async () => ({ code: 127, stdout: '', stderr: 'no rg', signal: null }), + sftp: async () => sftp, + } + const r = await searchRemote(pool, '/proj', { regex: /hit/, pattern: 'hit', maxScanBytes: 1024, isIgnored: () => false }) + assert.equal(r.matches.length, 1) + assert.equal(r.matches[0].path, '/proj/a.txt') +}) diff --git a/test/session-fs.test.js b/test/session-fs.test.js new file mode 100644 index 0000000..224d82f --- /dev/null +++ b/test/session-fs.test.js @@ -0,0 +1,152 @@ +// Sidebar HTTP routes must bind to the session's machine (Desktop multi-machine +// release blocker). rw_* already do this; /ls /read /write /fs used to use the +// active-machine pool instead. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { Readable } from 'node:stream' +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' + +function makeHome() { + const home = mkdtempSync(path.join(tmpdir(), 'dsh-remote-fs-')) + const root = path.join(home, 'remote-workspaces') + mkdirSync(root, { recursive: true }) + const machines = [ + { id: 'm-linux', name: 'linuxbox', host: '127.0.0.11', port: 1, username: 'lucas', password: 'pw-linux' }, + { id: 'm-win', name: 'winbox', host: '127.0.0.22', port: 1, username: 'Administrator', password: 'pw-win' }, + ] + writeFileSync(path.join(root, 'machines.json'), JSON.stringify({ list: machines, currentId: 'm-win' })) + const mirror = (host, user, port, base, remotePath) => { + const dir = path.join(root, `${host}-${user}-${port}`, base) + mkdirSync(dir, { recursive: true }) + writeFileSync(path.join(dir, '.dsh-remote-meta.json'), JSON.stringify({ host, port, username: user, remotePath })) + return dir + } + return { + home, + linuxCwd: mirror('127.0.0.11', 'lucas', 1, 'proj', '/home/lucas/proj'), + winCwd: mirror('127.0.0.22', 'Administrator', 1, 'tool', 'C:\\work\\tool'), + } +} + +function makeCtx(sessions) { + const routes = new Map() + const tools = new Map() + const ctx = { + effect: () => {}, + inject(names, callback) { if (names.every((name) => this.get(name))) callback(this) }, + get: (k) => { + if (k === 'webServer') return { register: (r) => { routes.set(r.path, r); return () => {} } } + if (k === 'sessions') return sessions + return undefined + }, + tools: { register: (t) => tools.set(t.name, t) }, + systemPrompt: { section: () => {} }, + } + return { ctx, routes, tools } +} + +const CONFIG = { + host: '', port: 22, username: '', password: '', privateKeyPath: '', passphrase: '', + workspace: '', shell: '', commandTimeoutMs: 1500, connectTimeoutMs: 1200, + maxOutputChars: 10000, maxFileBytes: 100000, hostKeyMode: 'off', + useAgent: false, keyboardInteractive: false, autoPush: false, auditLog: false, + encoding: 'utf-8', updateMode: 'off', updateCheckIntervalMs: 0, +} + +async function loadPlugin(home, sessions) { + process.env.DSH_HOME = home + const mod = await import(`../lib/index.js?fs=${Math.random()}`) + const { ctx, routes } = makeCtx(sessions) + await mod.apply(ctx, { ...CONFIG }) + return { routes } +} + +async function call(routes, routePath, { method = 'GET', url, body } = {}) { + const route = routes.get(routePath) + assert.ok(route, `route ${routePath} must be registered`) + const payload = body === undefined ? '' : (typeof body === 'string' ? body : JSON.stringify(body)) + const req = Readable.from([Buffer.from(payload)]) + req.method = method + req.url = url || routePath + const res = { + statusCode: 0, + headers: {}, + payload: '', + setHeader(k, v) { this.headers[k] = v }, + end(chunk) { this.payload += chunk == null ? '' : String(chunk) }, + } + let rejected = null + await route.handler(req, res).catch((e) => { rejected = e }) + let json = null + try { json = JSON.parse(res.payload) } catch { /* not JSON */ } + return { status: res.statusCode, json, rejected, raw: res.payload } +} + +test('GET /ls?sessionId hits THIS session host, not the active machine', async () => { + const { home, linuxCwd, winCwd } = makeHome() + const sessions = { + get: (id) => ({ + 'sess-linux': { header: { cwd: linuxCwd } }, + 'sess-win': { header: { cwd: winCwd } }, + }[id]), + } + try { + const { routes } = await loadPlugin(home, sessions) + const linux = await call(routes, '/dsh-remote/ls', { + url: '/dsh-remote/ls?path=' + encodeURIComponent('/home/lucas/proj') + '&sessionId=sess-linux', + }) + const win = await call(routes, '/dsh-remote/ls', { + url: '/dsh-remote/ls?path=' + encodeURIComponent('C:\\work\\tool') + '&sessionId=sess-win', + }) + assert.equal(linux.rejected, null) + assert.equal(win.rejected, null) + const linuxMsg = JSON.stringify(linux.json) + linux.raw + const winMsg = JSON.stringify(win.json) + win.raw + assert.match(linuxMsg, /127\.0\.0\.11/, `linux sidebar must target its host, got: ${linuxMsg}`) + assert.ok(!linuxMsg.includes('127.0.0.22'), `linux sidebar leaked onto the active windows host: ${linuxMsg}`) + assert.match(winMsg, /127\.0\.0\.22/, `windows sidebar must target its host, got: ${winMsg}`) + assert.ok(!winMsg.includes('127.0.0.11'), `windows sidebar leaked onto the linux host: ${winMsg}`) + } finally { + delete process.env.DSH_HOME + rmSync(home, { recursive: true, force: true }) + } +}) + +test('POST /read|/write|/fs with a LOCAL sessionId refuse instead of using the active machine', async () => { + const { home } = makeHome() + const sessions = { get: () => ({ header: { cwd: '/root/some/local/repo' } }) } + try { + const { routes } = await loadPlugin(home, sessions) + const cases = [ + ['/dsh-remote/read', { method: 'POST', body: { path: '/etc/hostname', sessionId: 'local-1' } }], + ['/dsh-remote/write', { method: 'POST', body: { path: '/tmp/x', content: 'x', sessionId: 'local-1' } }], + ['/dsh-remote/fs', { method: 'POST', body: { op: 'mkdir', path: '/tmp/n', sessionId: 'local-1' } }], + ] + for (const [routePath, opts] of cases) { + const r = await call(routes, routePath, opts) + assert.equal(r.rejected, null, `${routePath} must not reject`) + assert.equal(r.status, 403, `${routePath} status: ${r.status} ${r.raw}`) + assert.match(String(r.json && r.json.error), /LOCAL/, `${routePath} gave: ${r.raw}`) + assert.ok(!/127\.0\.0\.(11|22)/.test(r.raw), `${routePath} must not connect: ${r.raw}`) + } + } finally { + delete process.env.DSH_HOME + rmSync(home, { recursive: true, force: true }) + } +}) + +test('picker /ls without sessionId still uses the active-machine pool', async () => { + const { home } = makeHome() + try { + const { routes } = await loadPlugin(home, { get: () => null }) + const r = await call(routes, '/dsh-remote/ls', { url: '/dsh-remote/ls?path=/' }) + // Active machine is m-win (127.0.0.22). No session hint → that host. + const msg = JSON.stringify(r.json) + r.raw + assert.match(msg, /127\.0\.0\.22/) + } finally { + delete process.env.DSH_HOME + rmSync(home, { recursive: true, force: true }) + } +}) diff --git a/test/sync.test.js b/test/sync.test.js index bd35b56..0d1cd00 100644 --- a/test/sync.test.js +++ b/test/sync.test.js @@ -148,3 +148,14 @@ test('pushOneFile: pushes a local edit, never clobbers a remote change', async ( assert.equal(fs.readFileSync('/proj/a.txt').toString(), 'remote-new') rmSync(local, { recursive: true, force: true }) }) + +test('syncTree sets truncated when maxFiles is hit', async () => { + const fs = new MemFs() + seed(fs, { 'proj/a.txt': 'a', 'proj/b.txt': 'b', 'proj/c.txt': 'c' }) + const sftp = makeSftp(fs) + const local = tmpDir() + const r = await syncTree(sftp, '/proj', local, { maxFiles: 1, maxFileBytes: 0, isIgnored: noIgnore, state: {} }) + assert.ok(r.stats.files >= 1) + assert.equal(r.stats.truncated, true) + rmSync(local, { recursive: true, force: true }) +})