@twinsearth/w2m-dsh-plugin 0.5.8 → 0.6.1

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/PROTOCOL.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # W2M 线协议 v1(冻结)
2
2
 
3
- > 项目:**DSH: Windows2MacOS** · 版本 **0.0.1** · 本文件是 Rabbit / Localside / DSH 插件三方共同遵循的**唯一契约**。
3
+ > 项目:**DSH: Windows2MacOS** · **线协议版本 `1`**(`protocol_version = 1`,未变)· 本文件最后校对的软件版本 **`@twinsearth/w2m-dsh-plugin@0.5.9`** · 本文件是 Rabbit / Localside / DSH 插件三方共同遵循的**唯一契约**。
4
4
  > 任何一方改动本文件即为**破坏性变更**,必须同时升 `protocol_version`。
5
+ > 两个版本号不是一回事:**线协议版本**只在破坏性变更时上升,**软件版本**是写这份文件时校对过的发行版;软件版本比协议版本变得频繁得多,不要把它们混为一谈。
5
6
 
6
7
  ---
7
8
 
@@ -33,7 +34,7 @@
33
34
  | `POST` | `/v1/result` | Bearer | 回传结果信封 | 是(`dedupe_key` 去重) |
34
35
  | `POST` | `/v1/task` | Bearer | 提交任务(插件调用) | 否(每次新 `task_id`) |
35
36
  | `GET` | `/v1/devices` | Bearer | 设备表 | 是 |
36
- | `GET` | `/v1/tasks/{task_id}` | Bearer | 任务状态 + 六态聚合 | 是 |
37
+ | `GET` | `/v1/tasks/{task_id}` | Bearer | 任务状态 + 九态聚合 | 是 |
37
38
  | `GET` | `/v1/tasks/{task_id}/report?format=md\|json` | Bearer | 汇总报告 | 是 |
38
39
  | `GET` | `/v1/tasks?limit=N` | Bearer | 任务列表 | 是 |
39
40
 
@@ -139,7 +140,12 @@
139
140
  - 给了 → 只发给列出的机器;
140
141
  - 没给 → 发给**全部已配对设备**。
141
142
 
142
- **index 如何分配**:`replicate` → 所有机器 `index = 0`、`index_total = 1`;`split` → 按设备顺序 `index = i % index_total`。
143
+ **index 如何分配**(`src/relay/state.mjs` 的 `createTask`):`replicate` → 所有机器 `index = 0`、`index_total = 1`;`split` → 按设备顺序 `index = i % index_total`。
144
+
145
+ **四种 mode**:`replicate`(每台机器跑同一条命令)、`split`(按 index 切片)、`broadcast`(**只有一台**机器执行,由 `executor_machine_id` 指定;其余机器拿到 `state: "observing"` 的 lease,只被告知结果而**不执行**)、`pipeline`(每台机器按顺序跑完 `stages` 整条链,遇首个失败阶段即停;`stages` 仅在此 mode 下合法,最多 16 个阶段,且 `index_total` 必须为 1)。
146
+
147
+ > `observing` 是 **lease 状态**,不是 `outcome`:它持有该任务的一个位置并会被告知结果,但从未拿到命令,因此**不经过能力门**(拒绝一件没让你做的事不算拒绝),并且**被排除在每一个聚合步骤之外** —— 没跑过的机器永远不会被记成"结果一致"。
148
+
143
149
  能力门失败的机器仍会拿到一条 `state: "refused"` 的 lease,但**不会收到 offer**。
144
150
 
145
151
  ### 4.1.1 `GET /v1/tasks/{task_id}` 响应形状(初稿未定义,v0.0.1 补充)
@@ -167,7 +173,8 @@
167
173
  - **终态判定读 `aggregate.status`**(不是顶层 `status`,也没有顶层 `aggregate` 字符串)。
168
174
  - `machines[].outcome` 是**每台机器**的判定,`machines[].reasons[]` 是它被扣分的具体理由(例如 `"pre_tree_fingerprint != base_tree"`)。
169
175
  - `aggregate` 在结果到达前是 `pending`。**`pending` 与"某台机器仍 pending"都不算终态**:初稿的六态里 `partial` 会在第一台结果落地时立刻出现,此时其余机器仍在跑 —— 消费者必须同时检查 `machines[].outcome === "pending"`。
170
- - 六态之外还有两个仅作展示的值:`pending`(首条结果到达前)、`refused`(所有 lease 都被能力门拒绝)。
176
+ - **九态**(`AGGREGATE_STATUSES`,`src/relay/report.mjs`):`consistent`、`divergent`、`divergent-platform`、`degraded`、`failed`、`partial`、`timeout`、`cancelled`、`unverifiable`。其中 `timeout` / `cancelled` / `degraded` 是 v0.3.0 追加的三个**判定**,各自取代了一个说得不准确的说法:`timeout` 取代"永远 `pending`",`cancelled` 取代与 `failed` 混同,`degraded` 取代借用的 `partial`(后者同时意味着"还有机器没回")。
177
+ - 九态之外还有两个**仅作展示、不是判定**的值:`pending`(首条结果到达前,**唯一非终态**)、`refused`(所有 lease 都被能力门拒绝 —— 每台机器都已作答,等待不会改变结论,故按终态处理)。
171
178
 
172
179
  ### 4.2 `task.offer` 事件
