dsh-ferry 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +229 -2
- package/client.js +1619 -0
- package/cordis.patch.yml +5 -0
- package/docs/transfer-scope.md +132 -0
- package/icon.svg +10 -0
- package/index.js +69 -0
- package/lib/auth.js +160 -0
- package/lib/bundle.js +240 -0
- package/lib/config.js +156 -0
- package/lib/ferry.js +638 -0
- package/lib/http.js +167 -0
- package/lib/ignore.js +315 -0
- package/lib/paths.js +130 -0
- package/lib/routes.js +308 -0
- package/lib/settings.js +154 -0
- package/lib/state.js +129 -0
- package/lib/tools.js +283 -0
- package/lib/transfer.js +1084 -0
- package/lib/workspace.js +504 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +59 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zapan Gao
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,230 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-ferry
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
在彼此可达的 DSH 实例之间推送 / 拉取会话(Session),并连同工作区文件一起流转,让同一件工作可以在另一台机器上继续。
|
|
4
|
+
|
|
5
|
+
- **推送**:把本机会话 + 它需要的工作区文件送到对端,对端可直接继续。
|
|
6
|
+
- **拉取**:从对端挑选会话拉回本机。
|
|
7
|
+
- **不处理网络**:插件假设实例之间已经可达(目前默认 Tailscale 组网)。它只负责身份验证、打包、校验和落盘。
|
|
8
|
+
|
|
9
|
+
UI 入口:**左侧栏的 dsh-ferry 图标**(实例配置、连接状态、拉取会话);**会话 “...” 菜单 → 推送到…** 弹出对话框选择实例与推送方式。同时提供 5 个 agent 工具:`ferry_peers`、`ferry_sessions`、`ferry_remote_sessions`、`ferry_push`、`ferry_pull`。
|
|
10
|
+
|
|
11
|
+
## 前提
|
|
12
|
+
|
|
13
|
+
1. 两端 DSH 版本一致(会话日志格式版本必须相同)。版本不一致时导入会被拒绝并提示。
|
|
14
|
+
2. 对端的 DSH Web 监听地址必须从本机可达。默认 `dsh web` 只绑 `127.0.0.1`,跨机时要绑 Tailscale 地址或 `--host` 指定的具体网卡地址,例如:
|
|
15
|
+
```bash
|
|
16
|
+
dsh --profile web --host 100.64.0.2 --port 3080
|
|
17
|
+
```
|
|
18
|
+
3. 双方各配置一个共享令牌(见下)。插件用 HMAC 签名每个请求,令牌本身不在网络上传输。
|
|
19
|
+
|
|
20
|
+
## 配置
|
|
21
|
+
|
|
22
|
+
对端、令牌、传输范围和上限最终都落在 profile 的 `cordis.patch.yml` 里;日常改动用侧栏面板即可,下面的 YAML 仅作为预置/备份参考。**注意:按 id 定位的 patch 会整体替换该行 config,因此同一行只写一份完整配置。**
|
|
23
|
+
|
|
24
|
+
令牌是**全局共享**的:一个实例一个 `token`,所有对端共用。旧版 `peers[].token` 若各对端取值相同会自动迁移到全局;取值不同则激活失败并提示手工合并。
|
|
25
|
+
|
|
26
|
+
```yaml
|
|
27
|
+
- id: dsh-ferry
|
|
28
|
+
name: dsh-ferry
|
|
29
|
+
config:
|
|
30
|
+
instance:
|
|
31
|
+
id: zenbook # 本实例身份,默认主机名
|
|
32
|
+
name: 笔记本
|
|
33
|
+
token: 所有实例共用的共享密钥 # 全局唯一,不再按 peer 配置
|
|
34
|
+
peers:
|
|
35
|
+
- id: workstation # 本机给对端起的标识,保证本机唯一即可
|
|
36
|
+
name: 工作站
|
|
37
|
+
url: http://100.64.0.3:3080
|
|
38
|
+
importRoots: # 允许对端写入的目录;默认 [~]
|
|
39
|
+
- /home/zapan
|
|
40
|
+
workspace:
|
|
41
|
+
incomingRoot: ~/dsh-ferry/incoming # 收到的工作区没有本机映射目录时放这里
|
|
42
|
+
git: auto # auto | include | skip
|
|
43
|
+
respectGitignore: true
|
|
44
|
+
exclude: [] # 额外排除的 glob
|
|
45
|
+
include: [] # 强制包含(不能绕过凭据/大小限制)
|
|
46
|
+
includeSecrets: false
|
|
47
|
+
maxFileBytes: 67108864 # 64 MiB
|
|
48
|
+
maxWorkspaceBytes: 1073741824 # 1 GiB
|
|
49
|
+
maxGitBytes: 67108864 # 64 MiB
|
|
50
|
+
symlinks: inside # inside | skip
|
|
51
|
+
dataDir: ~/.dsh/ferry # 备份与暂存根目录
|
|
52
|
+
timeouts:
|
|
53
|
+
requestMs: 1200000 # 单次传输上限 20 分钟
|
|
54
|
+
handshakeMs: 6000
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
两端都需配置对端条目(或至少让接收方持有发送方的令牌,见“安全模型”)。改完配置后重载插件或重启 `dsh web`。
|
|
58
|
+
|
|
59
|
+
## 使用
|
|
60
|
+
|
|
61
|
+
### 配置实例(在 UI 里,不用改配置文件)
|
|
62
|
+
|
|
63
|
+
1. 点左侧栏的 **dsh-ferry** 图标打开面板。
|
|
64
|
+
2. 在面板顶部填写**全局共享令牌**(所有实例一致),再点“添加实例”填名称和对端 DSH Web 地址(如 `http://100.64.0.3:3080`),点“保存配置”。
|
|
65
|
+
3. 面板会显示每个实例的在线状态和延迟;“刷新状态”重新探测。
|
|
66
|
+
配置会写回 profile 的 `cordis.patch.yml`,令牌不会返回给浏览器;留空即保持原令牌。
|
|
67
|
+
|
|
68
|
+
### 推送
|
|
69
|
+
|
|
70
|
+
会话左侧的 “...” 菜单里点 **推送到…**,弹出「推送到其他实例」对话框。
|
|
71
|
+
- 打开时并发探测每个实例:在线状态、延迟,以及**这个会话在对方的状态**。
|
|
72
|
+
- 选择实例后自动给出推送方式,也可以手动改:
|
|
73
|
+
- **新建会话**:在对方创建独立副本,绝不改动对方已有内容;
|
|
74
|
+
- **追加更新**:沿用同一个会话 id,补上对方缺少的事件,并合并工作区。
|
|
75
|
+
- “合并已有目录(自动备份)”仅在新建时需要,默认开启。
|
|
76
|
+
- 传输在后台进行,右下角 toast 显示目标目录、文件数、追加事件数、未传输类别。
|
|
77
|
+
|
|
78
|
+
### 再次推送同一个会话
|
|
79
|
+
|
|
80
|
+
A 推送会话到 B、在 A 上继续之后再次推送,对话框会显示对方的状态并自动选好方式:
|
|
81
|
+
|
|
82
|
+
| 对方状态 | 追加更新 | 说明 |
|
|
83
|
+
| --- | --- | --- |
|
|
84
|
+
| 没有此会话 | 等同新建 | 用原 id 创建 |
|
|
85
|
+
| 对方是旧副本 | ✅ 默认 | 只追加缺失事件,工作区按文件哈希增量覆盖(先备份) |
|
|
86
|
+
| 对方内容一致 | ✅ | 0 条事件,只同步工作区文件 |
|
|
87
|
+
| 对方也续写过(分叉) | ❌ 只能新建 | 对方副本原样保留 |
|
|
88
|
+
| 对方副本更长 | ❌ 只能新建 | 或反过来从对方拉取 |
|
|
89
|
+
| 对方正在运行该会话 | ❌ 只能新建 | 先停止才能追加 |
|
|
90
|
+
|
|
91
|
+
判定依据是事件数 + 尾部 8 条事件指纹;真正导入前还会逐条校验前缀,不一致直接拒绝。追加只追加事件,不改会话头部:对方的 cwd、标题、模型预设保持原样。
|
|
92
|
+
|
|
93
|
+
### 拉取
|
|
94
|
+
|
|
95
|
+
在 dsh-ferry 面板的“拉取会话”区选择实例 → “加载会话” → 对目标会话点“拉取”,弹出对话框:
|
|
96
|
+
- 先显示本地是否已有该会话、差多少条事件,再选择 **新建副本** 或 **追加更新**。
|
|
97
|
+
- 目标目录留空时使用同一套自动映射规则;也可以显式填写本机目录。
|
|
98
|
+
- 需要写入非空目录时勾选“合并到已有目录”(仅新建时需要)。
|
|
99
|
+
|
|
100
|
+
### 自动映射规则
|
|
101
|
+
|
|
102
|
+
接收工作区时依次判断:
|
|
103
|
+
|
|
104
|
+
1. 请求里带显式目标目录 → 用它;
|
|
105
|
+
2. 源工作区路径**在本机已存在**且位于 `importRoots` 内 → 直接复用它(自然映射);
|
|
106
|
+
3. 否则 → 放到 `workspace.incomingRoot` 下,子目录名为 `<源目录名>-<会话 ID 前 8 位>`。
|
|
107
|
+
|
|
108
|
+
不会为了避免“没有映射”而凭空创建源机上的绝对路径。
|
|
109
|
+
|
|
110
|
+
命令行 / agent 侧:
|
|
111
|
+
|
|
112
|
+
```text
|
|
113
|
+
ferry_peers()
|
|
114
|
+
ferry_push(peer="workstation", session="session-…", targetDir="/home/zapan/proj", merge=true)
|
|
115
|
+
ferry_push(peer="workstation", session="session-…", mode="update") # 对方已有旧副本时追加
|
|
116
|
+
ferry_pull(peer="workstation", session="session-…", targetDir="/home/zapan/proj")
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### 合并、备份与幂等
|
|
120
|
+
|
|
121
|
+
- 目标目录不存在:直接创建。
|
|
122
|
+
- 目标目录已存在且非空:必须显式 `merge`(UI 勾选 / 工具参数),否则拒绝。
|
|
123
|
+
- `merge` 覆盖同名文件前,会先把旧文件备份到 `<dataDir>/backups/<时间戳>-<会话>/`,结果里返回备份路径。
|
|
124
|
+
- 同 id 且同内容的会话重复推送:默认返回 `exists`,不覆盖任何内容;显式 `mode=update` 时只同步工作区(0 条事件)。
|
|
125
|
+
- 对方是旧副本:`mode=update` 追加缺失事件(`status=updated`)。
|
|
126
|
+
- 同 id 但两边都已续写(分叉):拒绝追加,要求新建会话(`forkId` / `mode=new`)。拉取时同样规则。
|
|
127
|
+
- 任何校验和失败都会在写入工作区之前终止(先落暂存目录),已写入的文件会回滚。
|
|
128
|
+
|
|
129
|
+
## 单向可达:A 能访问 B,B 访问不了 A
|
|
130
|
+
|
|
131
|
+
这是 Tailscale/NAT/防火墙下很常见的情况。插件按“谁发起连接”工作,因此不需要反向连通:
|
|
132
|
+
|
|
133
|
+
| 想做的事 | 从哪边操作 | 是否可行 |
|
|
134
|
+
| --- | --- | --- |
|
|
135
|
+
| A 的会话 → B | 在 A 上推送 | ✅ |
|
|
136
|
+
| B 的会话 → A | 在 A 上拉取 | ✅ |
|
|
137
|
+
| B 的会话 → A | 在 B 上推送 | ❌ B 连不到 A |
|
|
138
|
+
| A 的会话 → B | 在 B 上拉取 | ❌ 同上 |
|
|
139
|
+
|
|
140
|
+
要点:
|
|
141
|
+
|
|
142
|
+
- **只有能主动访问对方的一侧需要配置 peer**。B 侧不需要添加 A,只需在面板里设置相同的全局令牌、`importRoots` 和接收目录,就能接收 A 的推送。
|
|
143
|
+
- B 的面板里 A 会显示“离线”,推送菜单里也没有 A——这是**单向连通下的正常表现**,不代表 A 不可达 B。真正的“在线”判定只对“本机 → 对端”这个方向有意义。
|
|
144
|
+
- 因此工作流是:**始终在 A 上操作**——A 推送自己的会话给 B,A 拉取 B 上的会话。B 负责接收和继续工作;需要把 B 的成果带回 A 时,回到 A 执行拉取。
|
|
145
|
+
- 网络层要反过来,需要自己加反向隧道(SSH `-R`、Tailscale subnet router 等);插件不处理网络,只负责在已可达的方向上搬运。
|
|
146
|
+
- 同一个会话在两个实例上各自被继续编辑后,**没有自动合并**:推送/拉取只会在“对方是本机的严格前缀”时追加缺失事件。两边都续写过就判定分叉并拒绝追加,必须用新建会话导入(谱系会分叉)。`archiveSource`(推送后归档源会话)可以减少“两边都在改”的错觉。
|
|
147
|
+
|
|
148
|
+
## 传输范围
|
|
149
|
+
|
|
150
|
+
只传“换一台机器继续干活所需的输入”,不传“本机环境的产物和秘密”。完整分析见 [docs/transfer-scope.md](docs/transfer-scope.md)。
|
|
151
|
+
|
|
152
|
+
**会传**:源码、文档、配置模板、lockfile、`AGENTS.md`、未提交改动、`.git`(有大小上限)、会话日志、会话引用的图片/附件、工作区内的相对符号链接。
|
|
153
|
+
|
|
154
|
+
**默认不传**:`node_modules` / `.venv` 等依赖树、`dist` / `build` / `target` 等构建产物、各类缓存、日志、`.idea` / `.vscode`、`.env` 与密钥文件(`.env.example` 例外)、超大文件、指向工作区外的符号链接、socket/FIFO 等特殊文件。
|
|
155
|
+
|
|
156
|
+
**不会迁移的运行时状态**:终端 PTY、后台 job、日程提醒、子会话、审批/提问、LSP/浏览器会话、被 spill 的大工具输出。它们不属于会话日志或工作区文件,目标机需要重新建立。
|
|
157
|
+
|
|
158
|
+
## 安全模型
|
|
159
|
+
|
|
160
|
+
- 每个请求用全局共享令牌做 HMAC-SHA256 签名,覆盖发送方 id、方法、完整路径、时间戳和随机数;接收方校验时间窗(默认 ±5 分钟)和重放。
|
|
161
|
+
- 令牌不出现在请求头或 URL 中。传输加密交给组网(Tailscale/WireGuard)或 HTTPS。
|
|
162
|
+
- 令牌是**fleet 级**凭据:持有它的任何实例都可以向本机推送,且签名里的“发送方 id”只是自称,并未被单独认证。令牌泄露等同整组实例失守;轮换时需要更新所有实例。对端可信度由 Tailscale ACL / 防火墙控制,不要把这个端口暴露到公网。
|
|
163
|
+
- 接收方只允许写入 `importRoots` 内的绝对路径;会解析符号链接的最长存在前缀,拒绝越界。
|
|
164
|
+
- 写入时逐个检查父目录不是符号链接,防止通过已存在的软链把文件写到工作区外。
|
|
165
|
+
- 凭据文件默认不传;`includeSecrets: true` 才会放开(不建议)。
|
|
166
|
+
- 发送方默认在 `handshake` 暴露实例名与 id,不暴露会话与文件。
|
|
167
|
+
|
|
168
|
+
## 协议
|
|
169
|
+
|
|
170
|
+
`/dsh-ferry/v1/*`(node:http,HMAC 认证):
|
|
171
|
+
|
|
172
|
+
| 端点 | 作用 |
|
|
173
|
+
| --- | --- |
|
|
174
|
+
| `GET /handshake` | 协议与实例身份(公开) |
|
|
175
|
+
| `POST /ping` | 签名校验 + 延迟 |
|
|
176
|
+
| `POST /sessions` | 对端可迁移会话列表 |
|
|
177
|
+
| `POST /describe` | 会话头部、工作区计划、附件统计、警告 |
|
|
178
|
+
| `POST /export` | 返回 gzip 打包流(manifest + 分帧载荷) |
|
|
179
|
+
| `POST /import/preflight` | 只做决策:create / merge / update / exists / 冲突,并返回对方状态 |
|
|
180
|
+
| `POST /import` | 接收打包流并落盘(`?targetDir=&merge=&forkId=&update=`) |
|
|
181
|
+
|
|
182
|
+
浏览器侧走 `/api/dsh-ferry/rpc`(Connection 自身认证,仅 owner),供设置页调用。
|
|
183
|
+
|
|
184
|
+
打包格式:`DSH-FERRY-BUNDLE/1`,gzip 流;首帧是 JSON manifest,之后按 manifest 顺序逐个“长度帧 + 内容”,每个文件带 SHA-256。零字节文件同样占一帧,保证顺序对齐。
|
|
185
|
+
|
|
186
|
+
## 安装
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
dsh plugin --profile web add dsh-ferry
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
或在 GUI 的插件页搜索 `dsh-ferry` 安装(安装后默认启用,侧栏出现图标)。
|
|
193
|
+
|
|
194
|
+
- 要求 DSH `>=0.2.1-rc.5 <0.3.0-0`、Node `>=20.3`(不满足时安装会被拒绝并提示豁免命令)。
|
|
195
|
+
- 两个实例都要装同一版本:追加更新依赖两端一致的状态判定协议,旧版对端只能新建副本。
|
|
196
|
+
- 升级:`dsh plugin --profile web add dsh-ferry@latest`,然后重启该实例。
|
|
197
|
+
|
|
198
|
+
## 开发与验证
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
node --test 'test/*.test.js'
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
测试覆盖:忽略规则与凭据识别、工作区计划、大小/符号链接策略、分帧与校验和(含零字节文件)、签名与重放、路径越界与符号链接父目录。
|
|
205
|
+
|
|
206
|
+
本次改动已在两个独立 DSH 实例(独立 `DSH_HOME`、不同端口)之间完成:
|
|
207
|
+
推送 1454 事件 + 158 个文件 + `.git`(约 2.2 MB 工作区)、拉取(含 `forkId`)、重复推送返回 `exists`、对方是旧副本时追加更新、分叉时拒绝追加、非空目录拒绝与 `merge` 导入、附件按内容寻址保留、导入后用 `describe` 完成日志重放校验。
|
|
208
|
+
配置链路也在独立 profile 中验证:UI 保存全局令牌/实例/接收目录 → 写入 `cordis.patch.yml` → 插件重载后配置生效;令牌留空保持不变、非法 URL 被拒绝且不落盘;旧的单值 per-peer 令牌迁移为全局令牌。
|
|
209
|
+
|
|
210
|
+
## 已知限制
|
|
211
|
+
|
|
212
|
+
- 只迁移会话日志与工作区目录;不迁移终端、后台任务、日程、子会话、spill 输出。
|
|
213
|
+
- 历史事件里的绝对路径不会重写。目标路径变化后,旧工具调用里的路径仍然指向源机路径,模型需要重新确认目录。
|
|
214
|
+
- `.git` 超过 `maxGitBytes` 时留在源机;目标已有 `.git` 时保留目标历史。
|
|
215
|
+
- 附件总字节超过 `maxAttachmentBytes` 时,超出部分不传,会话里会留下悬空引用并在结果中警告。
|
|
216
|
+
- 会话格式版本不一致时拒绝导入;两端需保持 DSH 版本一致。
|
|
217
|
+
- 单次上传是一整条 HTTP 请求。对端 Node 服务的 `requestTimeout` 默认 5 分钟,因此超大工作区在慢链路上可能超时;默认已排除依赖/构建产物,常规仓库不受影响。链路较慢时建议先缩小传输范围(`exclude` / `include`)。
|
|
218
|
+
|
|
219
|
+
## 发布到 npm(维护者)
|
|
220
|
+
|
|
221
|
+
`package.json` 已带 `publishConfig.registry`,所以本机 npm 指向镜像(如华为云、npmmirror)也不影响发布:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
npm login --registry=https://registry.npmjs.org/ # 或写入 granular token
|
|
225
|
+
npm publish # prepublishOnly 会先跑测试
|
|
226
|
+
npm view dsh-ferry version --registry=https://registry.npmjs.org/
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
发布前用 `npm pack --dry-run` 检查包内容(`files` 已限定为入口、`lib/`、`client.js`、`locale/`、`cordis.patch.yml`、`icon.svg`、README 和 `docs/`)。
|
|
230
|
+
版本号遵循语义化版本:改了协议/行为就走 minor,纯修复走 patch。
|