@zhangfengshun/dsh-remote-ssh 2.4.13 → 2.4.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  本文件的版本号与 `package.json` 的 `version` 保持一致。每个版本对应一个 Cordis Package 快照(`pkg-N`)。
4
4
 
5
+ ## [2.4.14] — 远程工作区里 agent 的 `write`/`edit` 现在会同步到远端(issue #15)
6
+ ### 新增
7
+ - **模型侧文件工具写回远端**(感谢 @Linhaojing 的桥接面分析与三条修法):远程工作区会话里 agent 的 `write` / `edit` 走的是**进程内 `ctx.fs`**(宿主 base bundle 挂的是本地 `fs-sandbox`),不经过任何 HTTP 路由——因此此前只落**本地镜像**,用户在远端机器上找不到文件,只能人工 `remote_ssh_push`/`scp`;工具返回"已创建"而远端 `ls` 为空,容易被误判为失败。这与 #10(`fileReferences` 是进程内服务)同源不同面。
8
+ - **接法刻意保守**:包装 `ctx.fs` 的 `writeText` / `editText`,**原方法照旧调用**——镜像内容、沙箱围栏(`sandboxPolicy`)、写意图(`createIfAbsent` / `replaceIfVersion`)、`signal` 全部原样透传,我们**不伪造 `FsTarget`、不改 `resolve()` 行为**,只读它的返回值(`FsWriteOutcome.after` / `FsEditOutcome.after` 即写入后的完整内容);
9
+ - 成功后把**这一份内容定向推回远端对应的那个文件**(沿用既有 `remoteWriteFile` 的 base64 写入 + 缓存失效),写前自动 `mkdir -p` 远端父目录;**不调用整镜像 push**(那会用旧镜像覆盖远端其它文件——正是报告者担心的点);
10
+ - **等待同步完成再返回**:工具说"写好了"时远端已有该文件,消除"说写了却找不到"的歧义;
11
+ - **失败只告警,绝不让写操作变失败**:本地写入已成功,推送失败打一条 warn(提示可用 `remote_ssh_push` 补推);
12
+ - 路径识别用「向上找最近的 `.remote-ssh.json`」(有界 8 层),非远程工作区路径直接返回 → **本地会话零影响**;软注入 `ctx.fs`(服务缺失/被替换时退回旧行为并打日志),清理时恢复原方法。
13
+ - **文档补齐**(同 issue 的方案 1):README「原理」与「缓存与一致性」写明模型侧文件工具的落点与 2.4.14 起的同步行为;新增一条已知限制——**agent 的 `read` 仍读本地镜像**(远端被他人改动时 agent 读到旧内容,需 `remote_ssh_sync` 刷新)。
14
+
15
+ ### 测试
16
+ - 新增 `tests/fs-write-bridge.test.mjs`(**33 条断言**):`installFsWriteBridge` 行为(原方法先调用、返回值透传、`expected`/`signal`/`sandboxPolicy` 原样透传、`target` 不被伪造、回调收到 `processPath` 规范路径与 `outcome.after`、原写入抛错时不回调、回调抛错不影响写入结果且只告警、无 `processPath` 时退回 `displayPath`、非法服务安全返回 null、恢复函数生效)、`findMirrorRoot` 真实文件系统测试(嵌套深层识别、非镜像路径、标记缺字段不误判、超 8 层有界)、以及接线断言(软注入、清理恢复、定向推送而非整镜像、`mkdir -p` 父目录)。
17
+
5
18
  ## [2.4.13] — `remote_ssh_push` / `remote_ssh_sync` 成功却报 `returned invalid output`(issue #14)
6
19
  ### 修复
7
20
  - **推送成功却被判为工具输出非法**(感谢 @Linhaojing 的根因定位与复现):`sync` / `push` 共用的 output schema 把 `error` 声明为**必填**且 `additionalProperties: false`,而两条成功路径返回 `{ ok: true, mirrorPath }` / `{ ok: true, remotePath }` —— 成功时既缺 `error` 又带未声明字段,必然被 output 校验拒掉;**失败路径反而合法**,于是症状是「**只有成功会报错**」:模型看到 `Error: tool "remote_ssh_push" returned invalid output: missing required property "value.error"; "value.remotePath" is not a declared property`,但远端文件其实已经写好了,只能靠再跑一次 `ls`/`sha256sum` 确认,很容易误判成需要重试。而这条链路正是远程工作区里 agent `write`/`edit` 落回远端的唯一通道,成功信号不可信影响面不小。