173
180
  ```json
@@ -236,7 +243,7 @@ dedupe_key = sha256(task_id + "|" + index + "|" + command_hash + "|" + base_tree
236
243
  | `platform` | object | `{os, os_version, arch, shell, shell_version}` |
237
244
  | `caps` | object | 能力声明 |
238
245
  | `index` / `index_total` | int | replicate 固定 0/1 |
239
- | `mode` | `replicate`\|`split` | |
246
+ | `mode` | `replicate`\|`split`\|`broadcast`\|`pipeline` | |
240
247
  | `cwd_rel` | string | 相对项目根,正斜杠 |
241
248
  | `base_commit` | string | 中继广播 |
242
249
  | `base_tree` | string\|null | 期望工作区指纹 |
@@ -284,21 +291,32 @@ dedupe_key = sha256(task_id + "|" + index + "|" + command_hash + "|" + base_tree
284
291
  `base_commit` + `pre_tree_fingerprint` + `command_hash`。
285
292
  > `toolchain` / `platform` **不是锚**,它们走能力门。
286
293
 
287
- ### 6.3 六态聚合(Rabbit 侧)
294
+ ### 6.3 九态聚合(Rabbit 侧)
295
+
296
+ 步序是契约的一部分:Step 0 → 1 → 2 → 3,**永不合并**,因为顺序会改变结论。
297
+
288
298
  ```
289
299
  Step 0 任一机器 refused → 该机记 refused(不阻断其他机器)
300
+ 全部被拒 → 聚合 status = refused,Step 2/3 跳过
290
301
  Step 1 任一机器缺必需字段 或 base_commit≠期望 或
291
302
  pre_tree_fingerprint≠base_tree 或 command_hash 不一致 → unverifiable(拒绝汇总)
292
303
  Step 2 其余机器中:
293
304
  全部 ok → 进 Step 3
294
- 部分 ok、部分 {nonzero_exit,timeout,crashed} → partial
305
+ 部分 ok、部分 {nonzero_exit,timeout,crashed} → degraded (所有机器都已作答)
306
+ 部分 ok、部分失败,且仍有机器 pending → partial ("还有机器没回")
295
307
  全部 {nonzero_exit,timeout,crashed} → failed
308
+ 无人作答且已过 deadline → timeout (等够了,不会再来)
309
+ 操作者主动停止 → cancelled (不是故障,不得报成 failed)
296
310
  Step 3 比对全部"可比字段":
297
311
  全等 → consistent
298
312
  仅 toolchain/platform 不同且差异集中在 stdout 文本 → divergent-platform(期望内,不告警)
299
313
  其他差异 → divergent(列出字段与逐机取值)
300
314
  ```
301
315
 
316
+ **两个非判定值**:`pending`(首条结果到达前,唯一非终态)、`refused`(所有 lease 都被能力门拒绝)。
317
+
318
+ `degraded` 与 `partial` 的分工是 v0.3.0 修正的一处失真:`partial` 同时意味着"还有机器没回",于是一个**已结束但部分失败**的任务和**仍在进行**的任务在 `status` 这一个字段上无法区分。现在两者各有其名。
319
+
302
320
  ---
303
321
 
304
322
  ## 7. 错误响应统一形状
@@ -311,6 +329,9 @@ Step 3 比对全部"可比字段":
311
329
  | 码 | 含义 |
312
330
  |---|---|
313
331
  | `COMMAND_NOT_ALLOWED` | argv 不在该机的 default-deny 白名单内 |
332
+ | `COMMAND_REPARSING_ARGUMENT` | 白名单条目匹配,但**尾随实参**会让该工具加载条目从未指明的代码(`--test-reporter`、`--import`、`--require`、`-e`/`--eval`、`-p`/`--print`、`python -c`、`bash -c`、`git --exec-path` …)。要跑就必须把该实参写进条目本身 |
333
+ | `COMMAND_EXECUTABLE_PATH_MISMATCH` | `argv[0]` 是路径,但不是该条目可执行文件解析到的同一个文件(同名副本不是同一个程序) |
334
+ | `COMMAND_EXECUTABLE_RELATIVE_PATH` | `argv[0]` 是相对路径:工作目录由 offer 决定,因此它指向的文件随目录而变,白名单无法判定 |
314
335
  | `MISSING_<TOOL>` | 例如 `MISSING_PYTHON`、`MISSING_NPM`:能力门要求该工具,但该机探不到 |
315
336
  | `PLATFORM_MISMATCH` | 任务 `requirements.platform` 不含该机 OS |
316
337
  | `READ_ONLY_MACHINE` | 任务 `write: true`,但该机项目目录不可写(`fs.access(W_OK)` 失败) |
@@ -340,7 +361,13 @@ Step 3 比对全部"可比字段":
340
361
 
341
362
  ---
342
363
 
343
- ## 9. P2P 数据面加密(v0.6.0 新增,**附加式扩展,不升版本**)
364
+ ## 9. P2P 数据面加密(v0.5.3 新增,**附加式扩展,不升版本**)
365
+
366
+ > **版本号更正。** 本节此前标为 "v0.6.0 新增",这个标注是错的:**P2P 数据面加密的 stage 1 随
367
+ > v0.5.3(2026-10-11)发布**,`CHANGELOG.md` 的 `[0.5.3]` 条目就是它。`v0.6.0` 这个 tag 确实
368
+ > 存在,但它是其后的一次发行,内容是 REFUSE 的冲突处理、允许清单作为授权、以及 Windows 监督器的
369
+ > 锁 —— 加密不是它引入的。本节下文凡写 "v0.6.0" 处,指的是 **v0.5.3 起的这批加固**,而不是一个
370
+ > 版本号;不要因为本节挨着 v0.6.0 就把它算到 v0.6.0 的账上。
344
371
 
345
372
  > **不破坏冻结。** `protocol_version` 仍为 `1`,`envelope_version` 仍为 `1.0`。本节全部内容是
346
373
  > **可选对象 `p2p` 内部的新键**,以及对**已存在的可选字段** `p2p.encrypted` 的赋值规则;不知道
@@ -387,9 +414,14 @@ Step 3 比对全部"可比字段":
387
414
 
388
415
  | 帧 | body 布局(大端) | 长度 |
389
416
  |---|---|---|
390
- | `HELLO` | `A5 \| ver u8 \| session u32 \| e_i[32] \| idLen u8 \| machineId \| mac[48]` | 88 + idLen |
391
- | `HELLO_ACK` | 同上,`e_r` 替代 `e_i` | 88 + idLen |
392
- | `REFUSE` | `A5 \| ver u8 \| session u32 \| code u8 \| idLen u8 \| machineId` | 8 + idLen |
417
+ | `HELLO` | `A5 \| ver u8 \| session u32 \| e_i[32] \| idLen u8 \| machineId \| mac[48]` | 87 + idLen |
418
+ | `HELLO_ACK` | 同上,`e_r` 替代 `e_i` | 87 + idLen |
419
+ | `REFUSE` | `A5 \| ver u8 \| session u32 \| code u8 \| idLen u8 \| machineId[ \| mac 48]` | 8 + idLen,或 56 + idLen |
420
+
421
+ > 长度是 **87 + idLen**,不是 88:前缀 39 字节(magic 1 + ver 1 + session 4 + `e[32]` 32 + idLen 1),
422
+ > 加 `mac[48]` 共 87,再加 `idLen` 字节的 `machineId`。代码里就是
423
+ > `HANDSHAKE_PREFIX_BYTES = 1 + 1 + 4 + KEY_BYTES + 1`(39)加 `MAC_BYTES`(48),
424
+ > `test/p2p-crypto.test.mjs` 也按 `1+1+4+32+1+byteLength(machineId)+48` 断言。
393
425
 
394
426
  `mac` = `AEAD(k0, nonce=0, BLAKE2s-256(prologue ‖ ver u8 ‖ session u32 ‖ s_i.pub ‖ s_r.pub))`,48 字节
395
427
  (32 密文 + 16 tag)。`mac'` = 同 `k0`,消息为 `BLAKE2s-256(HELLO ‖ e_r)`。
