@zhangfengshun/dsh-remote-ssh 2.4.12 → 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 +23 -0
- package/README.md +5 -1
- package/README_EN.md +5 -1
- package/lib/index.js +138 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
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
|
+
|
|
18
|
+
## [2.4.13] — `remote_ssh_push` / `remote_ssh_sync` 成功却报 `returned invalid output`(issue #14)
|
|
19
|
+
### 修复
|
|
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` 落回远端的唯一通道,成功信号不可信影响面不小。
|
|
21
|
+
- `syncOutput` 现在声明 `remotePath` / `mirrorPath`(成功路径本来就会带),`error` 改为**可选**;
|
|
22
|
+
- 两条成功路径补上 `error: ""`,与同文件其它工具(`normExec()`、`remote_ssh_ls` / `cat` / `grep` / `glob`)的风格一致;
|
|
23
|
+
- **并把全文件 6 处 output schema 的 `error` 一律改为可选**(通用护栏:`error` 按定义在成功时不存在,标必填就是定时炸弹)。失败路径的 `{ ok: false, error }` 不受影响。
|
|
24
|
+
|
|
25
|
+
### 测试
|
|
26
|
+
- 新增 `tests/tool-output-schema.test.mjs`(**17 条断言**):用**真实的 `remoteSyncUp` / `remoteSyncDown`**(桩掉 `subprocess`)取实际返回对象,再用**真实的 `syncOutput.schema`** 跑一个模拟宿主校验器的 mini-validator(必填齐全 + 无未声明字段 + 类型正确)——成功路径、参数缺失失败路径、非零退出码失败路径全部通过校验;另有静态护栏断言(**任何** output schema 都不得把 `error` 标成必填、`ok` 仍必填、两个路径字段已声明)与**反向自检**(把历史的坏 schema 喂给校验器,逐字复现报告者看到的 `missing required property "value.error"; "value.mirrorPath" is not a declared property`)。
|
|
27
|
+
|
|
5
28
|
## [2.4.12] — 公开产物脱敏(无功能改动)
|
|
6
29
|
### 变更
|
|
7
30
|
- **移除文档、代码注释与测试夹具中的真实标识**:此前 CHANGELOG / README / 代码注释 / issue 回复里出现了真实的项目名、HPC 主机名、内网 IP、真实远程路径与镜像目录 ID。现已全部替换为通用占位符(`proj-a` / `my-project` / `project-b`、`~/proj`、`hpc-a.example.com`、`192.0.2.10`(TEST-NET-1 文档网段)、`wmirror1`…、`solver`),并在 `MAINTENANCE.md` 写入硬规则:**公开产物一律用占位符**,发布前跑一次私有标识扫描。README 七夕段落里刻意保留的署名不受影响。
|
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.
|
|
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` **之后**。
|
|
@@ -207,6 +207,7 @@ dsh plugin --profile <name> remove @zhangfengshun/dsh-remote-ssh
|
|
|
207
207
|
| 远程工作区里「按文件名搜索」一直转圈(大工作区) | 2.4.11 起已修复:改为浅层优先(`-maxdepth 3`,实测冷 0.68s / 热 0.11s)且**有命中就立即返回**(深挖转后台预热缓存,浅层零命中才同步等深挖 `-maxdepth 8`),遍历前剪噪声目录、去掉会阻塞短路的 `sort`,并加远端墙钟预算——到点返回**已收集的部分结果**并标记不完整。实测某大型远程项目工作区:旧实现 5 分钟零输出 → 现在 **0.96s 返回 43 条** |
|
|
208
208
|
| 远程会话里 `@文件名` 没有候选,但单独输入 `@` 有 | 2.4.11 起已修复:模糊查询依赖索引,而索引首选 `git ls-files --cached --others`(`--others` 要遍历整棵工作树,巨型项目上跑不完 → 索引为空)。现在三级降级(完整 git 6s → 仅索引 git 3s → 有界 `find` `maxdepth 3` + 5s),并在索引未就绪时用有界 find 即时兜底(实测 0.65s),不再出现「全空」 |
|
|
209
209
|
| 安装时提示 `minimumReleaseAge` 或「No matching version」(刚发布) | npm 供应链新鲜度策略,等 1–5 分钟后重试即可 |
|
|
210
|
+
| `remote_ssh_push` / `remote_ssh_sync` 明明推送成功却报 `returned invalid output` | 2.4.13 起已修复:这两个工具共用的 output schema 把 `error` 标成必填、成功路径又返回未声明的 `remotePath`/`mirrorPath`,于是**只有成功会报错**(失败路径反而合法)。现在 schema 声明两个路径字段、`error` 改为可选,成功路径也带 `error: ""`;全文件所有 output schema 的 `error` 一并改为可选 |
|
|
210
211
|
| 命令卡住不返回 | 默认 120s 超时后自动丢弃会话;长时任务用 `timeoutMs: 0`,随时可用 `remote_ssh_kill` 强杀 |
|
|
211
212
|
| 大文件读取被截断 | 单文件读取上限 4MB、下载池化路径约 6.29MB(更大自动回落一次性连接);用 `remote_ssh_exec` + `head`/`tail` 分段处理 |
|
|
212
213
|
|
|
@@ -214,6 +215,8 @@ dsh plugin --profile <name> remove @zhangfengshun/dsh-remote-ssh
|
|
|
214
215
|
|
|
215
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 自动转换为远程路径——对客户端完全透明。
|
|
216
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
|
+
|
|
217
220
|
远程读取采用**单往返合并读**:一条池化命令同时返回 `size/mtime` 帧与文件内容(文本类扩展名优先 raw 直传,字节长 + U+FFFD 双校验失败自动回退 base64,结果逐字节一致);配合主机侧结果缓存与变更失效(见下节)。
|
|
218
221
|
|
|
219
222
|
Shell wrapper(`~/.dsh/remote-ssh/dsh-remote-shell[.cmd]`)检测工作区 `.remote-ssh.json`,自动 `ssh -tt` 连接远程,使内置「终端」页签透明接入。
|
|
@@ -225,6 +228,7 @@ Shell wrapper(`~/.dsh/remote-ssh/dsh-remote-shell[.cmd]`)检测工作区 `.r
|
|
|
225
228
|
已知限制:
|
|
226
229
|
|
|
227
230
|
- 集成终端(`ssh -tt`)与远端其它进程改动的文件依赖 TTL + 复验兜底,最多 **5 秒**陈旧;
|
|
231
|
+
- **agent 的 `read` 仍读本地镜像**:2.4.14 起 `write`/`edit` 会同步到远端,但若文件在远端被其它人改动,agent 读到的是镜像里的旧内容(用 `remote_ssh_sync` 重新拉取镜像即可);
|
|
228
232
|
- `/sidebar/file` 下载池化路径有效上限约 **6.29MB**,更大文件自动退回一次性连接下载(可成功,多一次重连开销);
|
|
229
233
|
- 二进制内容伪装成文本扩展名时会多一次 base64 回退往返(结果正确)。
|
|
230
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.
|
|
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.
|
|
@@ -207,6 +207,7 @@ The plugin never patches DSH sources or injects into the profile dependency tree
|
|
|
207
207
|
| "Search by file name" spins forever in a remote workspace (large trees) | Fixed in 2.4.11: shallow-first (`-maxdepth 3`, measured 0.68s cold / 0.11s warm) **returning as soon as anything matches** (the deeper pass becomes a background cache warm-up and only runs synchronously when the shallow pass finds nothing), noise directories pruned before traversal, the short-circuit-blocking `sort` removed, and a remote wall-clock budget that returns the **partial results collected so far** and flags them as incomplete. Measured on an 大型项目 workspace: 5 minutes with zero output before → **43 matches in 0.96s** now |
|
|
208
208
|
| In a remote session `@filename` shows no candidates, while a bare `@` works | Fixed in 2.4.11: fuzzy queries rely on the index, and the index preferred `git ls-files --cached --others` (`--others` must walk the whole working tree and never finishes on huge projects → empty index). It now degrades in three budgeted steps (full git 6s → index-only git 3s → bounded `find` `maxdepth 3` + 5s) and falls back to a bounded `find` (measured 0.65s) whenever the index is not ready yet, so candidates are never empty |
|
|
209
209
|
| Install fails with `minimumReleaseAge` or "No matching version" right after a release | npm supply-chain freshness policy — retry after 1–5 minutes |
|
|
210
|
+
| `remote_ssh_push` / `remote_ssh_sync` report `returned invalid output` even though the push succeeded | Fixed in 2.4.13: their shared output schema marked `error` as required while the success path returned an undeclared `remotePath`/`mirrorPath`, so **only success failed** (failures validated fine). The schema now declares both path fields, `error` is optional, the success paths carry `error: ""`, and every output schema in the file treats `error` as optional |
|
|
210
211
|
| A command hangs forever | The 120s timeout discards the pooled session automatically; use `timeoutMs: 0` for long jobs and `remote_ssh_kill` at any time |
|
|
211
212
|
| Large files are truncated | 4MB per read, ≈6.29MB on the pooled download path (larger files fall back to a one-shot connection); use `remote_ssh_exec` with `head`/`tail` to page through |
|
|
212
213
|
|
|
@@ -214,6 +215,8 @@ The plugin never patches DSH sources or injects into the profile dependency tree
|
|
|
214
215
|
|
|
215
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.
|
|
216
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
|
+
|
|
217
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).
|
|
218
221
|
|
|
219
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.
|
|
@@ -225,6 +228,7 @@ Remote reads and directory listings are cached host-side (read LRU 32 + listing
|
|
|
225
228
|
Known limitations:
|
|
226
229
|
|
|
227
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);
|
|
228
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);
|
|
229
233
|
- Binary content masquerading with a text extension costs one extra base64 fallback round-trip (results are still correct).
|
|
230
234
|
|
package/lib/index.js
CHANGED
|
@@ -1087,7 +1087,7 @@ async function remoteSyncDown(subprocess, p, remotePath, mirrorPath) {
|
|
|
1087
1087
|
const sshErr = readCollected(sshH, "stderr");
|
|
1088
1088
|
const tarErr = readCollected(tarH, "stderr");
|
|
1089
1089
|
// ssh 正常退出且 tar 正常退出才算成功
|
|
1090
|
-
if (sshOut.exitCode === 0 && tarOut.exitCode === 0) return { ok: true, mirrorPath: mirrorPath };
|
|
1090
|
+
if (sshOut.exitCode === 0 && tarOut.exitCode === 0) return { ok: true, error: "", mirrorPath: mirrorPath };
|
|
1091
1091
|
const parts = [];
|
|
1092
1092
|
if (pipeErr) parts.push("管道: " + pipeErr);
|
|
1093
1093
|
if (sshOut.exitCode !== 0) parts.push("ssh 退出码 " + sshOut.exitCode + (sshErr ? ": " + sshErr.slice(0, 500) : ""));
|
|
@@ -1125,7 +1125,7 @@ async function remoteSyncUp(subprocess, p, remotePath, mirrorPath) {
|
|
|
1125
1125
|
if (tarOut.exitCode === 0 && sshOut.exitCode === 0) {
|
|
1126
1126
|
// 批量覆盖远端文件:清空该 profile 的读/列举缓存(findings §4.3a)
|
|
1127
1127
|
clearCachesForProfile(p);
|
|
1128
|
-
return { ok: true, remotePath: remotePath };
|
|
1128
|
+
return { ok: true, error: "", remotePath: remotePath };
|
|
1129
1129
|
}
|
|
1130
1130
|
const parts = [];
|
|
1131
1131
|
if (pipeErr) parts.push("管道: " + pipeErr);
|
|
@@ -1278,7 +1278,7 @@ function execSchema() {
|
|
|
1278
1278
|
exitCode: { type: "number", required: true },
|
|
1279
1279
|
stdout: { type: "string", required: true },
|
|
1280
1280
|
stderr: { type: "string", required: true },
|
|
1281
|
-
error: { type: "string"
|
|
1281
|
+
error: { type: "string" },
|
|
1282
1282
|
truncated: { type: "boolean", required: true },
|
|
1283
1283
|
isTimeout: { type: "boolean", required: true }
|
|
1284
1284
|
}
|
|
@@ -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
|
// ---------------------------------------------------------------------------
|
|
@@ -2194,7 +2284,7 @@ function apply(ctx, config) {
|
|
|
2194
2284
|
ok: { type: "boolean", required: true },
|
|
2195
2285
|
path: { type: "string", required: true },
|
|
2196
2286
|
entries: { type: "array", required: true, items: { type: "object", additionalProperties: true } },
|
|
2197
|
-
error: { type: "string"
|
|
2287
|
+
error: { type: "string" }
|
|
2198
2288
|
}
|
|
2199
2289
|
},
|
|
2200
2290
|
render: textRender(function (a, v) {
|
|
@@ -2223,7 +2313,7 @@ function apply(ctx, config) {
|
|
|
2223
2313
|
content: { type: "string", required: true },
|
|
2224
2314
|
binary: { type: "boolean", required: true },
|
|
2225
2315
|
truncated: { type: "boolean", required: true },
|
|
2226
|
-
error: { type: "string"
|
|
2316
|
+
error: { type: "string" }
|
|
2227
2317
|
}
|
|
2228
2318
|
},
|
|
2229
2319
|
render: textRender(function (a, v) { return v.error || v.content || ""; })
|
|
@@ -2262,10 +2352,16 @@ function apply(ctx, config) {
|
|
|
2262
2352
|
const syncParams = {
|
|
2263
2353
|
workspaceId: { type: "string", description: "远程工作区 id(留空则在当前远程工作区会话中自动识别)。" }
|
|
2264
2354
|
};
|
|
2355
|
+
// sync / push 共用的输出 schema(issue #14):`error` **不能**标成必填 —— 成功路径本来就没有
|
|
2356
|
+
// error,标必填会让「推送成功」被判成 `returned invalid output`(模型看到失败、副作用却已落地,
|
|
2357
|
+
// 只能靠再跑一次 ls 才能确认)。同理成功路径会带上 remotePath / mirrorPath,必须在 schema 里声明
|
|
2358
|
+
// (additionalProperties: false 会拒掉未声明字段)。
|
|
2265
2359
|
const syncOutput = {
|
|
2266
2360
|
schema: { type: "object", additionalProperties: false, properties: {
|
|
2267
2361
|
ok: { type: "boolean", required: true },
|
|
2268
|
-
|
|
2362
|
+
remotePath: { type: "string" },
|
|
2363
|
+
mirrorPath: { type: "string" },
|
|
2364
|
+
error: { type: "string" }
|
|
2269
2365
|
} },
|
|
2270
2366
|
render: textRender(function (a, v) { return v.ok ? "同步完成" : (v.error || "同步失败"); })
|
|
2271
2367
|
};
|
|
@@ -2327,7 +2423,7 @@ function apply(ctx, config) {
|
|
|
2327
2423
|
path: { type: "string", required: true },
|
|
2328
2424
|
matches: { type: "array", required: true, items: { type: "object", additionalProperties: true } },
|
|
2329
2425
|
truncated: { type: "boolean", required: true },
|
|
2330
|
-
error: { type: "string"
|
|
2426
|
+
error: { type: "string" }
|
|
2331
2427
|
}
|
|
2332
2428
|
},
|
|
2333
2429
|
render: textRender(function (a, v) {
|
|
@@ -2364,7 +2460,7 @@ function apply(ctx, config) {
|
|
|
2364
2460
|
path: { type: "string", required: true },
|
|
2365
2461
|
files: { type: "array", required: true, items: { type: "string" } },
|
|
2366
2462
|
truncated: { type: "boolean", required: true },
|
|
2367
|
-
error: { type: "string"
|
|
2463
|
+
error: { type: "string" }
|
|
2368
2464
|
}
|
|
2369
2465
|
},
|
|
2370
2466
|
render: textRender(function (a, v) {
|
|
@@ -3467,9 +3563,43 @@ function apply(ctx, config) {
|
|
|
3467
3563
|
}
|
|
3468
3564
|
});
|
|
3469
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
|
+
|
|
3470
3599
|
// ---- 清理 ----
|
|
3471
3600
|
ctx.effect(() => () => {
|
|
3472
3601
|
if (restoreFileReferences) { try { restoreFileReferences(); } catch (e) {} restoreFileReferences = null; }
|
|
3602
|
+
if (restoreFsWrite) { try { restoreFsWrite(); } catch (e) {} restoreFsWrite = null; }
|
|
3473
3603
|
clearInterval(idleTimer);
|
|
3474
3604
|
sessions.forEach(function (s) { try { s.close(); } catch (e) {} });
|
|
3475
3605
|
sessions.clear();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhangfengshun/dsh-remote-ssh",
|
|
3
|
-
"version": "2.4.
|
|
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",
|