dsh-remote 0.8.33 → 0.8.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +72 -153
- package/README.md +57 -135
- package/lib/client.js +13 -3
- package/lib/ignore.js +33 -0
- package/lib/index.js +109 -21
- package/lib/pool.js +124 -53
- package/lib/search.js +53 -8
- package/lib/update.js +30 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,160 +22,88 @@ DSH 的 Web 界面刻意只监听 `127.0.0.1`(CLI 为安全拒绝 `--host 0.0.
|
|
|
22
22
|
|
|
23
23
|
## 数据采集 / 遥测
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
每次启动发送一次匿名心跳(同一安装最少间隔 6 小时),只用于统计用量:**去重日活、实际在跑的版本、平台分布**。npm 下载量由发版驱动、且含镜像与爬虫,GitHub clone 混有 CI,都回答不了这些。
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
只发送 5 个字段:`idHash`(`HMAC-SHA256('dsh-remote/telemetry/v1', installId)` 的伪名)、`version`、`platform`、`arch`、`node`。**不发送**主机名、用户名、路径、IP、SSH 主机/端口/密钥、机器列表、会话内容。原始 `installId`(`<DSH_HOME>/.dsh-remote-install-id`)不离开本机,只传它的 HMAC。
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|---|---|---|
|
|
31
|
-
| `idHash` | `872bd8cf…`(32 位 hex) | `HMAC-SHA256('dsh-remote/telemetry/v1', installId)`,本安装的伪名 |
|
|
32
|
-
| `version` | `0.8.29` | 实际在运行的版本 |
|
|
33
|
-
| `platform` | `win32` / `darwin` / `linux` | 平台分布 |
|
|
34
|
-
| `arch` | `x64` / `arm64` | 架构 |
|
|
35
|
-
| `node` | `24.14.0` | Node 版本 |
|
|
36
|
-
|
|
37
|
-
**绝不发送** —— 主机名、用户名、文件路径、IP、SSH 主机/端口/密钥、你的机器列表、会话内容、任何远程工作区数据。本插件恰恰能拿到这些信息,所以边界在这里划得很硬:原始 `installId` **不离开本机**,只有它的 HMAC 被传出,服务端无法与其他数据关联,也无法反推身份。
|
|
38
|
-
|
|
39
|
-
**身份存放位置** —— `<DSH_HOME>/.dsh-remote-install-id`(如 `~/.dsh/`)中的一个随机 UUID。刻意**不放在插件目录**:npm/pnpm 每次升级都会覆盖插件目录,放那里会让同一台机器每次升级都换身份,把 1 个用户算成 N 个。删除该文件即重置身份。
|
|
40
|
-
|
|
41
|
-
心跳是**尽力而为**的旁路:不阻塞加载、不产生日志噪音、任何失败(离线/被拦截/端点变更)都静默吞掉,绝不影响插件的任何功能。
|
|
29
|
+
心跳是尽力而为的旁路:不阻塞加载,失败静默忽略。
|
|
42
30
|
|
|
43
31
|
[实时用量看板](https://flymysql.github.io/dsh-remote/stats/) · [插件主页](https://flymysql.github.io/dsh-remote/)
|
|
44
32
|
|
|
45
33
|
## 界面预览
|
|
46
34
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
<img src="docs/ui-settings-panel.png" alt="dsh-remote 设置页 — 多机列表(浅色主题,主机已打码)" width="720"/>
|
|
35
|
+
**设置 → 远程工作区** —— 多机列表、高级配置(私钥/跳板机/agent)、连接体检、端口转发、审计日志、更新:
|
|
50
36
|
|
|
51
|
-
|
|
37
|
+
<img src="docs/shots/settings-panel.png" alt="dsh-remote 设置页:机器列表、高级配置、端口转发、审计日志、更新" width="640"/>
|
|
52
38
|
|
|
53
|
-
|
|
39
|
+
原生 **「Add workspace / 选择工作区」** 流程 —— 居中弹窗、两个 tab,默认落在「本机」;切到**「远程」**:
|
|
54
40
|
|
|
55
|
-
|
|
41
|
+
<img src="docs/shots/picker-dialog.png" alt="选择工作目录弹窗的「远程」tab:机器下拉、最近工作区、浏览…、设为远程工作区" width="632"/>
|
|
56
42
|
|
|
57
|
-
|
|
43
|
+
- 路径框实时补全;Windows 主机根级显示「此电脑」多盘视图;「浏览…」浮层选中只回填、不直接提交。
|
|
44
|
+
- 确定后创建**真实本地镜像**并被 harness 收养,同时通过 SFTP 保持同步;所选工作区持久化到该机器。
|
|
58
45
|
|
|
59
46
|
---
|
|
60
47
|
|
|
61
48
|
## 功能
|
|
62
49
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
- **
|
|
70
|
-
-
|
|
71
|
-
-
|
|
72
|
-
-
|
|
73
|
-
-
|
|
74
|
-
-
|
|
75
|
-
- **命令审计** —— 每次 `rw_exec`/写/删/移动/转发都追加到 `$DSH_HOME/remote-workspaces/audit.log`(时间 · user@host · 操作 · 退出码 · 命令);设置页显示最近 30 条。
|
|
76
|
-
- **长任务异步化** —— `rw_sync`/`rw_push` 传 `async: true` 返回 `taskId`,通过 `/dsh-remote/task` 查询进度/结果/取消(单飞队列)。
|
|
77
|
-
- **连接体检** —— 设置页「测试连接」按类别提示(认证 / 网络 / 主机指纹 / 超时);延迟会缓存在机器记录里。
|
|
78
|
-
- 当前 `user@host:/path`(以及生效的转发)会注入每次系统提示,让 Agent 明确自己的工作根。
|
|
79
|
-
- **不改任何 `dsh-workspace` 官方代码** —— 全部作为普通插件实现(client 半以 `priority -100` 填充 directory-flow holes)。
|
|
80
|
-
- **远端跨平台** —— 文件访问走 SFTP 协议(不依赖 POSIX shell),Linux/macOS/Windows 远端都能列/读/写/搜索/同步。
|
|
81
|
-
- **主机指纹校验(TOFU)** —— 每次 SSH 连接都校验主机密钥(`hostKeyMode: accept-new`):首次连接记录,之后**密钥一旦变化立即拒绝**(防中间人)。`verify` 模式还会拒绝从未见过的机器;`off` 关闭校验。指纹存于 `$DSH_HOME/remote-workspaces/known_hosts.json`;误判可用 `/remote forget-key` 重置。
|
|
82
|
-
- **数据跟随 Harness 根目录** —— 机器清单与镜像放在 `$DSH_HOME/remote-workspaces`(桌面版即 `userData/harness` 下);0.6 之前落在 `~/.dsh/remote-workspaces` 的数据**首次启动自动迁移**,不丢失。
|
|
50
|
+

|
|
51
|
+
|
|
52
|
+
上图是能力总览;下面只列**上图没说清、但用起来需要知道**的部分。
|
|
53
|
+
|
|
54
|
+
其余要点:
|
|
55
|
+
|
|
56
|
+
- **20 个模型工具**(便于复制/检索):`rw_info`、`rw_connect`、`rw_pick_workspace`、`rw_list_dir`、`rw_stat`、`rw_read_file`、`rw_write_file`、`rw_edit`、`rw_append`、`rw_mkdir`、`rw_remove`、`rw_move`、`rw_exec`、`rw_search`、`rw_download`、`rw_upload`、`rw_sync`、`rw_push`、`rw_forward`、`rw_disconnect`。
|
|
57
|
+
- **远端跨平台** —— 文件访问走 SFTP 协议层(不依赖 POSIX shell),Linux/macOS/Windows 远端都能列/读/写/搜索/同步。
|
|
58
|
+
- **Windows 主机** —— 自动探测平台并定位 Git Bash,命令经 `bash -s` 走 stdin 执行,不受引号/反斜杠转义困扰(`config.shell` 可指定或设 `native` 关闭);`C:\Users\dev` 与 `/c/Users/dev` 两种写法都接受。
|
|
59
|
+
- **长任务异步化** —— `rw_sync`/`rw_push` 传 `async: true` 返回 `taskId`,可查询进度/结果/取消。
|
|
60
|
+
- **数据跟随 Harness 根目录** —— 机器清单与镜像在 `$DSH_HOME/remote-workspaces`;0.6 之前的数据首次启动自动迁移。
|
|
61
|
+
- **不改动 `dsh-workspace` 官方代码** —— 全部作为普通插件实现。
|
|
83
62
|
|
|
84
63
|
## 安装
|
|
85
64
|
|
|
86
65
|
### DSH 版本兼容性
|
|
87
66
|
|
|
88
|
-
|
|
89
|
-
`@deepseek-ai/dsh-*` 的 peer 范围都是**跨线区间**(`>=0.1.0-rc.6 <0.3.0`,其中三个
|
|
90
|
-
`dsh-client-*` 保持各自原有的 `>=0.1.2-rc.1` 下界),而不再是 caret。
|
|
91
|
-
|
|
92
|
-
这一点的必要性在于:DSH 会在导入 bundle **之前**,把每条 `@deepseek-ai/dsh` /
|
|
93
|
-
`@deepseek-ai/dsh-*` 的 peer 范围与运行时版本比对,**只要有一条不匹配就整包丢弃**:
|
|
67
|
+
同时支持 `0.1.x` 与 `0.2.x` 两条 DSH 线。DSH 会在导入 bundle **之前**校验所有 `@deepseek-ai/dsh-*` 的 peer 范围,**任一条不匹配就整包丢弃**(没有设置页、没有 `rw_*` 工具):
|
|
94
68
|
|
|
95
69
|
```
|
|
96
70
|
dsh: skipping profile bundle "dsh-remote": Error: Plugin dsh-remote@… is incompatible …
|
|
97
71
|
```
|
|
98
72
|
|
|
99
|
-
|
|
100
|
-
`^0.2.0-rc.1` 也永远无法容纳 `0.1.x`。**如果你用的版本低于 0.8.29,请升级**——若你在升级
|
|
101
|
-
DSH 后插件整个消失(没有设置页、没有 `rw_*` 工具),原因就是它。
|
|
102
|
-
`test/compat.test.js` 用 DSH 自己的判定谓词钉住这些范围,防止 caret 回潮。
|
|
73
|
+
caret 在 `0.x` 上会锁死小版本线(`^0.1.x` 容不下 `0.2.x`,反之亦然),所以自 **0.8.29** 起改为跨线区间 `>=0.1.0-rc.6 <0.3.0`。**低于 0.8.29 请在升级 DSH 前先升级本插件**。
|
|
103
74
|
|
|
104
|
-
### 官方 Desktop
|
|
75
|
+
### 官方 Desktop 兼容适配(实验性)
|
|
105
76
|
|
|
106
|
-
|
|
107
|
-
的适配,以 `0.1.5-rc.2` Host 通信协议验证,不修改 Harness 核心:
|
|
77
|
+
对 [DeepSeek 官方 Desktop](https://github.com/deepseek-ai/deepseek-harness) 的适配(以 `0.1.5-rc.2` Host 协议验证,不修改 Harness 核心):
|
|
108
78
|
|
|
109
|
-
-
|
|
110
|
-
|
|
111
|
-
-
|
|
112
|
-
复用原来的文件树与编辑器,不把远端路径传给本地文件预览器。
|
|
113
|
-
- `dsh-better-sidebar` 不再内置;Web 版可以单独安装,官方 Desktop 则使用
|
|
114
|
-
原生右侧栏集成。
|
|
79
|
+
- 经 `ctx.connection.fetch` 注册 `/api/dsh-remote/*`,由 Desktop 的 `dsh-app:` 通道承载,不启动 Web Server。
|
|
80
|
+
- 经 `sidebarRightTabs` 提供原生「远程文件」入口,不把远端路径传给本地预览器。
|
|
81
|
+
- `dsh-better-sidebar` 不再内置;官方 Desktop 用原生右侧栏,不需要它。
|
|
115
82
|
|
|
116
|
-
已验证 Host 启动、IPC 请求、真实 SSH
|
|
117
|
-
测试 SSH 配置的导入。**v0.8.19** 起侧栏 `/ls` `/read` `/write` `/fs` 在请求带
|
|
118
|
-
`sessionId` 时按该会话的镜像绑定选机(与 `rw_*` 同一套),不再落到「当前机器」
|
|
119
|
-
连接池;宿主侧测试覆盖双机会话路由与编辑 409/重读/保存。原生文件标签的完整 GUI、
|
|
120
|
-
失败/取消交互、非 macOS 宿主以及旧 Web 版完整 UI 回归仍属实验性。
|
|
121
|
-
|
|
122
|
-
Desktop 安装器还可能要求明确配置 `ssh2` / `cpu-features` 可选构建脚本策略。
|
|
123
|
-
隔离验证中禁用了这些可选脚本;本改动不放宽应用的构建白名单,也不自动批准脚本。
|
|
83
|
+
已验证 Host 启动、IPC 请求、真实 SSH 的只读连接/列目录/读文件,以及双机会话路由(侧栏 `/ls` `/read` `/write` `/fs` 带 `sessionId` 时按会话选机)。原生文件 tab 的完整 GUI、失败/取消交互、非 macOS 宿主仍属实验性。Desktop 安装器可能需为 `ssh2` / `cpu-features` 可选构建脚本配置策略。
|
|
124
84
|
|
|
125
85
|
### 已发布的 Web bundle
|
|
126
86
|
|
|
127
|
-
```bash
|
|
128
|
-
dsh plugin add dsh-remote # 添加 bundle
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
从 **v0.8.18** 起,`dsh-remote` 只安装并挂载自身。Web 侧边栏
|
|
132
|
-
([dsh-better-sidebar](https://www.npmjs.com/package/dsh-better-sidebar))
|
|
133
|
-
改为可选,不再是依赖,也不会被自动挂载。这样 SSH 工具和设置页不再被某个侧边栏
|
|
134
|
-
实现的版本/API 变化拖垮。
|
|
135
|
-
|
|
136
|
-
如需 Web 版远程文件浏览/编辑,请显式安装两个 bundle:
|
|
137
|
-
|
|
138
87
|
```bash
|
|
139
88
|
dsh plugin add dsh-remote
|
|
140
|
-
dsh plugin add dsh-better-sidebar
|
|
141
89
|
```
|
|
142
90
|
|
|
143
|
-
|
|
144
|
-
不安装时,`rw_*` 工具、设置页、同步、审计日志和端口转发均照常工作。
|
|
145
|
-
官方 Desktop 使用原生右侧栏,不需要安装 `dsh-better-sidebar`。
|
|
91
|
+
自 **v0.8.18** 起只安装并挂载自身;Web 侧边栏([dsh-better-sidebar](https://www.npmjs.com/package/dsh-better-sidebar))改为可选。需要 Web 版远程文件浏览/编辑时再单独装它;不装时 `rw_*` 工具、设置页、同步、审计与转发均照常工作。
|
|
146
92
|
|
|
147
|
-
> **从 0.7.2–0.8.17 升级:**
|
|
148
|
-
> 只有仍需要 Web 侧边栏 UI 时才单独安装 `dsh-better-sidebar`。旧 profile 里针对
|
|
149
|
-
> `id: dsh-remote-sidebar` 的覆盖可以删除,因为这行已不存在。
|
|
93
|
+
> **从 0.7.2–0.8.17 升级:** 内嵌侧边栏会消失,旧 profile 里 `id: dsh-remote-sidebar` 的覆盖可以删除。
|
|
150
94
|
|
|
151
95
|
(或 `npm install dsh-remote`,再在 `cordis.patch.yml` 加 `- id: dsh-remote / name: dsh-remote`。)
|
|
152
96
|
|
|
153
97
|
## 快速上手
|
|
154
98
|
|
|
155
|
-
1. **加一台机器** —— 设置 → 远程工作区 → 填 host/port/user + 密码或 key
|
|
156
|
-
> **保存 ≠
|
|
157
|
-
> remote context。只有「设为当前」(或 Agent 显式调用 `rw_connect`)才激活当前机器;
|
|
158
|
-
> 「取消设为当前」可回到 `active remote = none`。
|
|
99
|
+
1. **加一台机器** —— 设置 → 远程工作区 → 填 host/port/user + 密码或 key → 设为当前。
|
|
100
|
+
> **保存 ≠ 激活**:保存只是备用连接;只有「设为当前」(或 Agent 调 `rw_connect`)才进入会话的 remote context。
|
|
159
101
|
2. **选工作区** —— 点侧边栏/会话的 **Add workspace**:
|
|
160
|
-
- **本机** →
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
3. **让 Agent 工作** —— 把它当普通工作区用:
|
|
166
|
-
- `rw_list_dir(path?)` / `rw_read_file` / `rw_stat` —— 查看远程文件
|
|
167
|
-
- `rw_write_file` / `rw_edit` / `rw_append` —— 创建、补丁、追加远程文件
|
|
168
|
-
- `rw_mkdir` / `rw_remove` / `rw_move` —— 管理远程路径
|
|
169
|
-
- `rw_search(pattern, path?)` —— 远程 grep(POSIX 走 rg/grep,否则 SFTP 遍历)
|
|
170
|
-
- `rw_exec(command, cwd?, pty?)` —— 在远程执行命令(默认在工作区目录)
|
|
171
|
-
- `rw_forward` —— SSH 隧道
|
|
172
|
-
- `rw_sync` / `rw_push` —— 冲突感知的镜像拉取/推送
|
|
173
|
-
|
|
174
|
-
> **Remote context 是 session 级的(v0.8.8+)**:system prompt 只会在**当前 session 的
|
|
175
|
-
> cwd 位于某个远程 mirror 内**(即你把远程目录选成了这个 session 的工作区)时注入
|
|
176
|
-
> 「Remote workspace」段落;普通本地 session 不注入、侧边栏「远程文件」也不显示任何
|
|
177
|
-
> 机器默认目录,模型不会主动调用 `rw_*`。混合访问(本地 + 远程同屏比较)请通过显式
|
|
178
|
-
> 选择远程工作区进行。
|
|
102
|
+
- **本机** → 系统文件夹选择(或手输路径)→ 本地工作区。宿主没有可用系统对话框时改用插件内置浏览器。
|
|
103
|
+
- **远程** → 选机器 → 浏览到远程目录(或输入 `/path`)→ 「设为远程工作区」⇒ 创建并收养本地镜像工作区。
|
|
104
|
+
3. **让 Agent 工作** —— 当作普通工作区使用,例如 `rw_read_file` / `rw_write_file` / `rw_edit` / `rw_exec` / `rw_search` / `rw_sync` / `rw_push` / `rw_forward`(完整列表见上文)。
|
|
105
|
+
|
|
106
|
+
> **Remote context 是 session 级的**:只有当前 session 的工作区是某个远程镜像时,system prompt 才注入「Remote workspace」段落;普通本地 session 不受影响,模型也不会主动调 `rw_*`。
|
|
179
107
|
|
|
180
108
|
## 可选:CLI 默认机
|
|
181
109
|
|
|
@@ -224,8 +152,7 @@ npx --yes @deepseek-ai/dsh plugin --profile web remove dsh-remote # 恢复用
|
|
|
224
152
|
|
|
225
153
|
## 开发(沙箱优先,勿改产品)
|
|
226
154
|
|
|
227
|
-
|
|
228
|
-
重装会把手工部署的文件还原掉。用仓库内的辅助脚本:
|
|
155
|
+
迭代一律在沙箱里做——手工改产品 profile 会被插件管理器在重装时还原:
|
|
229
156
|
|
|
230
157
|
```bash
|
|
231
158
|
scripts/dev-run.sh --restart # 启动 / 重启隔离沙箱
|
|
@@ -233,18 +160,11 @@ scripts/dev-run.sh --stop # 停止
|
|
|
233
160
|
scripts/dev-run.sh --status # 是否在运行
|
|
234
161
|
```
|
|
235
162
|
|
|
236
|
-
-
|
|
237
|
-
|
|
238
|
-
-
|
|
239
|
-
|
|
240
|
-
-
|
|
241
|
-
(`lib/client.js`)只需刷新页面。
|
|
242
|
-
- Node ESM 按导入文件的真实路径解析依赖,脚本用**硬链接拷贝**(`cp -al`)把
|
|
243
|
-
`lib/` 复制进沙箱 profile,而不是软链——软链会破坏 `@deepseek-ai/*` 的解析。
|
|
244
|
-
- 每次提交前跑 `node check.mjs`(静态框架约束闸门:命令名正则等);
|
|
245
|
-
`scripts/boot-smoke.sh` 用隔离实例证明插件仍能启动。
|
|
246
|
-
- 完整规则见 `scripts/dev-standards.md`(命令名、cordis 服务只许 `ctx.get()`、
|
|
247
|
-
可选框架服务可能压根不注册、三方库回调契约以真实运行为准等)。
|
|
163
|
+
- 沙箱自带独立 DSH 实例(仓库内 `dev-harness/harness`),UI 在 `http://127.0.0.1:50599`。
|
|
164
|
+
- **宿主半**(`lib/index.js`)改动需 `--restart`;**客户端半**(`lib/client.js`)改动刷新页面即可。
|
|
165
|
+
- 脚本用**硬链接拷贝**把 `lib/` 放进沙箱而非软链——软链会破坏 `@deepseek-ai/*` 的解析。
|
|
166
|
+
- 提交前跑 `node check.mjs`(框架约束闸门)与 `npm test`;`scripts/boot-smoke.sh` 证明插件仍能启动。
|
|
167
|
+
- 完整规则见 `scripts/dev-standards.md`。
|
|
248
168
|
|
|
249
169
|
部署到产品 profile 是单独的受控动作(`./sync.sh`),只在确定要发布时做。
|
|
250
170
|
|
|
@@ -276,6 +196,8 @@ scripts/dev-run.sh --status # 是否在运行
|
|
|
276
196
|
| `fileReferenceMaxEntries` | int | `3000` | 一棵远程工作区索引最多保留多少条目 |
|
|
277
197
|
| `fileReferenceExcludedDirectories` | string[] | `[.git, node_modules, dist, build, out, coverage, target, .next, .nuxt, .turbo, .venv, __pycache__, .pytest_cache, .mypy_cache, .gradle]` | 远程 `@` 遍历跳过的目录名 |
|
|
278
198
|
| `fileReferenceTimeoutMs` | int | `4000` | 一次远程索引遍历的墙钟预算(超时用已扫到的部分结果,不让光标等) |
|
|
199
|
+
| `searchTimeoutMs` | int | `60000` | `rw_search` 的协作式预算(ms):既作为工具声明的 `timeoutMs` 交给 DSH 的 timeout-policy,也是搜索自身的墙钟上限;到点返回部分结果并标 `TRUNCATED`(issue #44)。 |
|
|
200
|
+
| `searchMaxEntries` | int | `50000` | `rw_search` 在返回部分结果前最多扫描多少个文件。 |
|
|
279
201
|
| `updateMode` | string | `auto` | 自更新模式:`auto`=加载时及每 6 小时检查并自动应用、`manual`=仅在手动检查时查、`off`=完全不查。**0.8.27 起默认 `auto`**——之所以现在才安全,是因为 0.8.24 补上了宿主半热切换 |
|
|
280
202
|
| `updateCheckIntervalMs` | int | 21600000(6h) | `auto` 模式检查 npm 的间隔(下限 60000) |
|
|
281
203
|
| `updateAutoReload` | bool | `true` | 更新落地后自动热切换宿主半;`false` 则留到下次启动,设置页会显示 `pendingReload` |
|
|
@@ -284,29 +206,29 @@ scripts/dev-run.sh --status # 是否在运行
|
|
|
284
206
|
|
|
285
207
|
## 常见问题 / 排查
|
|
286
208
|
|
|
287
|
-
**`@`
|
|
209
|
+
**`@` 能列出远程文件,但内置读文件工具打不开** —— harness 自带工具看到的是**本地镜像**,要等 `rw_sync` 下载后才有内容。读远程文件请用 `rw_read_file` 或侧栏远程文件 tab。
|
|
288
210
|
|
|
289
|
-
|
|
211
|
+
**主机指纹变了** —— `/remote forget-key`(或设置页 → 机器 → 重新信任)。
|
|
290
212
|
|
|
291
|
-
**连接报「认证失败」** ——
|
|
213
|
+
**连接报「认证失败」** —— 检查用户名/密码/私钥路径;加密私钥要填 Passphrase;需要动态码时勾选 keyboard-interactive。
|
|
292
214
|
|
|
293
|
-
**连不上内网机器** ——
|
|
215
|
+
**连不上内网机器** —— 填「跳板机」主机(也可先把跳板机本身配成一台机器)。
|
|
294
216
|
|
|
295
|
-
**`rw_sync`/`rw_push` 报冲突** ——
|
|
217
|
+
**`rw_sync`/`rw_push` 报冲突** —— 两边都改过的文件会被跳过并列出(绝不静默覆盖);手动合并后重试,或用 `force=true` 以一边为准。
|
|
296
218
|
|
|
297
|
-
**Windows 远程** ——
|
|
219
|
+
**Windows 远程** —— 全部走 SFTP,不依赖 POSIX shell;中文文件用 `encoding=gbk`。
|
|
298
220
|
|
|
299
|
-
|
|
221
|
+
**镜像里缺目录** —— 默认 ignore 会跳过 `.git`/`node_modules` 等;在 `$DSH_HOME/remote-workspaces/.dsh-remote-ignore` 调整(gitignore 语法)。
|
|
300
222
|
|
|
301
|
-
|
|
223
|
+
**保存远程文件报 409** —— 打开后远端已被改动,重新读取再编辑。
|
|
302
224
|
|
|
303
|
-
**密码怎么加密保存** ——
|
|
225
|
+
**密码怎么加密保存** —— 勾选「加密保存密码」:macOS 钥匙串 / Windows DPAPI / Linux secret-tool(libsecret);后端不可用时回退明文。
|
|
304
226
|
|
|
305
|
-
**升级后插件整个不见了** ——
|
|
227
|
+
**升级后插件整个不见了** —— DSH 兼容性判定丢弃了 bundle,升到 **0.8.29+** 即可(见上文「DSH 版本兼容性」)。
|
|
306
228
|
|
|
307
229
|
## 安全提醒
|
|
308
230
|
|
|
309
|
-
|
|
231
|
+
把凭据交给插件等于允许 Agent 以你的用户身份在该主机执行 **shell 命令**——只添加可信机器。密码存在本机文件(或钥匙串),请当作敏感数据。开启 `auditLog` 时每条命令都会记入审计日志。
|
|
310
232
|
|
|
311
233
|
## License
|
|
312
234
|
|
package/lib/client.js
CHANGED
|
@@ -144,6 +144,7 @@ window.__ModuleLoader__.load({
|
|
|
144
144
|
'settings.updateAppliedNoReload': '✅ 已更新到 v{to}({from} → {to})。重启 Harness 生效。',
|
|
145
145
|
'settings.updatePendingReload': '⚠️ 磁盘已是 v{disk},运行中的仍是 v{loaded};点「立即更新」或重启 Harness 完成切换。',
|
|
146
146
|
'settings.selfUpdateBlocked': 'ℹ️ 当前是 link:/开发模式安装(不在 node_modules 下),已禁止自动更新以免覆盖源码——请在你的源码仓库里更新。',
|
|
147
|
+
'settings.updateRegistryError': 'npm registry 查询失败:{msg}',
|
|
147
148
|
'settings.updateFailed': '更新失败',
|
|
148
149
|
'settings.updateFailedMsg': '更新失败: {msg}',
|
|
149
150
|
'settings.registryUnreachable': '无法连接 npm registry',
|
|
@@ -371,6 +372,7 @@ window.__ModuleLoader__.load({
|
|
|
371
372
|
'settings.updateAppliedNoReload': '✅ Updated to v{to} ({from} → {to}). Restart Harness to apply.',
|
|
372
373
|
'settings.updatePendingReload': '⚠️ v{disk} is on disk but v{loaded} is still running; update again or restart Harness to switch.',
|
|
373
374
|
'settings.selfUpdateBlocked': 'ℹ️ This is a link:/dev install (not under node_modules), so self-update is disabled to avoid overwriting source — update it from its source repo.',
|
|
375
|
+
'settings.updateRegistryError': 'npm registry query failed: {msg}',
|
|
374
376
|
'settings.updateFailed': 'Update failed',
|
|
375
377
|
'settings.updateFailedMsg': 'Update failed: {msg}',
|
|
376
378
|
'settings.registryUnreachable': 'Cannot reach the npm registry',
|
|
@@ -830,9 +832,17 @@ window.__ModuleLoader__.load({
|
|
|
830
832
|
setUpdBusy(true); setUpdMsg('')
|
|
831
833
|
api('GET', '/dsh-remote/update-check')
|
|
832
834
|
.then((r) => {
|
|
833
|
-
if (!r
|
|
834
|
-
|
|
835
|
-
|
|
835
|
+
if (!r) { setUpdMsg(tr('settings.updateFail')); return }
|
|
836
|
+
// 即使 registry 查不到(限流/离线/超时),也先用本地状态填充面板,
|
|
837
|
+
// 否则 latest 永远为 null,界面会一直停在「版本信息加载中…」——
|
|
838
|
+
// 看起来像卡死,而本地版本/更新模式其实都是已知的。
|
|
839
|
+
if (r.ok) { setUpd(r); if (r.updateMode) setUpdMode(r.updateMode) }
|
|
840
|
+
if (r.registryError) {
|
|
841
|
+
// 把真实原因说出来:限流是"稍后自动恢复",与"网络坏了"处置不同。
|
|
842
|
+
setUpdMsg(tr('settings.updateRegistryError', { msg: r.registryError }))
|
|
843
|
+
return
|
|
844
|
+
}
|
|
845
|
+
if (!r.ok) { setUpdMsg(r.error || tr('settings.updateFail')); return }
|
|
836
846
|
if (!quiet) setUpdMsg(r.updateAvailable ? tr('settings.updateAvailable', { version: r.latest }) : tr('settings.updateLatest', { version: r.current }))
|
|
837
847
|
})
|
|
838
848
|
.catch((e) => setUpdMsg(String((e && e.message) || e)))
|
package/lib/ignore.js
CHANGED
|
@@ -106,6 +106,39 @@ export const DEFAULT_IGNORE = [
|
|
|
106
106
|
'.dsh-remote-sync-state.json',
|
|
107
107
|
]
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Extra directories skipped by `rw_search` (but NOT by mirror sync — a user may
|
|
111
|
+
* legitimately want their cache tree mirrored, and silently excluding it from
|
|
112
|
+
* sync would lose data).
|
|
113
|
+
*
|
|
114
|
+
* These are machine-local package/tool caches: enormous numbers of tiny files
|
|
115
|
+
* that essentially never contain what a human is searching for, and that are
|
|
116
|
+
* the main way a "search my home directory" turns into a multi-hour walk
|
|
117
|
+
* (issue #44 reports `~/.npm/_cacache` specifically). Searching them is
|
|
118
|
+
* available via an explicit `path`, which bypasses these excludes.
|
|
119
|
+
*/
|
|
120
|
+
export const DEFAULT_SEARCH_IGNORE = [
|
|
121
|
+
...DEFAULT_IGNORE,
|
|
122
|
+
'.npm/',
|
|
123
|
+
'.cache/',
|
|
124
|
+
'.local/',
|
|
125
|
+
'.cargo/',
|
|
126
|
+
'.rustup/',
|
|
127
|
+
'.gradle/',
|
|
128
|
+
'.m2/',
|
|
129
|
+
'.pnpm-store/',
|
|
130
|
+
'.yarn/',
|
|
131
|
+
'.conda/',
|
|
132
|
+
'.docker/',
|
|
133
|
+
'.nvm/',
|
|
134
|
+
'.pyenv/',
|
|
135
|
+
'.Trash/',
|
|
136
|
+
'.Trash-1000/',
|
|
137
|
+
'snap/',
|
|
138
|
+
'AppData/',
|
|
139
|
+
'Library/Caches/',
|
|
140
|
+
]
|
|
141
|
+
|
|
109
142
|
/**
|
|
110
143
|
* Load the user ignore file (gitignore syntax) and merge with defaults.
|
|
111
144
|
* Returns `{ patterns, fromFile }`.
|
package/lib/index.js
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
toShellPath, toDisplayPath, remotePathBase, truncate, shortHash,
|
|
32
32
|
} from './paths.js'
|
|
33
33
|
import { createHostKeyGuard, isHostKeyKnown as _isHostKeyKnown } from './hostkey.js'
|
|
34
|
-
import { compileIgnore, DEFAULT_IGNORE } from './ignore.js'
|
|
34
|
+
import { compileIgnore, DEFAULT_IGNORE, DEFAULT_SEARCH_IGNORE } from './ignore.js'
|
|
35
35
|
import { friendlyMessage } from './errors.js'
|
|
36
36
|
import { importableEntries, sshConfigPath, readSshConfigText, resolveMachineSshConfig } from './sshconfig.js'
|
|
37
37
|
import { createRemoteFileReference, installFileReferenceOverlay, DEFAULT_EXCLUDED_DIRECTORIES } from './file-reference.js'
|
|
@@ -134,6 +134,14 @@ export const Config = z.object({
|
|
|
134
134
|
/** Wall-clock budget (ms) for one remote `@` index pass; on expiry the
|
|
135
135
|
* partial index answers rather than making the caret wait. */
|
|
136
136
|
fileReferenceTimeoutMs: z.number().step(1).min(200).default(4000),
|
|
137
|
+
/** Cooperative budget (ms) for `rw_search`; also declared as the tool's
|
|
138
|
+
* `timeoutMs` so @deepseek-ai/dsh-tool-call-timeout-policy can end a call
|
|
139
|
+
* that overruns. The search itself stops at this budget and returns partial
|
|
140
|
+
* results (issue #44: an unbounded walk over a home directory could never be
|
|
141
|
+
* stopped and left the turn `running` forever). */
|
|
142
|
+
searchTimeoutMs: z.number().step(1).min(1000).default(60000),
|
|
143
|
+
/** Max files `rw_search` will stat/read before returning partial results. */
|
|
144
|
+
searchMaxEntries: z.number().step(1).min(1).default(50000),
|
|
137
145
|
/** Update mode: `auto` (default) checks on load and periodically, applies a
|
|
138
146
|
* newer npm release automatically, then hot-swaps the host half (see
|
|
139
147
|
* `updateAutoReload`); `manual` only checks when the user asks; `off` disables
|
|
@@ -734,6 +742,9 @@ export async function apply(ctx, config) {
|
|
|
734
742
|
}
|
|
735
743
|
|
|
736
744
|
// ── ignore rules (defaults + user file) ───────────────────────────────────
|
|
745
|
+
// NOTE: this matcher is shared by mirror sync (rw_sync/rw_push) and search.
|
|
746
|
+
// Do NOT add search-only cache excludes here: widening it would silently stop
|
|
747
|
+
// syncing directories a user may rely on. Search uses searchIgnoreMatcher().
|
|
737
748
|
const ignoreMatcher = () => {
|
|
738
749
|
try {
|
|
739
750
|
const fromFile = readFileSync(ignoreFile(), 'utf8').split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'))
|
|
@@ -742,6 +753,18 @@ export async function apply(ctx, config) {
|
|
|
742
753
|
return compileIgnore(DEFAULT_IGNORE)
|
|
743
754
|
}
|
|
744
755
|
}
|
|
756
|
+
/** Search-only matcher: same as ignoreMatcher() plus machine-local cache trees
|
|
757
|
+
* (`~/.npm`, `~/.cache`, …) that are huge, almost never what a human is
|
|
758
|
+
* looking for, and the main cause of the issue #44 hang. Mirror sync is
|
|
759
|
+
* deliberately unaffected. An explicit `path` still searches anywhere. */
|
|
760
|
+
const searchIgnoreMatcher = () => {
|
|
761
|
+
try {
|
|
762
|
+
const fromFile = readFileSync(ignoreFile(), 'utf8').split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'))
|
|
763
|
+
return compileIgnore(DEFAULT_SEARCH_IGNORE.concat(fromFile))
|
|
764
|
+
} catch {
|
|
765
|
+
return compileIgnore(DEFAULT_SEARCH_IGNORE)
|
|
766
|
+
}
|
|
767
|
+
}
|
|
745
768
|
|
|
746
769
|
// ── task + forward managers ───────────────────────────────────────────────
|
|
747
770
|
const tasks = new TaskManager()
|
|
@@ -1593,21 +1616,44 @@ export async function apply(ctx, config) {
|
|
|
1593
1616
|
defineTool({
|
|
1594
1617
|
name: 'rw_edit',
|
|
1595
1618
|
description:
|
|
1596
|
-
'Edit a remote text file by replacing literal text (read-modify-write with an mtime optimistic lock: aborts if the file changed on the remote between read and write). Path is absolute.',
|
|
1619
|
+
'Edit a remote text file by replacing literal text (read-modify-write with an mtime optimistic lock: aborts if the file changed on the remote between read and write). Path is absolute. Accepts the aliases old_string/new_string (and file_path for path), matching the host edit tool, so either spelling works.',
|
|
1597
1620
|
parameters: {
|
|
1598
|
-
path
|
|
1599
|
-
|
|
1600
|
-
|
|
1621
|
+
// `path`/`old`/`new` also accept the host edit tool's spellings
|
|
1622
|
+
// (file_path/old_string/new_string): models habitually use those, and a
|
|
1623
|
+
// schema-level `required` on only one spelling rejects the other before
|
|
1624
|
+
// execute() ever runs. So NONE of them is declared required here — the
|
|
1625
|
+
// resolver below enforces "path + old + new" and reports which spelling
|
|
1626
|
+
// to pass. Keep this in sync: adding `required: true` back to any of
|
|
1627
|
+
// these silently breaks its alias.
|
|
1628
|
+
path: { type: 'string', description: 'Absolute remote file path (alias: file_path)' },
|
|
1629
|
+
old: { type: 'string', description: 'Literal text to replace (must appear exactly once unless count is given). Alias: old_string' },
|
|
1630
|
+
new: { type: 'string', description: 'Replacement text; an empty string deletes the old text. Alias: new_string' },
|
|
1631
|
+
old_string: { type: 'string', description: 'Alias of old (same meaning)' },
|
|
1632
|
+
new_string: { type: 'string', description: 'Alias of new (same meaning)' },
|
|
1633
|
+
file_path: { type: 'string', description: 'Alias of path (same meaning)' },
|
|
1601
1634
|
count: { type: 'integer', description: 'How many occurrences to replace (default: error if the text appears more than once)' },
|
|
1602
1635
|
encoding: { type: 'string', description: 'Text encoding, e.g. utf-8 (default) or gbk' },
|
|
1603
1636
|
},
|
|
1604
1637
|
output: okOut,
|
|
1605
1638
|
async execute(args, exec) {
|
|
1606
1639
|
const b = requireMachine(exec, 'rw_edit')
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1640
|
+
// Presence must be checked on the RAW argument, not on the resolved
|
|
1641
|
+
// path: resolveRemoteArg falls back to the workspace root for an empty
|
|
1642
|
+
// string, so `!p` would never fire and a pathless rw_edit would silently
|
|
1643
|
+
// target the workspace root. (That is why `path` cannot simply lose its
|
|
1644
|
+
// schema `required` without this check replacing it.)
|
|
1645
|
+
const rawPath = args.path ?? args.file_path
|
|
1646
|
+
if (rawPath == null || String(rawPath).trim() === '') {
|
|
1647
|
+
throw new Error('rw_edit: a file path is required — pass path (alias: file_path)')
|
|
1648
|
+
}
|
|
1649
|
+
const p = resolveRemoteArg(b, rawPath)
|
|
1650
|
+
if (!p || p === '/') throw new Error('rw_edit: a file path is required — pass path (alias: file_path)')
|
|
1651
|
+
const oldRaw = args.old ?? args.old_string
|
|
1652
|
+
const newRaw = args.new ?? args.new_string
|
|
1653
|
+
if (oldRaw == null) throw new Error('rw_edit: old text is required — pass old (alias: old_string)')
|
|
1654
|
+
if (newRaw == null) throw new Error('rw_edit: replacement text is required — pass new (alias: new_string); use an empty string to delete the old text')
|
|
1655
|
+
const oldS = String(oldRaw)
|
|
1656
|
+
const newS = String(newRaw)
|
|
1611
1657
|
if (oldS === '') throw new Error('rw_edit: old text must not be empty')
|
|
1612
1658
|
const sftp = await b.pool.sftp()
|
|
1613
1659
|
const st0 = await sftp.stat(p)
|
|
@@ -1772,6 +1818,7 @@ export async function apply(ctx, config) {
|
|
|
1772
1818
|
if (!parts.length) parts.push('(no output)')
|
|
1773
1819
|
let text = parts.join('\n')
|
|
1774
1820
|
if (res.signal === 'TIMEOUT') text += `\n[command timed out after ${config.commandTimeoutMs}ms]`
|
|
1821
|
+
else if (res.signal === 'ABORTED') text += '\n[cancelled — the remote command was stopped]'
|
|
1775
1822
|
else if (res.code !== 0) text += `\n[exit code: ${res.code}]`
|
|
1776
1823
|
return { text }
|
|
1777
1824
|
} catch (err) {
|
|
@@ -1783,7 +1830,12 @@ export async function apply(ctx, config) {
|
|
|
1783
1830
|
defineTool({
|
|
1784
1831
|
name: 'rw_search',
|
|
1785
1832
|
description:
|
|
1786
|
-
'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.',
|
|
1833
|
+
'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. Search is bounded (maxEntries/maxDurationMs) and can be cancelled: on a very large tree it returns partial results marked TRUNCATED instead of running indefinitely.',
|
|
1834
|
+
// A cooperative budget for @deepseek-ai/dsh-tool-call-timeout-policy.
|
|
1835
|
+
// Only useful together with the cancellation support below: the policy can
|
|
1836
|
+
// only *ask* a tool to stop, so a tool that ignores exec.signal would keep
|
|
1837
|
+
// the caller waiting (issue #44).
|
|
1838
|
+
timeoutMs: config.searchTimeoutMs,
|
|
1787
1839
|
parameters: {
|
|
1788
1840
|
pattern: { type: 'string', required: true, description: 'Pattern to search for (extended regex)' },
|
|
1789
1841
|
path: { type: 'string', description: 'Directory to search (default: current remote workspace)' },
|
|
@@ -1791,6 +1843,8 @@ export async function apply(ctx, config) {
|
|
|
1791
1843
|
ignoreCase: { type: 'boolean', description: 'Case-insensitive search (default true)' },
|
|
1792
1844
|
contextLines: { type: 'integer', description: 'Lines of context around each match (default 0)' },
|
|
1793
1845
|
maxMatches: { type: 'integer', description: 'Max matches to return (default 500)' },
|
|
1846
|
+
maxEntries: { type: 'integer', description: 'Stop after scanning this many files (default 50000)' },
|
|
1847
|
+
maxDurationMs: { type: 'integer', description: 'Stop after this many ms and return partial results (default 60000)' },
|
|
1794
1848
|
},
|
|
1795
1849
|
output: textOut,
|
|
1796
1850
|
async execute(args, exec) {
|
|
@@ -1807,21 +1861,36 @@ export async function apply(ctx, config) {
|
|
|
1807
1861
|
throw new Error('rw_search: bad pattern: ' + ((err && err.message) || err))
|
|
1808
1862
|
}
|
|
1809
1863
|
const maxMatches = Math.min(Math.max(Number(args.maxMatches) || 500, 1), 2000)
|
|
1810
|
-
|
|
1811
|
-
|
|
1864
|
+
// Budgets. The SFTP walk had NO entry/duration cap before, so a search
|
|
1865
|
+
// over a home directory could run for hours while the turn stayed
|
|
1866
|
+
// `running` and cancel/steer did nothing (issue #44).
|
|
1867
|
+
const maxFiles = Math.min(Math.max(Number(args.maxEntries) || config.searchMaxEntries, 1), 500000)
|
|
1868
|
+
const maxDurationMs = Math.min(Math.max(Number(args.maxDurationMs) || config.searchTimeoutMs, 1000), 600000)
|
|
1869
|
+
const matcher = searchIgnoreMatcher()
|
|
1870
|
+
const { matches, scanned, truncated, cancelled } = await searchRemote(b.pool, dir, {
|
|
1812
1871
|
pattern,
|
|
1813
1872
|
regex,
|
|
1814
1873
|
glob: args.glob,
|
|
1815
1874
|
ignoreCase: args.ignoreCase !== false,
|
|
1816
1875
|
contextLines: Math.min(Math.max(Number(args.contextLines) || 0, 0), 10),
|
|
1817
1876
|
maxMatches,
|
|
1877
|
+
maxFiles,
|
|
1878
|
+
maxDurationMs,
|
|
1879
|
+
signal: exec.signal,
|
|
1818
1880
|
maxScanBytes: Math.min(config.maxFileBytes || 1024 * 1024, 1024 * 1024),
|
|
1819
1881
|
isIgnored: (name, isDir) => matcher(name, isDir),
|
|
1820
|
-
timeoutMs: config.commandTimeoutMs,
|
|
1882
|
+
timeoutMs: Math.min(config.commandTimeoutMs, maxDurationMs),
|
|
1821
1883
|
})
|
|
1822
|
-
|
|
1884
|
+
const note = cancelled
|
|
1885
|
+
? `, CANCELLED after ${scanned} files scanned`
|
|
1886
|
+
: truncated
|
|
1887
|
+
? ', TRUNCATED'
|
|
1888
|
+
: ''
|
|
1889
|
+
if (!matches.length) {
|
|
1890
|
+
return { text: `no matches for /${pattern}/ in ${dir} (${scanned} files scanned${note})` }
|
|
1891
|
+
}
|
|
1823
1892
|
let text = matches.map((m) => `${m.path}:${m.line}: ${m.text}`).join('\n')
|
|
1824
|
-
text += `\n(${matches.length} match(es), ${scanned} files scanned${
|
|
1893
|
+
text += `\n(${matches.length} match(es), ${scanned} files scanned${note})`
|
|
1825
1894
|
return { text: truncate(text, config.maxOutputChars) }
|
|
1826
1895
|
},
|
|
1827
1896
|
}),
|
|
@@ -2822,13 +2891,21 @@ export async function apply(ctx, config) {
|
|
|
2822
2891
|
// a link:/dev install must not be overwritten by an npm publish.
|
|
2823
2892
|
selfUpdateAllowed: isInstalledCopy(),
|
|
2824
2893
|
}
|
|
2825
|
-
const
|
|
2826
|
-
|
|
2894
|
+
const probe = await fetchLatestVersion()
|
|
2895
|
+
// 即使查不到最新版,也把**本地状态**照常返回(ok:true),只把 registry
|
|
2896
|
+
// 的失败原因放在 registryError 里。
|
|
2897
|
+
//
|
|
2898
|
+
// 为什么:原先 registry 失败时整条响应 ok:false,客户端遇到 !ok 就只设错误
|
|
2899
|
+
// 文案、不 setUpd,于是面板永远停在「版本信息加载中…」——看起来像卡死,
|
|
2900
|
+
// 而实际上本地版本、更新模式、是否允许自更新都是已知的,完全可以展示。
|
|
2901
|
+
if (probe.error) {
|
|
2902
|
+
return sendJson(res, 200, { ok: true, ...base, latest: null, updateAvailable: false, registryError: probe.error, registryReason: probe.reason })
|
|
2903
|
+
}
|
|
2827
2904
|
return sendJson(res, 200, {
|
|
2828
2905
|
ok: true,
|
|
2829
2906
|
...base,
|
|
2830
|
-
latest,
|
|
2831
|
-
updateAvailable: gtVersion(
|
|
2907
|
+
latest: probe.version,
|
|
2908
|
+
updateAvailable: gtVersion(probe.version, loaded),
|
|
2832
2909
|
})
|
|
2833
2910
|
},
|
|
2834
2911
|
},
|
|
@@ -2904,9 +2981,20 @@ export async function apply(ctx, config) {
|
|
|
2904
2981
|
const rawMode = readUpdateMode() || config.updateMode || DEFAULT_UPDATE_MODE
|
|
2905
2982
|
const effectiveUpdateMode = ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : DEFAULT_UPDATE_MODE
|
|
2906
2983
|
if (effectiveUpdateMode === 'auto') {
|
|
2984
|
+
// 限流/网络失败后的退避:命中后跳过接下来若干轮检查,避免在被 npm 限流
|
|
2985
|
+
// (HTTP 429)时反复重试把配额烧得更久。指数增长,上限 24 轮(配合默认
|
|
2986
|
+
// 6h 间隔即最多约 6 天,足以让限流窗口滑过且不会永久停摆)。
|
|
2987
|
+
let backoffRounds = 0
|
|
2907
2988
|
const checkAndApply = async () => {
|
|
2908
|
-
|
|
2909
|
-
|
|
2989
|
+
if (backoffRounds > 0) { backoffRounds -= 1; return }
|
|
2990
|
+
const probe = await fetchLatestVersion()
|
|
2991
|
+
if (probe.error) {
|
|
2992
|
+
// 限流与网络故障都要退避;成功一次即重置。
|
|
2993
|
+
backoffRounds = Math.min(backoffRounds === 0 ? 1 : backoffRounds * 2, 24)
|
|
2994
|
+
return
|
|
2995
|
+
}
|
|
2996
|
+
backoffRounds = 0
|
|
2997
|
+
const latest = probe.version
|
|
2910
2998
|
// The version in flight is whichever of "already on disk" / "already
|
|
2911
2999
|
// loaded" is newer. Pinning it once outside this closure (as v0.8.23 did)
|
|
2912
3000
|
// made every later interval re-download and re-apply the same tarball,
|