@@ -402,7 +434,19 @@ Step 3 比对全部"可比字段":
402
434
  > 真实性无关。该省略以常量 `HANDSHAKE_MAC_FIELDS_OMITTED` 落在代码里,不是沉默。
403
435
 
404
436
  `REFUSE.code`:`1`=UNKNOWN_PEER、`2`=BAD_MAC、`3`=REPLAY、`4`=NO_KEY、`5`=VERSION、`6`=NO_PEER_KEY。
405
- `REFUSE` 本身**不认证**(它正是在无法认证对端时发出的),因此不携带任何证明。
437
+
438
+ `REFUSE` 的**可选尾部 48 字节 MAC**(v0.5.3 起的加固,MEDIUM-4 修复)用
439
+ `k_ref = HKDF-SHA256(ECDH(e_i, s_r))` 封装,消息为 `BLAKE2s-256(label ‖ 不含 MAC 的 REFUSE)`,其中
440
+ `label = "w2m-p2p-crypto v1 refuse"`。两端都能推导 `k_ref`:发起方持有 `e_i` 私钥与 `s_r` 公钥,
441
+ 响应方持有自己的 `s_r` 与 `HELLO` 里公开的 `e_i`——因此**即使响应方完全没有本机公钥**(`NO_PEER_KEY`
442
+ 的情形)也仍能给出证明;而 `k0` 在那种情形下根本不存在,这正是这个 MAC 必须用 `k_ref` 的原因。
443
+ `NO_PEER_KEY`/`UNKNOWN_PEER`/`BAD_MAC` 在可封装时一律封装;`VERSION` 保持裸体(需要对端的 body 布局
444
+ 本端无法担保)。因此「`REFUSE` 不认证」这句话只在**旧版本对端**意义上成立:可以没有 MAC,但规则是——
445
+ **要求明文的降级类拒绝**(`NO_KEY`/`NO_PEER_KEY`)必须**要么带通过验证的 `k_ref` 证明,要么不带 MAC
446
+ 且来自「已确认本次打洞的那个地址」**;**终结类拒绝**(`BAD_MAC`/`REPLAY`/版本不符)只要来自打过的地址
447
+ 就被采纳(无论是否封装),结果里用 `refusal_authenticated` 记下 `true`(证明通过)/`null`(无 MAC)/
448
+ `false`(有 MAC 但没通过),账本因此不会把「有人说」当成「对端证明」。丢弃的拒绝数据报只被记录、
449
+ 绝不使打洞失败——否则伪造一个数据报就成了比压制真实应答更便宜的拒绝服务。
406
450
 
407
451
  ### 9.4 数据面帧加密
408
452
 
@@ -432,10 +476,19 @@ frame = kind u8 | session u32 | A5 | epoch u8 | counter u64le | ciphertext | tag
432
476
  | `P2P_CRYPTO_HANDSHAKE_TIMEOUT` | 路径已通但握手未完成 → 不建通道 |
433
477
  | `P2P_CRYPTO_VERSION_MISMATCH` / `MALFORMED` | 版本或报文形状不可用 |
434
478
  | `P2P_CRYPTO_PLAINTEXT_PEER` | 对端未学得密钥(v0.5.x 情形)→ 明文通道 |
479
+ | `P2P_CRYPTO_REFUSE_SESSION_MISMATCH` | 收到的 `REFUSE` 未指名本次打洞的 session → **丢弃并记录**,不改变结果 |
480
+ | `P2P_CRYPTO_REFUSE_UNPUNCHED_SOURCE` | `REFUSE` 来自本次打洞从未打过的地址 → 丢弃并记录 |
481
+ | `P2P_CRYPTO_REFUSE_BAD_MAC` | 降级类 `REFUSE` 带 MAC 但验证失败 → 丢弃并记录(终结类拒绝不因 MAC 失败被丢) |
482
+ | `P2P_CRYPTO_REFUSE_UNCORROBORATED` | 未封装的降级类 `REFUSE` 并非来自「已确认本次打洞的地址」→ 丢弃并记录 |
435
483
 