package/README.md CHANGED
@@ -46,7 +46,7 @@
46
46
  **一条命令安装**(无需 token、API Key 或额外配置):
47
47
 
48
48
  ```bash
49
- dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.13
49
+ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.14
50
50
  ```
51
51
 
52
52
  安装后**重启 DSH**。`@zhangfengshun/dsh-remote-ssh` 必须在 bundles 列表中排在 `dsh-better-sidebar` **之后**。
@@ -215,6 +215,8 @@ dsh plugin --profile <name> remove @zhangfengshun/dsh-remote-ssh
215
215
 
216
216
  插件注册 6 个 exact 路由(`/sidebar/api/fs.tree`、`fs.read`、`fs.write`、`fs.search`,以及 better-sidebar 0.19 新增的 `fs.rename`、`fs.remove`),在 better-sidebar 的 prefix 路由之前拦截。会话 cwd 含 `.remote-ssh.json` 时走 SSH,否则走本地 fs。客户端看到的是本地镜像路径,Host 自动转换为远程路径——对客户端完全透明。
217
217
 
218
+ **模型侧的文件工具(agent 的 `write`/`edit`)**走的是**进程内** `ctx.fs`(宿主 base bundle 挂的是本地 `fs-sandbox`),不经过任何 HTTP 路由,因此 2.4.13 及以前只落**本地镜像**——用户在远端机器上找不到文件,只能人工 `remote_ssh_push`。2.4.14 起插件包装 `ctx.fs` 的 `writeText`/`editText`:**原写入照旧**(镜像内容、沙箱围栏、写意图语义全不变),成功后把同一份内容**定向推回远端对应的那个文件**(不是整镜像 tar,避免用旧镜像覆盖远端其它文件),写前自动 `mkdir -p` 远端父目录。推送失败只记一条 warn——本地写入已成功,桥接层不会让写操作变成失败;包装不可用时(服务缺失/被替换)退回旧行为并在日志提示。
219
+
218
220
  远程读取采用**单往返合并读**:一条池化命令同时返回 `size/mtime` 帧与文件内容(文本类扩展名优先 raw 直传,字节长 + U+FFFD 双校验失败自动回退 base64,结果逐字节一致);配合主机侧结果缓存与变更失效(见下节)。
219
221
 
220
222
  Shell wrapper(`~/.dsh/remote-ssh/dsh-remote-shell[.cmd]`)检测工作区 `.remote-ssh.json`,自动 `ssh -tt` 连接远程,使内置「终端」页签透明接入。
@@ -226,6 +228,7 @@ Shell wrapper(`~/.dsh/remote-ssh/dsh-remote-shell[.cmd]`)检测工作区 `.r
226
228
  已知限制:
227
229
 
228
230
  - 集成终端(`ssh -tt`)与远端其它进程改动的文件依赖 TTL + 复验兜底,最多 **5 秒**陈旧;
231
+ - **agent 的 `read` 仍读本地镜像**:2.4.14 起 `write`/`edit` 会同步到远端,但若文件在远端被其它人改动,agent 读到的是镜像里的旧内容(用 `remote_ssh_sync` 重新拉取镜像即可);
229
232
  - `/sidebar/file` 下载池化路径有效上限约 **6.29MB**,更大文件自动退回一次性连接下载(可成功,多一次重连开销);
230
233
  - 二进制内容伪装成文本扩展名时会多一次 base64 回退往返(结果正确)。
231
234
 
package/README_EN.md CHANGED
@@ -46,7 +46,7 @@ A **DSH** plugin like **VSCode Remote-SSH**: connect to remote HPC / servers via
46
46
  **One command** (no token, API key or extra configuration needed):
47
47
 
48
48
  ```bash