436
484
  **回退规则**:握手失败 → 任务**照旧走中继**,信封记录 `p2p.encrypted: false` 与原因;机器**绝不**
437
485
  因为安全升级而丢失。唯一不允许的降级是"已持有对端密钥、握手失败后仍建明文通道"——那是一次攻击者
438
- 可触发的降级。
486
+ 可触发的降级。**这句在 v0.5.3 被收紧为更准确的表述**:握手*失败*本身绝不回落明文;唯一会给出明文
487
+ 通道的是对端明确声明"无法参与"(`NO_KEY`/`NO_PEER_KEY`),而这样的拒绝必须**要么带 `k_ref` 证明、
488
+ 要么来自已确认本次打洞的那个地址**。结果里同时记录 `refusal_authenticated`,让账本能区分
489
+ 「对端证明了自己」与「有人这么说」。仍然存在的残余风险(攻击者能从被打洞对端自己的地址与端口发出
490
+ **未封装**的拒绝)写在 `docs/P2P-CRYPTO.md` §2 与 §9 的最后,不再用绝对句掩盖。
491
+ 四个 `P2P_CRYPTO_REFUSE_*` 码都只记录、**绝不使打洞失败**:否则伪造一个数据报就比压制真实应答更便宜。
439
492
 
440
493
  ### 9.6 信封字段
441
494
 
@@ -455,7 +508,8 @@ frame = kind u8 | session u32 | A5 | epoch u8 | counter u64le | ciphertext | tag
455
508
  | 对端 | 结果 |
456
509
  |---|---|
457
510
  | v0.4.x / v0.5.x(无 `static_keys`) | 本端发**普通** `HELLO`,明文通道,任务照常完成,`p2p.encrypted: false` + 具名原因 |
458
- | v0.6.0(`static_keys` 齐备) | 握手完成,通道密封,`p2p.encrypted: true` |
459
- | v0.6.0,但本端无对端公钥 | 对端回 `REFUSE(6)`+普通 `HELLO_ACK` → 明文通道,原因为 `P2P_CRYPTO_NO_KEY` |
511
+ | v0.5.3 及以后,`static_keys` 齐备 | 握手完成,通道密封,`p2p.encrypted: true` |
512
+ | v0.5.3 及以后,但本端无对端公钥 | 对端回 `REFUSE(6)`+普通 `HELLO_ACK` → 明文通道,原因为 `P2P_CRYPTO_NO_KEY` |
460
513
  | 公钥错误 / 有人在冒充 | `REFUSE(2)` → **不建通道**,任务走中继,账本记 `encrypted: false` + `P2P_CRYPTO_BAD_MAC` |
514
+ | 第三方伪造 `REFUSE`(v0.5.9 MEDIUM-4) | 会话不符、地址未打过、MAC 不通过,或降级类拒绝未被同一地址确认 → 丢弃并记 `P2P_CRYPTO_REFUSE_*`,打洞结果不变 |
461
515
  | 自打洞(relay 把任务租给下发机自己) | 明文通道(自连接无路径可保护,且不得失败)
package/README.md CHANGED
@@ -65,8 +65,13 @@ when the network will not allow it.
65
65
  Both machines announce the address a STUN server sees them as, the dispatching
66
66
  machine punches to the other's candidate, and the task offer travels over the
67
67
  punched channel instead of the relay. The relay holds its own copy of that offer
68
- back for about a second while the punch is attempted, and offers it anyway if the
69
- punch does not land — so a failed punch costs latency, not the task.
68
+ back for **3000 ms** (`DEFAULT_P2P_OFFER_GRACE_MS`) while the punch is attempted,
69
+ and offers it anyway if the punch does not land — so a failed punch costs latency,
70
+ not the task. The constant was 1200 ms first and that was **too tight**: measured on
71
+ a 1-vCPU shared host, two agent tests saw the relay's copy overtake the direct one,
72
+ which under `p2pMode: direct` means refusing an offer that was about to arrive.
73
+ 3000 ms covers a slow host with room to spare, and the cost is bounded — one delayed
74
+ copy per task, once.
70
75
 
71
76
  The result *notice* returns the same way, with one stated limit: the frame carries
72
77
  the task and machine identity, not the result payload. `result_path` is one of the
@@ -95,27 +100,52 @@ socket — `endpoint-dependent`, punchable: false. A default that hard-required
95
100
  direct path would break every deployment on such a network the moment it
96
101
  upgraded. See [PROTOCOL-v0.4.0.md](PROTOCOL-v0.4.0.md) §1.
97
102
 
98
- ### Two coordination modes
103
+ ### Four coordination modes
104
+
105
+ Four — and the third and fourth are the ones people fail to find, which is why they are named here.
99
106
 
100
107
  | Mode | What it does | When to use |
101
108
  |---|---|---|
102
109
  | `replicate` | every machine runs the **same** command; results compared side by side | "does this change pass on both platforms?" |
103
110
  | `split` | work divided by `index` (`explicit` or `modulo` sharding) | one large test suite, several machines |
111
+ | `broadcast` | **exactly one** machine runs it, chosen with `executor_machine_id` (or the composer's selected target); every other machine holds an `observing` lease and is told the outcome | "run this migration on the database host, and tell everyone what happened" |
112
+ | `pipeline` | **every** machine runs the whole `stages` chain, in order, stopping at the first stage that fails | "check out, build, then test — as one task with one verdict" |
113
+
114
+ Two properties of the last two are worth stating because they are what stop them from being footguns:
115
+
116
+ * **Observers are never counted as agreeing.** An `observing` lease is excluded from every aggregation
117
+ step, so a machine that did not run the command cannot be reported as having produced the same result.
118
+ * **A pipeline has no cross-machine data flow.** It is `replicate` applied to a sequence: stage 2 on a
119
+ machine consumes that machine's stage 1, never a peer's. The alternative — fan stage 1 out, converge
120
+ the outputs into stage 2 — has no well-defined input on a fleet, and two machines finishing in a
121
+ different order would make one `task_id` describe two different executions. The reasoning is in
122
+ [docs/PIPELINE-DESIGN.md](docs/PIPELINE-DESIGN.md), which also records that the chain's `command_hash`
123
+ binds **every** stage, not just the first.
104
124
 
105
- ### Six aggregation states
125
+ ### Nine aggregation states
106
126
 
107
- Not four — because "the environments differ" and "the results differ" are
108
- different findings, and collapsing them turns noise into bug reports.
127
+ Nine, not four — because "the environments differ" and "the results differ" are different findings, and
128
+ collapsing them turns noise into bug reports. Three more were added in v0.3.0, each replacing a case that
129
+ used to be reported as something less true.
109
130
 
110
131
  | State | Meaning |
111
132
  |---|---|
112
133
  | `consistent` | every machine agreed on every comparable field |
113
134
  | `divergent` | machines disagreed — the report names the field and each machine's value |
114
135
  | `divergent-platform` | only toolchain/platform differ, and the difference is confined to stdout text — expected |
136
+ | `degraded` | every machine reported; some succeeded and some failed. Distinct from `partial`, which also means "still in flight" |
115
137
  | `failed` | every machine failed |
116
- | `partial` | some succeeded, some failed |
138
+ | `partial` | some succeeded, some failed, and machines are still out |
139
+ | `timeout` | the deadline passed and the machines that never answered are not coming back |
140
+ | `cancelled` | the operator stopped it — not a defect, so it must not be reported as `failed` |
117
141
  | `unverifiable` | an **anchor** did not match, so comparison is refused |
118
142
 
143
+ Two more values are **not verdicts**, because no terminal answer exists yet or none was ever possible:
144
+ `pending` (before the first result lands) and `refused` (every machine was refused at the capability
145
+ gate). `pending` is the only non-terminal one; `refused` reads like "still deciding" but every machine
146
+ has already answered, so waiting cannot change it. All nine verdicts are declared in
147
+ `src/relay/report.mjs`'s `AGGREGATE_STATUSES`.
148
+
119
149
  ## Install
120
150
 
121
151
  > **This package can run three ways, and all three start the same binaries.**
@@ -181,6 +211,28 @@ runs unless it matches a prefix:
181
211
  > split on whitespace — so `node -e console.log` does **not** allow
182
212
  > `node -e console.log(1)`, and a path containing a space has to be written as an
183
213
  > array entry. See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) §10.
214
+ >
215
+ > **What an entry actually grants — read this before trusting a list.** An entry
216
+ > names a tool and the arguments up to a point; whatever follows is still that
217
+ > tool's own behaviour, so `node --test` allows `node --test <any file>`, and the
218
+ > test runner loads and runs whatever file it is handed. **A prefix entry is not a
219
+ > sandbox**: it says which program may run, not what that program will do. Two
220
+ > narrower rules sit on top of the prefix match:
221
+ >
222
+ > * **Code-loading trailing arguments are refused** (`COMMAND_REPARSING_ARGUMENT`):
223
+ > `--test-reporter`, `--import`, `--require`, `-r`, `--experimental-loader`,
224
+ > `-e`/`--eval`, `-p`/`--print`, `python -c`/`-m`, `bash -c`, `git -c`/`--exec-path`,
225
+ > `make -f`, `tar --to-command`, and their equivalents in the interpreters in
226
+ > common use. If a machine is meant to run one of them, name it **in the entry**
227
+ > (`["node --test --test-reporter=tap"]`) — the entry is the explicit grant, and
228
+ > the refusal says so.
229
+ > * **The executable is compared by identity, not by spelling.** A bare name is
230
+ > resolved through PATH as before; an absolute path is admitted only when it
231
+ > resolves to the same file the entry's executable does (otherwise
232
+ > `COMMAND_EXECUTABLE_PATH_MISMATCH`), and a relative path is refused outright
233
+ > (`COMMAND_EXECUTABLE_RELATIVE_PATH`) because the working directory comes from
234
+ > the offer. A copy of some other program at `E:\Temp\evil\node.exe` is not
235
+ > `node`, however it is named.
184
236
 
185
237
  > ⚠️ **v0.0.1 note:** Localside is verified as a standalone process. Running it
186
238
  > *in-process* from the plugin (`autoStartAgent: true`) is implemented but has
@@ -198,7 +250,7 @@ dsh plugin --profile <profile-name> add @twinsearth/w2m-dsh-plugin
198
250
 
199
251
  ```bash
200
252
  dsh plugin --profile <profile-name> add \
201
- https://github.com/TwinsEarth/dsh-windows2macos/releases/download/v0.4.6/twinsearth-w2m-dsh-plugin-0.4.6.tgz
253
+ https://github.com/TwinsEarth/dsh-windows2macos/releases/download/v0.5.9/twinsearth-w2m-dsh-plugin-0.5.9.tgz
202
254
  ```
203
255
 
204
256
  **From the repository** — the form a marketplace's one-click installer uses (it clones
@@ -342,10 +394,14 @@ before 0.1.2.
342
394
  | `operator_token` | **only you** | **sending work** (`POST /v1/task`) |
343
395
 
344
396
  A machine that can be told what to do should not automatically be able to tell the