49
- dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.13
49
+ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.14
50
50
  ```
51
51
 
52
52
  **Restart DSH** after installation. `@zhangfengshun/dsh-remote-ssh` must come **after** `dsh-better-sidebar` in the bundles list.
@@ -215,6 +215,8 @@ The plugin never patches DSH sources or injects into the profile dependency tree
215
215
 
216
216
  The plugin registers 6 exact routes (`/sidebar/api/fs.tree`, `fs.read`, `fs.write`, `fs.search`, plus `fs.rename` and `fs.remove` added by better-sidebar 0.19) that intercept better-sidebar's prefix route. When the session cwd contains `.remote-ssh.json`, requests go through SSH; otherwise local fs. The client sees local mirror paths — the Host transparently translates them to remote paths.
217
217
 
218
+ **The model-side file tools (the agent's `write`/`edit`) use the in-process `ctx.fs`** (the host base bundle mounts the local `fs-sandbox`), never an HTTP route — so up to 2.4.13 they only landed in the **local mirror**, leaving users unable to find the file on the remote machine without a manual `remote_ssh_push`. Since 2.4.14 the plugin wraps `ctx.fs`'s `writeText`/`editText`: the **original write runs unchanged** (mirror content, sandbox fence and write-intent semantics intact), and on success the same content is pushed to **that one remote file** (not a whole-mirror tar, which could overwrite unrelated remote files), creating the remote parent directory first. A failed push only logs a warning — the local write already succeeded, so the bridge never turns a write into a failure; if the service cannot be wrapped, the old behaviour returns with a log line.
219
+
218
220
  Remote reads use a **single-roundtrip merged read**: one pooled command returns the `size/mtime` frame plus the file content (text extensions prefer raw transfer with byte-length + U+FFFD validation and automatic base64 fallback — results are byte-identical), combined with host-side result caching and change invalidation (see below).
219
221
 
220
222
  A shell wrapper (`~/.dsh/remote-ssh/dsh-remote-shell[.cmd]`) detects the workspace's `.remote-ssh.json` and auto-launches `ssh -tt`, making the built-in **Terminal** tab transparently connect to remote.
@@ -226,6 +228,7 @@ Remote reads and directory listings are cached host-side (read LRU 32 + listing
226
228
  Known limitations:
227
229
 
228
230
  - Files changed from the integrated terminal (`ssh -tt`) or by other remote processes rely on TTL + revalidation and may be stale for up to **5 seconds**;
231
+ - **The agent's `read` still reads the local mirror**: since 2.4.14 `write`/`edit` are mirrored to the remote, but if someone else changes the file remotely the agent reads the older mirror copy (run `remote_ssh_sync` to refresh the mirror);
229
232
  - The pooled `/sidebar/file` download path has an effective limit of ≈**6.29MB**; larger files automatically fall back to a one-shot connection download (succeeds, with one extra reconnect);
230
233
  - Binary content masquerading with a text extension costs one extra base64 fallback round-trip (results are still correct).
231
234
 
package/lib/index.js CHANGED
@@ -1556,6 +1556,96 @@ function fsSearchResult(matches, entries, truncated) {
1556
1556
  };
1557
1557
  }
1558
1558
 
1559
+ // ---------------------------------------------------------------------------
1560
+ // agent 文件工具写回远端(issue #15)
1561
+ //
1562
+ // 远程工作区会话里 agent 的 write / edit 走**进程内** ctx.fs(base bundle 挂的是本地
1563
+ // fs-sandbox:`@deepseek-ai/dsh-base/cordis.patch.yml` 的 `fs-sandbox` 行),因此只落在本地
1564
+ // 镜像 —— 用户在远端机器上找不到文件,只能人工 push/scp。这条链路不经过任何 HTTP 路由,
1565
+ // 与 #10(`fileReferences` 是进程内服务)同源不同面。
1566
+ //
1567
+ // 接法(刻意保守):
1568
+ // - **原方法照旧调用**:镜像内容、沙箱围栏(sandboxPolicy)、写意图(createIfAbsent /
1569
+ // replaceIfVersion)与审计语义全部不变 —— 我们只读它的返回值(`outcome.after` 即写入后的
1570
+ // 完整内容),不伪造 FsTarget、不改 resolve 行为;
1571
+ // - 成功后把**这一份内容定向推回远端对应的那个文件**(不是整镜像 tar —— 那会用旧镜像覆盖
1572
+ // 远端其它文件);
1573
+ // - 推失败只记一条 warn:本地写入已经成功,桥接层绝不让写操作变成失败。
1574
+ // ---------------------------------------------------------------------------
1575
+
1576
+ /** 从文件路径向上找最近的「镜像根」(含 `.remote-ssh.json` 的目录);有界 8 层。 */
1577
+ function findMirrorRoot(filePath) {
1578
+ if (!filePath) return null;
1579
+ let dir = dirname(String(filePath));
1580
+ for (let i = 0; i < 8 && dir; i++) {
1581
+ try {
1582
+ const marker = join(dir, ".remote-ssh.json");
1583
+ if (existsSync(marker)) {
1584
+ const info = JSON.parse(readFileSync(marker, "utf8"));
1585
+ if (info && info.host && info.user && info.remotePath) return { root: dir, info: info };
1586
+ }
1587
+ } catch (e) {}
1588
+ const parent = dirname(dir);
1589
+ if (!parent || parent === dir) break;
1590
+ dir = parent;
1591
+ }
1592
+ return null;
1593
+ }
1594
+
1595
+ /**
1596
+ * 包装 ctx.fs 的 `writeText` / `editText`:原调用照旧,成功后回调 `onWritten(本地绝对路径, 写入后的内容)`。
1597
+ * 返回恢复函数;服务缺失/不可包装时返回 null(插件照常工作,只是不桥接)。
1598
+ */
1599
+ function installFsWriteBridge(svc, hooks) {
1600
+ if (!svc || typeof svc.writeText !== "function") return null;
1601
+ const onWritten = hooks && typeof hooks.onWritten === "function" ? hooks.onWritten : null;
1602
+ if (!onWritten) return null;
1603
+ const log = (hooks && hooks.log) || null;
1604
+ const warn = function (msg) { try { if (log && typeof log.warn === "function") log.warn(msg); } catch (e) {} };
1605
+ const originalWrite = svc.writeText.bind(svc);
1606
+ const originalEdit = typeof svc.editText === "function" ? svc.editText.bind(svc) : null;
1607
+ const originalProcessPath = typeof svc.processPath === "function" ? svc.processPath.bind(svc) : null;
1608
+
1609
+ /** target → 本地绝对路径:优先 processPath(后端执行世界的规范路径),退回 displayPath。 */
1610
+ function pathOf(target) {
1611
+ try { if (originalProcessPath) { const p = originalProcessPath(target); if (p) return String(p); } } catch (e) {}
1612
+ try { return target && target.displayPath ? String(target.displayPath) : ""; } catch (e) { return ""; }
1613
+ }
1614
+ /** 同步到远端;**等待**它完成再返回("写好了"就意味着远端也已有),但失败只告警不抛出。 */
1615
+ async function mirror(target, content) {
1616
+ if (typeof content !== "string") return;
1617
+ const abs = pathOf(target);
1618
+ if (!abs) return;
1619
+ try {
1620
+ await onWritten(abs, content);
1621
+ } catch (e) {
1622
+ warn("[dsh-remote-ssh] 文件同步到远端失败(本地镜像已写入): " + String(e && e.message ? e.message : e));
1623
+ }
1624
+ }
1625
+
1626
+ const patchedWrite = async function (target, content, expected, signal, sandboxPolicy) {
1627
+ const out = await originalWrite(target, content, expected, signal, sandboxPolicy);
1628
+ await mirror(target, out && typeof out.after === "string" ? out.after : content);
1629
+ return out;
1630
+ };
1631
+ const patchedEdit = originalEdit ? async function (target, edit, expected, signal, sandboxPolicy) {
1632
+ const out = await originalEdit(target, edit, expected, signal, sandboxPolicy);
1633
+ await mirror(target, out && typeof out.after === "string" ? out.after : undefined);
1634
+ return out;
1635
+ } : null;
1636
+
1637
+ try {
1638
+ svc.writeText = patchedWrite;
1639
+ if (patchedEdit) svc.editText = patchedEdit;
1640
+ } catch (e) {
1641
+ return null;
1642
+ }
1643
+ return function restoreFsWriteBridge() {
1644
+ try { if (svc.writeText === patchedWrite) svc.writeText = originalWrite; } catch (e) {}
1645
+ try { if (patchedEdit && svc.editText === patchedEdit) svc.editText = originalEdit; } catch (e) {}
1646
+ };
1647
+ }
1648
+
1559
1649
  // ---------------------------------------------------------------------------
1560
1650
  // apply
1561
1651
  // ---------------------------------------------------------------------------
@@ -3473,9 +3563,43 @@ function apply(ctx, config) {
3473
3563
  }
3474
3564
  });
3475
3565
 
3566
+ // ---- agent 文件工具写回远端(issue #15)----
3567
+ // ctx.fs 是进程内服务(agent 的 write/edit 走它),这里在原写入成功之后把同一份内容定向推到
3568
+ // 远端对应文件;非远程工作区路径(找不到 .remote-ssh.json)时什么都不做。
3569
+ async function mirrorWriteToRemote(absPath, content) {
3570
+ if (!absPath || typeof content !== "string") return;
3571
+ const hit = findMirrorRoot(absPath);
3572
+ if (!hit) return; // 本地工作区 / 非镜像路径:不动
3573
+ const p = getProfile(hit.info.profileId);
3574
+ if (!p) return;
3575
+ const remoteFile = localToRemote(absPath, hit.root, hit.info.remotePath);
3576
+ if (!remoteFile || remoteFile === hit.info.remotePath) return;
3577
+ // 远端父目录可能还不存在(新文件常落在新建目录里)
3578
+ const remoteDir = posixDirname(remoteFile);
3579
+ if (remoteDir && remoteDir !== remoteFile) {
3580
+ try { await runPooled(p, "mkdir -p " + shellQuotePath(remoteDir), undefined, undefined); } catch (e) {}
3581
+ }
3582
+ const r = await remoteWriteFile(runPooled, p, remoteFile, content);
3583
+ if (r && r.ok) {
3584
+ try { ctx.logger?.info?.("[dsh-remote-ssh] agent 写入已同步到远端: " + remoteFile); } catch (e) {}
3585
+ } else {
3586
+ try { ctx.logger?.warn?.("[dsh-remote-ssh] agent 写入同步远端失败(本地镜像已写入,可用 remote_ssh_push 补推): " + remoteFile + " — " + ((r && r.error) || "")); } catch (e) {}
3587
+ }
3588
+ }
3589
+
3590
+ let restoreFsWrite = null;
3591
+ ctx.inject(["fs"], (sctx) => {
3592
+ const svc = sctx && (sctx.fs || (typeof sctx.get === "function" ? sctx.get("fs") : null));
3593
+ restoreFsWrite = installFsWriteBridge(svc, { onWritten: mirrorWriteToRemote, log: ctx.logger });
3594
+ if (!restoreFsWrite) {
3595
+ try { ctx.logger?.warn("[dsh-remote-ssh] ctx.fs 不可包装:远程工作区里 agent 的 write/edit 仍只落本地镜像(需 remote_ssh_push)"); } catch (e) {}
3596
+ }
3597
+ });
3598
+
3476
3599
  // ---- 清理 ----
3477
3600
  ctx.effect(() => () => {
3478
3601
  if (restoreFileReferences) { try { restoreFileReferences(); } catch (e) {} restoreFileReferences = null; }
3602
+ if (restoreFsWrite) { try { restoreFsWrite(); } catch (e) {} restoreFsWrite = null; }
3479
3603
  clearInterval(idleTimer);
3480
3604
  sessions.forEach(function (s) { try { s.close(); } catch (e) {} });
3481
3605
  sessions.clear();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhangfengshun/dsh-remote-ssh",
3
- "version": "2.4.13",
3
+ "version": "2.4.14",
4
4
  "description": "DSH web plugin: VSCode Remote-SSH-like remote development (SSH to supercomputers/servers, remote workspace, file explorer, integrated terminal), integrated with dsh-better-sidebar and DSH settings.",
5
5
  "keywords": [
6
6
  "dsh",