345
- others. The relay writes the operator token to `<state>/operator-token.txt` on
346
- first start and prints it once; the plugin takes it as `operatorToken`. Starting
347
- the relay with `--operator-token ''` removes the requirement — it warns loudly,
348
- and that is only appropriate for a trusted LAN.
397
+ others. The relay writes the operator token to `<state>/operator-token.txt` (mode
398
+ 0600) on first start and prints it once — on that first start only, because a
399
+ relay's stdout ends up in journald, a container log or a CI transcript and this
400
+ token authorises dispatching to the whole fleet. Every later start names the file
401
+ instead of the value; `--print-operator-token` (or `W2M_PRINT_OPERATOR_TOKEN=1`)
402
+ prints it anyway for an operator who needs it there. The plugin takes it as
403
+ `operatorToken`. Starting the relay with `--operator-token ''` removes the
404
+ requirement — it warns loudly, and that is only appropriate for a trusted LAN.
349
405
 
350
406
  ### Surviving a restart
351
407
 
@@ -367,8 +423,8 @@ Turn it on in the profile patch:
367
423
  ```yaml
368
424
  # ~/.dsh/profiles/<profile>/cordis.patch.yml
369
425
  - insert:
370
- - id: w2m-tools
371
- name: '@twinsearth/w2m-dsh-plugin/tools'
426
+ - id: w2m
427
+ name: '@twinsearth/w2m-dsh-plugin'
372
428
  config:
373
429
  rabbitUrl: 'http://100.x.y.z:8787'
374
430
  autoUpdate: true # default false
@@ -378,6 +434,16 @@ Turn it on in the profile patch:
378
434
  # updateRepo: TwinsEarth/dsh-windows2macos # default
379
435
  ```
380
436
 
437
+ > **The id is `w2m` and the name is the bare package — both matter, and neither is cosmetic.**
438
+ > `w2m` is the id the packaged `cordis.patch.yml` inserts, so a second row under a different id
439
+ > is a second plugin instance rather than a configuration of the first. `@twinsearth/w2m-dsh-plugin`
440
+ > must be the **bare** package name: a subpath such as
441
+ > `@twinsearth/w2m-dsh-plugin/tools` resolves to no `package.json`, so `dsh.client` is never read
442
+ > and **the client half is silently disabled** — the composer control never registers, and nothing
443
+ > on the host side reports a problem. `./tools` remains a supported mount point; it is simply not
444
+ > the one this bundle uses, and using it costs you the browser half. See
445
+ > [TROUBLESHOOTING.md](TROUBLESHOOTING.md) §2.
446
+
381
447
  Ask it what it is doing from any session with `w2m_update` (`action: "status"` to
382
448
  inspect, `action: "check"` to run one cycle now).
383
449
 
@@ -431,8 +497,11 @@ Read this before exposing the relay to anything.
431
497
  - **Commands are never shell strings.** argv arrays are passed to
432
498
  `spawn(cmd, args, { shell: false })`, so nothing a caller types can be
433
499
  reinterpreted as shell syntax.
434
- - **Default-deny whitelist.** A machine runs nothing that does not match
435
- `--allowed-commands`.
500
+ - **Default-deny whitelist, with its limit stated.** A machine runs nothing that
501
+ does not match `--allowed-commands`, and the matching rules are in §2 above.
502
+ The list controls *which program* may run; a prefix entry grants what that
503
+ program can be told to do with the arguments it is given (`node --test` runs
504
+ the test files it is handed), so it is a control, not a sandbox.
436
505
  - **Read-only by default.** `replicate` tasks run with `write: false`; there is
437
506
  no code path that writes to your project in that mode.
438
507
  - **TLS is your choice, but a deliberate one.** Shape A gets it from WireGuard;
@@ -496,19 +565,103 @@ a product.
496
565
 
497
566
  | Suite | Tests | What it covers |
498
567
  |---|---|---|
499
- | relay | 91 | pairing, auth, SSE with `ready`-first and `seq` replay, leases with heartbeat renewal and expiry, dedupe, all six aggregation states, report generation, operation token, rate limiting, persistence and restart recovery, TLS, and the lost-offer recovery path |
500
- | agent | 105 | whitelist allow/deny, timeout, output truncation, exit codes, anchors on clean and dirty trees, four concurrent fingerprint computations, spool, a 40-case URL join matrix, cursor lifecycle across relay restarts |
501
- | plugin | 73 | eight tools registered, schemas, typed errors, polling, operator-token enforcement (no request is sent without it), sub-path endpoints |
502
- | end to end + crossnetwork | 46 | two Localsides on two checkouts against one relay running real commands: consistent / divergent / failed / refused / unverifiable / deduped / long-lease / split, plus sub-path deployment, the two credential kinds, a SIGKILLed relay restarting with its ledger intact, pairing rate limiting, proxy-header trust boundaries, and the anti-buffering headers |
568
+ | relay | 142 | pairing, auth, SSE with `ready`-first and `seq` replay, leases with heartbeat renewal and expiry, dedupe, all **nine** aggregation states plus `pending`/`refused`, report generation, operator token, rate limiting, persistence and restart recovery, TLS, and the lost-offer recovery path |
569
+ | agent | 129 | allow-list allow/deny, timeout, output truncation, exit codes, anchors on clean and dirty trees, four concurrent fingerprint computations, spool, a 40-case URL join matrix, cursor lifecycle across relay restarts, and the bundled-runtime resolver |
570
+ | tools | 89 | eight tools registered, schemas, typed errors, polling, operator-token enforcement (no request is sent without it), sub-path endpoints, and the credential canaries |
571
+ | broadcast | 19 | exactly one executor, the rest `observing`; observers excluded from every aggregation step so a machine that did not run is never counted as agreeing |
572
+ | claims | 19 | the document claims themselves: the version, the bundle patch, the tool list, every documented tarball URL, and the CHANGELOG's shape |
573
+ | fleet-target | 37 | the composer's target selection, and the `executor_machine_id` narrowing that overrides it |
574
+ | client-bundle | 25 | the browser half builds, exports, and resolves against `PLATFORM_MODULES` |
503
575
  | schedule | 39 | the daily slots as exact UTC instants, zones that shift by 30 minutes for DST, a full simulated year of consecutive arming, and catch-up collapsing several missed slots into one run |
504
576
  | auto-update | 34 | the install/skip decision, no downgrade, prerelease refused, an unverified tarball refused, a failed lookup recorded as an error rather than as "current", and no token ever persisted |
505
577
  | update-source | 50 | version ordering, streaming SHA-256 verification, timeouts that really abort, rate-limit reporting, and one real GitHub API call (opt-in — see below) |
506
578
  | update-install | 28 | zero writes on a hash mismatch, byte-for-byte restore, atomic staging, dry run, and two real-pnpm integration runs in throwaway profiles |
507
- | update-wiring | 15 | configuration validation that names the setting, and one `ctx.effect` owning the timer whose disposer stops it |
508
-
509
- 435 unit tests total: 434 pass, 1 skip. Run them with `scripts/verify.ps1`
510
- (Windows) or `scripts/verify.sh` (macOS/Linux). The skip is one agent test that
511
- asserts POSIX mode bits, which NTFS does not carry.
579
+ | update-wiring | 34 | configuration validation that names the setting, and one `ctx.effect` owning the timer whose disposer stops it |
580
+ | signing | 33 | request signing: mismatch, expiry, replay and required-signature refusals |
581
+ | stress | 9 | sustained load on the relay's frame handling |
582
+ | shared-config | 16 | the fleet-wide config file, including its refusal of secret-looking keys |
583
+ | metrics / metrics-route | 16 + 5 | the Prometheus exposition and its route, with no credential ever rendered |
584
+ | tool-cards | 8 | the tool descriptions the model actually reads |
585
+ | p2p-stun | 18 | the STUN responder and reflexive-address discovery |
586
+ | p2p-transport | 28 | the reliable ordered channel over UDP |
587
+ | p2p-signaling | 17 | peer candidates: announce, fetch, expiry |
588
+ | p2p-node | 38 | the node that binds the punch socket, announces, punches and dials |
589
+ | p2p-transport-fields | 28 | `transport` / `p2p.offer_path` / `p2p.result_path` / `p2p.opened_by` on every result |
590
+ | p2p-plugin | 32 | the plugin half of the direct path, including the pushes and their named failures |
591
+ | p2p-crypto | 33 | stage 1 of the data-plane encryption: the handshake, the refusal codes, the replay window and the epoch ratchet |
592
+ | stun-server | 27 | the standalone `w2m-stun` binary |
593
+ | loader-entry | 10 | the entry DSH actually mounts, packed and installed — the gate v0.5.4 needed |
594
+ | mdns-wire / mdns-discover | 38 + 45 | the DNS wire codec, and LAN discovery |
595
+ | e2e + crossnetwork | 11 + 35 | two Localsides on two checkouts against one relay running real commands: consistent / divergent / failed / refused / unverifiable / deduped / long-lease / split, plus sub-path deployment, the two credential kinds, a SIGKILLed relay restarting with its ledger intact, pairing rate limiting, proxy-header trust boundaries, and the anti-buffering headers |
596
+ | signing-e2e | 8 | a signed task end to end |
597
+ | outbound-only | 7 | a running agent binds no TCP listener, and `relay` mode binds no UDP socket at all |
598
+ | recovery | 4 | restart and lost-offer recovery |
599
+ | pipeline-e2e | 2 | a real relay and a real agent running a chain: the stages append to a witness file, so the ordering evidence is written by the stages themselves; the second case proves the chain stops at the first failure |
600
+ | p2p-agent | 21 | the agent half of the direct path, including the reverse dial for results |
601
+
602
+ **The numbers above are from v0.5.9 on `win32 x64`, Node `v24.18.1`** — the floor this package
603
+ declares is `>=20.19.0`, and Node 24 is what DSH ships on this host. **36 suites on disk, 1,139 tests:**
604
+ 1,131 pass, **2 fail**, 6 skip. The two failures are `test/pipeline-e2e.test.mjs`'s two cases on this
605
+ Windows host; **everything else is green.** Three suites are environment-sensitive and their counts
606
+ move with it: `claims` is 19 tests here and **22 when `dist/` holds a packed tarball**, which is when
607
+ its two tarball-vs-`package.json` checks run instead of skipping — so a checkout with `dist/` measures
608
+ **1,142 tests / 1,134 pass / 4 skip**, and that is the number to expect from a release tree.
609
+
610
+ **Which script runs which suite matters, because they are not the same set:**
611
+
612
+ | Command | Suites | Result on this host (v0.5.9) |
613
+ |---|---|---|
614
+ | `npm test` — the unit suite | 29 | 1,051 tests, **all pass**, 6 skip |
615
+ | `npm run test:all` — units + end-to-end | 35 | 1,106 tests, 1,098 pass, **2 fail** (`pipeline-e2e`), 6 skip |
616
+ | `pwsh scripts/verify.ps1` — the acceptance run | 29 | 1,050 tests, 1,048 pass, **2 fail** (`pipeline-e2e`), 6 skip |
617
+
618
+ > **`test:all` and `verify.ps1` are not supersets of each other.** At v0.5.9 `test:all` was still
619
+ > missing `test/p2p-crypto.test.mjs` — 33 tests covering the data-plane encryption, absent from the
620
+ > suite a release runs, and `CHANGELOG.md`'s v0.5.9 entry says so instead of claiming otherwise.
621
+ > **v0.6.0 applied the one-line fix that entry asked its owner for**, so the file is now in `npm test`
622
+ > and in `test:all`; `verify.ps1`'s own suite list still does not name it.
623
+
624
+ `%DSH_HOME%\dsh-runtimes\` is absent on this host, which is why `verify.ps1` falls back to the
625
+ `node` on `PATH`; the resolver's own bundled-runtime case is exercised against a throwaway home in
626
+ `test/agent.test.mjs`, and “Not verified” below says what that does and does not prove.
627
+
628
+ > **`pipeline-e2e` fails 2/2 at the v0.5.9 tag on this host, and the cause is the test, not the
629
+ > product.** Both cases die with `ENOENT … order.txt` / `stop.txt`: the witness files the stages are
630
+ > supposed to append to were never created, because **no stage ever ran**. In that revision the suite
631
+ > passes its agent `--allowed-commands` as the *string* entry `process.execPath`
632
+ > (`pipeline-e2e.test.mjs`, `allowedCommands = [NODE]`). A string entry is split on whitespace **by
633
+ > design**, and on a machine whose Node lives under `C:\Program Files\`, that entry becomes the two
634
+ > tokens `C:\Program` and `Files\nodejs\node.exe`, which match nothing — so the agent refused the
635
+ > chain with `COMMAND_NOT_ALLOWED` before its first stage. It is therefore **not a Node-24 artifact**:
636
+ > it is a test defect that appears wherever the interpreter's path contains a space, and it is
637
+ > consistent with this suite passing on CI runners, whose Node is unpacked to a space-free path.
638
+ > **The working tree already repairs it** (array-form entry naming `-e`, plus a third case asserting
639
+ > that a stage outside the allow-list refuses the whole chain and runs nothing), and measured on this
640
+ > host that repair takes the suite to **3/3 pass**. The failure is recorded here because v0.5.9 is
641
+ > what a reader installs, and the honest label for the tag is **failing on Windows under
642
+ > `C:\Program Files`** rather than "passes".
643
+
644
+ **Skips are per platform, not a single number, and the difference is one test.** On this Windows host
645
+ (Node 24.18.1, `dist/` present, network tests not enabled) there are **4**:
646
+
647
+ | Suite | Skipped case | Why |
648
+ |---|---|---|
649
+ | agent | `writes 0600 where the platform honours POSIX bits` | NTFS carries no POSIX mode bits. **Skips on Windows; runs on macOS/Linux.** |
650
+ | agent | `starts a bundled-only tool from runArgv even when PATH cannot see it` | A POSIX shell script is not a Windows executable. **Skips on Windows; runs on macOS/Linux.** |
651
+ | claims | `every documented tarball exists as a release asset` | network check off unless `W2M_NETWORK_TESTS=1` |
652
+ | update-source | `fetches, parses and verifies the published release end to end` | same gate |
653
+
654
+ Two further cases are environment-conditional rather than platform-conditional, and skip only when
655
+ their input is missing: `claims`'s two tarball-vs-`package.json` checks run when `dist/` holds a
656
+ packed tarball and skip when it does not, and `agent`'s `git anchors` suite skips when `git` is not on
657
+ `PATH`. On this machine `git` is present, so that one does not fire.
658
+
659
+ The consequence, stated rather than generalised: **macOS and Linux run the two agent cases Windows
660
+ cannot, and skip `reports the windows filesystem honestly`, which is Windows-only**, so their skip
661
+ count is set by the same network gate plus whichever platform case applies — **it is not this host's
662
+ number, and calling it one is exactly the generalisation this section used to make.** No macOS or
663
+ Linux run was performed for this table; only the CI matrix measures those, and by this project's own
664
+ labelling rule the figure above is `[M]` for Windows and `[?]` for the other two.
512
665
 
513
666
  The one test that calls GitHub for real is **opt-in** (`W2M_NETWORK_TESTS=1`).
514
667
  GitHub allows 60 unauthenticated API requests per hour *per IP* and hosted CI
@@ -618,7 +771,7 @@ DSH 目前**不能**跨机器协作,而且不是疏忽:
618
771
  | **中继 = 会合点 + 账本 + 回落** | 中继仍然收下每一条结果:它是账本,账本不完整,比较就不可信 |
619
772
  | **默认只读**(`replicate` 且 `write: false`) | 一次性消除重复提交、覆盖写、推送竞争这三类最常见的事故 |
620
773
  | **三锚校验** | 实测:脏工作区上 `git checkout --detach <sha>` **退出码 0 却保留本地改动** —— 只看 commit SHA 会把"不是同一份代码"误判成"结果分歧" |
621
- | **六态聚合** | "环境不同"与"结果不同"是两件事,合并记账会把噪声当 bug |
774
+ | **九态聚合** | "环境不同"与"结果不同"是两件事,合并记账会把噪声当 bug;另有 `pending` / `refused` 两个非判定值不计入结论 |
622
775
  | **能力不匹配 → `refused`** | 拒绝执行,而不是静默降级 |
623
776
  | **长租约 + 进度心跳** | 固定超时会让跑 40 分钟的机器被判死并重投 → **两台同时跑、同时产生副作用** |
624
777
  | **零第三方运行时依赖** | 只用 `node:` 内置模块;下行 SSE + 上行 POST,不用 WebSocket |
@@ -640,7 +793,7 @@ node bin/w2m-localside.mjs --rabbit http://<中继地址>:8787 --pair PAIR-XXXXX
640
793
 
641
794
  # 3) 装 DSH 插件
642
795
  dsh plugin --profile <profile-name> add \
643
- https://github.com/TwinsEarth/dsh-windows2macos/releases/download/v0.4.3/twinsearth-w2m-dsh-plugin-0.4.3.tgz
796
+ https://github.com/TwinsEarth/dsh-windows2macos/releases/download/v0.5.9/twinsearth-w2m-dsh-plugin-0.5.9.tgz
644
797
  ```
645
798
 
646
799
  然后在 profile 的 `cordis.patch.yml` 里给插件 `rabbitUrl`(留空即用共享服务器)与 `operatorToken`,重启 DSH,即可直接用自然语言指挥: