@twinsearth/w2m-dsh-plugin 0.5.1 → 0.5.3
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 +62 -0
- package/PROTOCOL.md +122 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +117 -2
- package/docs/AUDIT.md +130 -0
- package/docs/P2P-CRYPTO.md +225 -0
- package/package.json +1 -1
- package/src/agent/agent.mjs +199 -5
- package/src/agent/crypto.mjs +1618 -0
- package/src/agent/p2p-node.mjs +276 -5
- package/src/agent/p2p.mjs +667 -40
- package/src/plugin/tools.mjs +200 -14
- package/src/relay/peers.mjs +56 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,68 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
5
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.5.3] — 2026-10-11
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
* **The peer-to-peer data plane can now be encrypted.** Stage 1 of `docs/P2P-CRYPTO.md`: each machine
|
|
12
|
+
holds an X25519 keypair beside its device identity, the relay carries each peer's static public key
|
|
13
|
+
inside the offer it already sends — so **nobody fills in a `[Peer]` block by hand** — and the direct
|
|
14
|
+
channel completes a two-message IK-shaped handshake before a single frame is trusted. Triple DH gives
|
|
15
|
+
mutual authentication and forward secrecy; ChaCha20-Poly1305 with WireGuard's nonce layout carries the
|
|
16
|
+
frames; a 64-message replay window and an HKDF epoch ratchet (2^20 messages or 120 s, previous key
|
|
17
|
+
retained 15 s) keep it going without a round trip in the data path.
|
|
18
|
+
* A handshake failure is **terminal for that peer, never a silent downgrade to plaintext**: a peer that
|
|
19
|
+
has no key for us gets the plain channel it would have had and the ledger records
|
|
20
|
+
`p2p.encrypted: false` with a reason. A machine is never lost to a security upgrade — only told that
|
|
21
|
+
the upgrade did not complete.
|
|
22
|
+
* `PROTOCOL.md` §9; `w2m_status` reports `p2p.encryption`.
|
|
23
|
+
|
|
24
|
+
### Notes
|
|
25
|
+
|
|
26
|
+
* **The tests earned their keep: they caught seven real defects**, including a replay window that
|
|
27
|
+
accepted replays of frame 2, a lookahead bound that capped our own send epoch, and a plain-ACK race
|
|
28
|
+
that could downgrade a live encrypted session to plaintext — the exact failure the design forbids, now
|
|
29
|
+
structurally impossible.
|
|
30
|
+
* `mac1`/`mac2` are **not** implemented: keyed BLAKE2s is not exposed by `node:crypto`. They mitigate
|
|
31
|
+
CPU-exhaustion denial of service, not confidentiality or authenticity, and the omission is a named
|
|
32
|
+
value in the code rather than a silence.
|
|
33
|
+
* Every socket in the new suite is loopback and crosses no NAT; that is stated in the suite header and
|
|
34
|
+
is not evidence that a real translation can be crossed.
|
|
35
|
+
* No protocol version bump: `envelope_version` stays `1.0` and a v0.4.x/v0.5.x peer is unaffected.
|
|
36
|
+
## [0.5.2] — 2026-10-10
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
* **The supervisor now says what it is doing, in a file.** A death that arrives with the console
|
|
41
|
+
cannot be logged from inside the process it kills — the log simply stops — so the supervisor
|
|
42
|
+
records `supervisor-heartbeat.json` (written atomically) with its state (`running` / `backoff` /
|
|
43
|
+
`degraded` / `stopped`), the child pid, restarts within ten minutes, the last exit code, and a note
|
|
44
|
+
that `3221225786` is `0xC000013A` rather than a crash of the agent logic. After three exits inside
|
|
45
|
+
ten minutes the state becomes `degraded` and the backoff grows to five minutes: not "give up", but
|
|
46
|
+
"stop hammering and say so", so the fleet sees a machine that is present and unwell instead of one
|
|
47
|
+
that vanished.
|
|
48
|
+
* **Orphan agents are reaped at start.** Detaching the agent (v0.5.1) is what stops a console
|
|
49
|
+
teardown from taking the machine off the fleet, and it leaked: three agents with one identity were
|
|
50
|
+
found running at once, the newest unable to bind the punch port (`P2P_BIND_FAILED: EADDRINUSE
|
|
51
|
+
0.0.0.0:41235`) because an orphan held it, while `MultipleInstances IgnoreNew` kept a fresh
|
|
52
|
+
supervisor from starting at all. The heartbeat names the last child, so a new supervisor terminates
|
|
53
|
+
that pid before it spawns anything.
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
|
|
57
|
+
* **`docs/AUDIT.md`** — what is implemented, what is running and how it works, with every claim
|
|
58
|
+
labelled `[M]` measured live, `[T]` asserted by the suites, or `[?]` **not verified**. It records
|
|
59
|
+
the uncomfortable half on purpose: A and B have no live proof yet, the macOS machine's state is
|
|
60
|
+
unconfirmed, the WireGuard hub has never seen a handshake, the session-0 service has never been
|
|
61
|
+
executed, and the link from the Windows machine to the relay is intermittently down. Its last
|
|
62
|
+
section is the commands to re-run each check, so the next reader need not trust the snapshot.
|
|
63
|
+
|
|
64
|
+
### Notes
|
|
65
|
+
|
|
66
|
+
* This release exists because `deploy/` and `docs/` are inside the published package: after v0.5.1 the
|
|
67
|
+
tag and the tarball had drifted, and a release is the only honest way to close that.
|
|
68
|
+
* No runtime change to the eight tools.
|
|
7
69
|
## [0.5.1] — 2026-10-10
|
|
8
70
|
|
|
9
71
|
### Fixed
|
package/PROTOCOL.md
CHANGED
|
@@ -337,3 +337,125 @@ Step 3 比对全部"可比字段":
|
|
|
337
337
|
- 不做写回合并(`write: true` 时仅记录分支名,不做 merge)
|
|
338
338
|
- 不做跨机浏览器看板(那是设计文档的 P6)
|
|
339
339
|
- 不做 Mac 真机验证(本机无 Mac;代码跨平台但需用户在 Mac 上复验)
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
## 9. P2P 数据面加密(v0.6.0 新增,**附加式扩展,不升版本**)
|
|
344
|
+
|
|
345
|
+
> **不破坏冻结。** `protocol_version` 仍为 `1`,`envelope_version` 仍为 `1.0`。本节全部内容是
|
|
346
|
+
> **可选对象 `p2p` 内部的新键**,以及对**已存在的可选字段** `p2p.encrypted` 的赋值规则;不知道
|
|
347
|
+
> 这些键的一端(v0.4.x / v0.5.x)会原样忽略它们,行为与本文件冻结时一致。
|
|
348
|
+
> 设计契约见 `docs/P2P-CRYPTO.md`(§4 是规范,§7 是验收清单)。
|
|
349
|
+
|
|
350
|
+
### 9.1 设备静态密钥
|
|
351
|
+
|
|
352
|
+
| 项 | 值 |
|
|
353
|
+
|---|---|
|
|
354
|
+
| 算法 | X25519(`generateKeyPairSync('x25519')`) |
|
|
355
|
+
| 私钥存放 | 与 `device.json` 同目录的 `device.key`,权限 0600(**Windows/NTFS 无 POSIX 位,见下**) |
|
|
356
|
+
| 生成时机 | 首次配对时生成,此后永不更换;文件损坏 → 硬错误,**绝不静默换新**(换新会使所有认识旧公钥的peer拒绝本机) |
|
|
357
|
+
| 公钥编码 | 32 字节 X25519 公钥的标准 base64(44 字符,含 `=` 填充) |
|
|
358
|
+
|
|
359
|
+
`device.key` 内容(JSON,原子写入):
|
|
360
|
+
```json
|
|
361
|
+
{ "format": "w2m-device-key/1", "algo": "x25519", "machine_id": "<uuid>", "private_key": "<base64 32B>" }
|
|
362
|
+
```
|
|
363
|
+
只存私钥标量,公钥由私钥推导——手改文件无法发布与私钥不匹配的公钥。
|
|
364
|
+
|
|
365
|
+
**0600 的诚实表述**:在 POSIX 平台上按 0600 写入。**Windows 无 POSIX 权限位**,`chmod` 是
|
|
366
|
+
尽力而为且不报错,该文件的实际保护来自用户 profile 目录继承的 ACL,而非本实现。能读到该 profile
|
|
367
|
+
目录的本地用户即可读到该私钥。
|
|
368
|
+
|
|
369
|
+
### 9.2 公钥分发(无人手工填 `[Peer]`)
|
|
370
|
+
|
|
371
|
+
公钥随**已有的**两条信令一起走,没有新端点:
|
|
372
|
+
|
|
373
|
+
| 位置 | 字段 | 说明 |
|
|
374
|
+
|---|---|---|
|
|
375
|
+
| `POST /v1/peer/announce` 请求体 | `static_key` | 可选。非 32 字节 base64 X25519 公钥 → `BAD_REQUEST`(`BAD_STATIC_KEY`)。**缺省即缺省**,不写 `null`——"没有密钥"与"密钥未知"不能同值 |
|
|
376
|
+
| `GET /v1/peer/{machine_id}` 响应 `peer` | `static_key` | 与 `candidates` 同级;该机未公告密钥时**该键不存在** |
|
|
377
|
+
| `POST /v1/task` 请求体 `p2p` | `static_keys` | `{ "<machine_id>": "<base64 公钥>" }`。中继按既有规则**原样转发**(`validateTaskRouting` 只校验 `p2p.mode` 并整体复制),不解释该键 |
|
|
378
|
+
| `task.offer` 帧 `p2p` | `static_keys` | 同上;下发帧在此基础上追加**该帧目标机**的公钥 |
|
|
379
|
+
|
|
380
|
+
一台机器由此从"要求它谈话的那份 offer"里学到**恰好那些 peer** 的公钥,无需任何本地配置。
|
|
381
|
+
|
|
382
|
+
### 9.3 握手(两帧,Noise IK 形状)
|
|
383
|
+
|
|
384
|
+
握手消息**寄生在既有帧类型内**:`HELLO`/`HELLO_ACK` 的 **body 首字节为魔数 `0xA5`** 即为握手消息;
|
|
385
|
+
**body 为空**即为 v0.5.x 的普通打洞。v0.5.x 端从不读 body,因此对握手 `HELLO` 会回普通 `HELLO_ACK`;
|
|
386
|
+
本端在无对端密钥时也回普通 `HELLO_ACK`。两端都无需预先知道对方是哪个版本。
|
|
387
|
+
|
|
388
|
+
| 帧 | body 布局(大端) | 长度 |
|
|
389
|
+
|---|---|---|
|
|
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 |
|
|
393
|
+
|
|
394
|
+
`mac` = `AEAD(k0, nonce=0, BLAKE2s-256(prologue ‖ ver u8 ‖ session u32 ‖ s_i.pub ‖ s_r.pub))`,48 字节
|
|
395
|
+
(32 密文 + 16 tag)。`mac'` = 同 `k0`,消息为 `BLAKE2s-256(HELLO ‖ e_r)`。
|
|
396
|
+
`k0 = HKDF-SHA256(ECDH(s_i, s_r))`;会话密钥 `k = HKDF-SHA256(ECDH(e_i,e_r) ‖ ECDH(s_i,e_r) ‖ ECDH(e_i,s_r))`
|
|
397
|
+
展开为四个互不相同的 32 字节子密钥(两个握手 MAC、两个方向的数据密钥)。
|
|
398
|
+
|
|
399
|
+
> **相对 `docs/P2P-CRYPTO.md` §4 的两处偏离,均属更强而非更弱**:MAC 额外绑定了 `session` 与握手
|
|
400
|
+
> 版本(否则同一对机器在任何一次打洞中捕获的 `HELLO` 都可在下一次重放);`mac1`/`mac2` **未实现**
|
|
401
|
+
> ——keyed BLAKE2s 未被 `node:crypto` 暴露,它们缓解的是握手路径的 **CPU 耗尽 DoS**,与机密性、
|
|
402
|
+
> 真实性无关。该省略以常量 `HANDSHAKE_MAC_FIELDS_OMITTED` 落在代码里,不是沉默。
|
|
403
|
+
|
|
404
|
+
`REFUSE.code`:`1`=UNKNOWN_PEER、`2`=BAD_MAC、`3`=REPLAY、`4`=NO_KEY、`5`=VERSION、`6`=NO_PEER_KEY。
|
|
405
|
+
`REFUSE` 本身**不认证**(它正是在无法认证对端时发出的),因此不携带任何证明。
|
|
406
|
+
|
|
407
|
+
### 9.4 数据面帧加密
|
|
408
|
+
|
|
409
|
+
启用后**所有帧类型**(`DATA`/`ACK`/`DONE`/`PING`/`PONG`/`BYE`)一律加密,不只 `DATA`:
|
|
410
|
+
|
|
411
|
+
```
|
|
412
|
+
frame = kind u8 | session u32 | A5 | epoch u8 | counter u64le | ciphertext | tag[16]
|
|
413
|
+
```
|
|
414
|
+
- 前 5 字节**明文但参与认证**(AAD):改 kind 或 session 即验证失败。
|
|
415
|
+
- nonce = `00000000 ‖ counter u64le`(12 字节),ChaCha20-Poly1305,tag 16 字节。整帧开销 **26 字节**。
|
|
416
|
+
- `counter` 每方向独立、从 0 起、**跨 epoch 单调不减**(不复位)。
|
|
417
|
+
- 64 条滑动重放窗口;窗口外或已见过的计数器 → 丢弃(`P2P_CRYPTO_REPLAYED_FRAME`)。
|
|
418
|
+
- **重协商(rekey)**:发送满 2^20 帧或满 120 秒即 `epoch+1`,双方各自沿 HKDF 链推导,无需往返;
|
|
419
|
+
旧 epoch 密钥保留 15 秒以接住在途帧。未认证帧**绝不**用其它 epoch 密钥重试。
|
|
420
|
+
|
|
421
|
+
### 9.5 拒绝与回退(**拒绝永远具名,回退永不静默**)
|
|
422
|
+
|
|
423
|
+
| 码 | 含义 |
|
|
424
|
+
|---|---|
|
|
425
|
+
| `P2P_CRYPTO_NO_KEY` | 本机无密钥,或对端无本机密钥 → 明文通道,已记录 |
|
|
426
|
+
| `P2P_CRYPTO_UNKNOWN_PEER` | 握手来自本机未持有公钥的 `machine_id` |
|
|
427
|
+
| `P2P_CRYPTO_BAD_MAC` | 对端已知但证明不成立(密钥过期或有人在冒充)→ **不建通道** |
|
|
428
|
+
| `P2P_CRYPTO_REPLAY` | 同一 `e_i` 二次出现;重传会收到**逐字节相同**的应答 |
|
|
429
|
+
| `P2P_CRYPTO_AUTH_FAILED` | 数据帧 tag 校验失败 → 丢弃,不处理 |
|
|
430
|
+
| `P2P_CRYPTO_REPLAYED_FRAME` | 计数器已在窗口内 |
|
|
431
|
+
| `P2P_CRYPTO_STALE_EPOCH` / `EPOCH_TOO_FAR` | 密钥已过期 / epoch 超出推导上限 |
|
|
432
|
+
| `P2P_CRYPTO_HANDSHAKE_TIMEOUT` | 路径已通但握手未完成 → 不建通道 |
|
|
433
|
+
| `P2P_CRYPTO_VERSION_MISMATCH` / `MALFORMED` | 版本或报文形状不可用 |
|
|
434
|
+
| `P2P_CRYPTO_PLAINTEXT_PEER` | 对端未学得密钥(v0.5.x 情形)→ 明文通道 |
|
|
435
|
+
|
|
436
|
+
**回退规则**:握手失败 → 任务**照旧走中继**,信封记录 `p2p.encrypted: false` 与原因;机器**绝不**
|
|
437
|
+
因为安全升级而丢失。唯一不允许的降级是"已持有对端密钥、握手失败后仍建明文通道"——那是一次攻击者
|
|
438
|
+
可触发的降级。
|
|
439
|
+
|
|
440
|
+
### 9.6 信封字段
|
|
441
|
+
|
|
442
|
+
`p2p` 对象新增(均为可选;`encrypted` 恒存在):
|
|
443
|
+
|
|
444
|
+
| 字段 | 类型 | 含义 |
|
|
445
|
+
|---|---|---|
|
|
446
|
+
| `encrypted` | bool | **仅当**该任务的直连通道确实完成握手并被密封时为 `true` |
|
|
447
|
+
| `encryption_reason` | string | `encrypted: false` 时的具名原因(`P2P_CRYPTO_*` 或直接路径未建立的原因) |
|
|
448
|
+
| `encryption_session` | number | 可选。承载该任务的会话 id |
|
|
449
|
+
|
|
450
|
+
`encrypted` 进入 `p2p` 块,因此**在 `envelope_sha256` 覆盖范围之内**——中继无法在不破坏哈希的前提下
|
|
451
|
+
改写它。它也**不进入** `COMPARABLE_FIELDS`:传输路径不是执行结果。
|
|
452
|
+
|
|
453
|
+
### 9.7 兼容性矩阵
|
|
454
|
+
|
|
455
|
+
| 对端 | 结果 |
|
|
456
|
+
|---|---|
|
|
457
|
+
| 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` |
|
|
460
|
+
| 公钥错误 / 有人在冒充 | `REFUSE(2)` → **不建通道**,任务走中继,账本记 `encrypted: false` + `P2P_CRYPTO_BAD_MAC` |
|
|
461
|
+
| 自打洞(relay 把任务租给下发机自己) | 明文通道(自连接无路径可保护,且不得失败)
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
//
|
|
36
36
|
// Nothing here is Windows-specific except the defaults; the same supervisor works on POSIX hosts.
|
|
37
37
|
import { spawn } from 'node:child_process';
|
|
38
|
-
import { createWriteStream, existsSync, mkdirSync, openSync, readFileSync } from 'node:fs';
|
|
38
|
+
import { createWriteStream, existsSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
39
39
|
import { dirname, join } from 'node:path';
|
|
40
40
|
|
|
41
41
|
/** Minimal `--flag value` / `--flag` parser: no dependency, no surprises. */
|
|
@@ -176,7 +176,108 @@ for (const signal of ['SIGINT', 'SIGTERM', 'SIGBREAK']) {
|
|
|
176
176
|
});
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
// Declared before the heartbeat block, which reads it: a `let` referenced earlier in the same scope
|
|
180
|
+
// would be a temporal-dead-zone throw, not an undefined value.
|
|
179
181
|
let runs = 0;
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------------------------
|
|
184
|
+
// HEARTBEAT AND DEGRADED MARKER
|
|
185
|
+
//
|
|
186
|
+
// WHY FILES AND NOT LOG LINES: the failure this exists for is a console-teardown death, and a death
|
|
187
|
+
// that arrives with the console cannot be logged from inside the process it kills -- the log simply
|
|
188
|
+
// stops. A file that an outside observer reads has no such dependency, so a watchdog task, a human,
|
|
189
|
+
// or (later) the fleet can answer "is this machine working?" without parsing a log that may itself be
|
|
190
|
+
// truncated.
|
|
191
|
+
//
|
|
192
|
+
// The state is one of:
|
|
193
|
+
// running a worker is alive right now
|
|
194
|
+
// backoff between runs, within the healthy band
|
|
195
|
+
// degraded the crash-loop breaker has tripped: too many exits inside the window, so the fleet
|
|
196
|
+
// should see a machine that is present but unhealthy rather than one that is gone
|
|
197
|
+
//
|
|
198
|
+
// Written atomically (temp file + rename) so a reader never sees half a JSON document.
|
|
199
|
+
// ---------------------------------------------------------------------------------------------
|
|
200
|
+
const heartbeatPath = join(dirname(logPath), 'supervisor-heartbeat.json');
|
|
201
|
+
const degradedAfter = Number.isFinite(Number(pick('degraded-after', 3))) ? Number(pick('degraded-after', 3)) : 3;
|
|
202
|
+
const degradedWindowMs = 10 * 60_000;
|
|
203
|
+
const degradedBackoffMs = Number.isFinite(Number(pick('degraded-backoff-ms', 300_000)))
|
|
204
|
+
? Number(pick('degraded-backoff-ms', 300_000))
|
|
205
|
+
: 300_000;
|
|
206
|
+
const exitTimes = [];
|
|
207
|
+
|
|
208
|
+
function writeHeartbeat(patch) {
|
|
209
|
+
const recent = exitTimes.filter((at) => Date.now() - at < degradedWindowMs).length;
|
|
210
|
+
const state = patch.state ?? (recent >= degradedAfter ? 'degraded' : 'backoff');
|
|
211
|
+
const record = {
|
|
212
|
+
updated_at: stamp(),
|
|
213
|
+
state,
|
|
214
|
+
supervisor_pid: process.pid,
|
|
215
|
+
run: patch.run ?? runs,
|
|
216
|
+
child_pid: patch.child_pid ?? null,
|
|
217
|
+
child_started_at: patch.child_started_at ?? null,
|
|
218
|
+
restarts_total: Math.max(0, runs - 1),
|
|
219
|
+
restarts_within_10min: recent,
|
|
220
|
+
last_exit_code: patch.last_exit_code ?? null,
|
|
221
|
+
last_exit_at: patch.last_exit_at ?? null,
|
|
222
|
+
// The known killer, recorded in decimal because that is how Node reports it: 3221225786 is
|
|
223
|
+
// 0xC000013A, STATUS_CONTROL_C_EXIT. Signed, it is -1073741510.
|
|
224
|
+
note: 'exit code 3221225786 means the console went away; it is not a crash of the agent logic',
|
|
225
|
+
project,
|
|
226
|
+
rabbit,
|
|
227
|
+
};
|
|
228
|
+
try {
|
|
229
|
+
const tmp = `${heartbeatPath}.tmp`;
|
|
230
|
+
writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
|
|
231
|
+
renameSync(tmp, heartbeatPath);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
say(`[w2m-supervisor] could not write the heartbeat: ${error.message}`);
|
|
234
|
+
}
|
|
235
|
+
return record;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
writeHeartbeat({ state: 'backoff' });
|
|
239
|
+
// ---------------------------------------------------------------------------------------------
|
|
240
|
+
// START-TIME REAPING -- the cost of detaching the agent, paid here
|
|
241
|
+
//
|
|
242
|
+
// Detaching the agent is what stops a console teardown from taking the machine off the fleet, and it
|
|
243
|
+
// has a price that was measured: every supervisor generation that dies without stopping its child
|
|
244
|
+
// leaves an orphan behind. Three agents with one identity were found running at once, the newest
|
|
245
|
+
// unable to bind the punch port (`P2P_BIND_FAILED: EADDRINUSE 0.0.0.0:41235`) because an orphan held
|
|
246
|
+
// it -- two streams on one device token, and a port that pins nothing.
|
|
247
|
+
//
|
|
248
|
+
// So the supervisor reaps what it did not start: the heartbeat names the last child, and if that pid
|
|
249
|
+
// is still alive when a new supervisor starts, it is a previous generation's orphan and is terminated
|
|
250
|
+
// before anything else runs. "Trust a graceful exit" is exactly the assumption that failed.
|
|
251
|
+
// ---------------------------------------------------------------------------------------------
|
|
252
|
+
try {
|
|
253
|
+
const previous = JSON.parse(readFileSync(heartbeatPath, 'utf8').replace(/^\uFEFF/, ''));
|
|
254
|
+
const orphan = Number(previous.child_pid);
|
|
255
|
+
if (Number.isInteger(orphan) && orphan > 0 && orphan !== process.pid) {
|
|
256
|
+
let alive = true;
|
|
257
|
+
try {
|
|
258
|
+
process.kill(orphan, 0);
|
|
259
|
+
} catch {
|
|
260
|
+
alive = false;
|
|
261
|
+
}
|
|
262
|
+
if (alive) {
|
|
263
|
+
try {
|
|
264
|
+
process.kill(orphan);
|
|
265
|
+
say(`[w2m-supervisor] reaped orphan agent pid=${orphan} left by an earlier supervisor`);
|
|
266
|
+
await new Promise((resolve) => {
|
|
267
|
+
setTimeout(resolve, 1500);
|
|
268
|
+
});
|
|
269
|
+
} catch (error) {
|
|
270
|
+
say(`[w2m-supervisor] could not reap orphan pid=${orphan}: ${error.message}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
// No heartbeat yet, or unreadable: nothing to reap, and that is not an error worth stopping for.
|
|
276
|
+
}
|
|
277
|
+
// A periodic beat, so "the supervisor itself is alive but a worker is wedged" is visible too.
|
|
278
|
+
const beat = setInterval(() => writeHeartbeat({}), 30_000);
|
|
279
|
+
beat.unref();
|
|
280
|
+
|
|
180
281
|
while (!stopping) {
|
|
181
282
|
runs += 1;
|
|
182
283
|
const startedAt = Date.now();
|
|
@@ -208,6 +309,7 @@ while (!stopping) {
|
|
|
208
309
|
stdio: ['ignore', logFd, logFd],
|
|
209
310
|
windowsHide: true,
|
|
210
311
|
});
|
|
312
|
+
writeHeartbeat({ state: 'running', child_pid: child.pid ?? null, child_started_at: stamp() });
|
|
211
313
|
|
|
212
314
|
const result = await new Promise((resolve) => {
|
|
213
315
|
child.on('exit', (exitCode, signal) => resolve({ exitCode, signal }));
|
|
@@ -216,6 +318,7 @@ while (!stopping) {
|
|
|
216
318
|
resolve({ exitCode: null, signal: null });
|
|
217
319
|
});
|
|
218
320
|
});
|
|
321
|
+
exitTimes.push(Date.now());
|
|
219
322
|
|
|
220
323
|
const lived = Math.round((Date.now() - startedAt) / 1000);
|
|
221
324
|
say(
|
|
@@ -223,10 +326,22 @@ while (!stopping) {
|
|
|
223
326
|
`${stopping ? ' (stopping)' : ' -- restarting'}`,
|
|
224
327
|
);
|
|
225
328
|
if (stopping) break;
|
|
329
|
+
const record = writeHeartbeat({ last_exit_code: result.exitCode, last_exit_at: stamp() });
|
|
330
|
+
// Degraded is not "give up": it is "stop hammering and say so". The marker file is what makes the
|
|
331
|
+
// difference visible from outside -- the fleet can then see a machine that is present and unwell
|
|
332
|
+
// instead of one that silently vanished, which is the failure this whole file exists for.
|
|
333
|
+
const wait = record.state === 'degraded' ? degradedBackoffMs : backoffMs;
|
|
334
|
+
if (record.state === 'degraded') {
|
|
335
|
+
say(
|
|
336
|
+
`[w2m-supervisor] degraded: ${record.restarts_within_10min} exits within 10 minutes -- ` +
|
|
337
|
+
`backing off ${Math.round(wait / 1000)}s and marking ${heartbeatPath}`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
226
340
|
// Deliberately NOT unref'ed: an unref'ed timer with nothing else pending lets Node exit during the
|
|
227
341
|
// backoff, which is the silent death this loop exists to prevent.
|
|
228
342
|
await new Promise((resolve) => {
|
|
229
|
-
setTimeout(resolve,
|
|
343
|
+
setTimeout(resolve, wait);
|
|
230
344
|
});
|
|
231
345
|
}
|
|
346
|
+
writeHeartbeat({ state: 'stopped' });
|
|
232
347
|
log.end();
|
package/docs/AUDIT.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Audit — what is implemented, what is running, and how it works
|
|
2
|
+
|
|
3
|
+
Written 2026-10-10 (UTC) against `main` at `460c437`, plus the live behaviour observed during the
|
|
4
|
+
session that built v0.4.1 … v0.5.1. Every claim is labelled with how it is known, because an audit
|
|
5
|
+
whose evidence cannot be told apart from its assumptions is worse than no audit:
|
|
6
|
+
|
|
7
|
+
* **[M]** measured live on the three-machine fleet during the session,
|
|
8
|
+
* **[T]** asserted by the repository's own test suites or CI,
|
|
9
|
+
* **[?]** **not verified** — stated as an open item rather than assumed.
|
|
10
|
+
|
|
11
|
+
Re-run the checks in §5 rather than trusting this file; it records a moment, not a guarantee.
|
|
12
|
+
|
|
13
|
+
## 1. Designed features: implemented?
|
|
14
|
+
|
|
15
|
+
| Designed feature | State | Evidence |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| Eight DSH tools (`w2m_devices`, `run`, `wait`, `report`, `status`, `update`, `history`, `stats`) | ✅ | **[T]** `scripts/verify-installed.mjs` on the packed artifact (`PASS: 8 tools registered`), plus a test that every tool returns a **string** (the host refuses anything else) |
|
|
18
|
+
| Four modes: replicate / split / broadcast / pipeline | ✅ | **[M]** replicate across three machines; **[T]** dedicated suites for the others |
|
|
19
|
+
| Nine aggregate verdicts | ✅ | **[M]** `consistent`, `divergent-platform`, `partial` observed live; **[T]** the rest covered by `test/relay`, `broadcast`, `recovery` |
|
|
20
|
+
| Anchor comparability (`base_commit` + `git-temp-index-tree/v1`) | ✅ | **[M]** Windows and Linux produced the **same** fingerprint `d6d39082…` for commit `a1e7dc0` — cross-platform comparison is real, not assumed |
|
|
21
|
+
| Default-deny allow-list, token-wise prefix match | ✅ | **[M]** allowed and refused commands observed on two machines; v0.4.1 unified the plugin's pre-flight with the agent's matcher |
|
|
22
|
+
| P2P direct path (punch / accept / dial, reliable fragmented channel) | ✅ | **[M]** `transport: p2p` between Windows and the Tokyo server, repeatedly |
|
|
23
|
+
| **A** simultaneous two-way punch, **B** reverse dial for results | ✅ code | **[T]** +17 tests, all passing (loopback). **[?]** **no live proof yet** — the fleet has not run this build on all three machines at once |
|
|
24
|
+
| Pinned punch port (`--p2p-port`) | ✅ | **[M]** `0.0.0.0:41235` bound on Windows after pinning |
|
|
25
|
+
| Windows deployment without elevation: supervisor + self-healing trigger | ✅ | **[M]** killing the supervisor left the agent alive with its socket bound; `every=PT5M` trigger registered |
|
|
26
|
+
| Supervisor heartbeat file + `degraded` marker + orphan reaping | ✅ | **[M]** marker reaches `degraded` after three exits; a clean start leaves exactly one supervisor and one agent, heartbeat `running` |
|
|
27
|
+
| Session-0 service mode (fully immune to console teardown) | ✅ script | **[?]** requires one elevation; **not executed** |
|
|
28
|
+
| **C** self-hosted WireGuard hub | ✅ server side | **[M]** `wg0` active on `:51820`, two peers configured. **[?]** no `latest handshake` has ever appeared: **no client has connected**, so rung 4 is not end-to-end |
|
|
29
|
+
| Delivery-ladder documentation + acceptance script | ✅ | **[M]** `deploy/networking/acceptance.mjs` ran against the live fleet; baseline recorded (all three machines on rung 5 for a relay-dispatched task) |
|
|
30
|
+
| npm publication with Trusted Publishing | ✅ | **[M]** `@twinsearth/w2m-dsh-plugin@0.5.1`, latest, with a SLSA provenance attestation |
|
|
31
|
+
| dsh-plugin.org listing | ✅ | **[M]** listed and marked *Verified*; the page's own snapshot is stale (submission issue #141 asks for a re-scan) |
|
|
32
|
+
| TUN/VPN mode | ❌ **deliberately not built** | Decision and reasoning in `deploy/networking/LESSONS.md`: a tunnel is hub-and-spoke, so it would make "the data path is peer-to-peer" false for exactly the traffic a tunnel exists to carry |
|
|
33
|
+
|
|
34
|
+
**Also not built, and known:** the ladder is not yet ordered *data* with per-rung budgets; the
|
|
35
|
+
plugin has no `p2pPort` of its own (only the agent CLI needs one, since a dispatcher only dials out).
|
|
36
|
+
|
|
37
|
+
## 2. Modules: running, and correct?
|
|
38
|
+
|
|
39
|
+
| Module | State | Notes |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| Relay `w2m-rabbit` (`:8787`) | ✅ active | **[M]** three devices tracked, ledger persisted across a restart (`revived_devices`) |
|
|
42
|
+
| STUN (`:3478/udp`) | ✅ active | |
|
|
43
|
+
| Localside `jp-shared` (Tokyo) | ✅ v0.5.x, streaming | Re-deployed from the GitHub release asset; sha256 matched the locally built tarball byte for byte |
|
|
44
|
+
| Localside `win-desktop` (this machine) | ✅ supervisor + agent, socket `41235`, heartbeat `running` | **[M]** after reaping three orphans that a previous generation had left behind |
|
|
45
|
+
| Localside `macmini` | **[?]** version and liveness unconfirmed | Four-line self-check for its owner is in `deploy/networking/README.md`'s section on rungs; it previously showed *online but not accepting work* (`expired` leases) |
|
|
46
|
+
| DSH plugin in this profile | ✅ v0.5.0 installed, eight tools live | Configuration is picked up by hot reload, verified via `w2m_status` twice |
|
|
47
|
+
| CI / Release / npm | ✅ | CI green on four platforms; Release #31 green; npm publish automatic via OIDC |
|
|
48
|
+
| v2ray-agent (same server, unrelated to this project) | ✅ xray active | TLS entry on **556** with live `accepted` log lines; nothing listens on 443, which that configuration does not need |
|
|
49
|
+
| WireGuard `wg0` | ✅ server, ❌ clients | No handshake yet (§1) |
|
|
50
|
+
| **Known environmental blocker** | ⚠️ | The link from this machine (an iPhone hotspot behind carrier-grade NAT) to Tokyo is intermittently unreachable — HTTP and SSH together. It takes the Windows machine off the fleet while it lasts, and it is the reason several checks above are marked **[?]** |
|
|
51
|
+
|
|
52
|
+
**Known defect classes found and fixed during the session, recorded because they are the kind that
|
|
53
|
+
recur:** the plugin's dispatch pre-flight could never match a multi-token allow-list entry; the punch
|
|
54
|
+
port could not be pinned; `w2m_update` returned an object where the host requires a string; the
|
|
55
|
+
Windows supervisor silently dropped every `-AgentArgs` value; a test read the punch port out of a
|
|
56
|
+
timestamp; and detaching the agent (which fixed the console-teardown death) leaked orphan agents.
|
|
57
|
+
Each is fixed with a test or a verification, and each is described in `CHANGELOG.md` under its
|
|
58
|
+
version.
|
|
59
|
+
|
|
60
|
+
## 3. How it works
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
DSH plugin (any machine) Relay (the shared server) Localside agent (each machine)
|
|
64
|
+
eight tools ──HTTPS────▶ /v1/task · leases · ledger · SSE ──▶ executes argv within the allow-list
|
|
65
|
+
│ ▲ │
|
|
66
|
+
└──── UDP hole punch: task offer + result ──────────────────────────────┘
|
|
67
|
+
└─ when no direct path opens, the relay carries it
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Control plane is centralised, data plane tries to be direct, and every task records which path it
|
|
71
|
+
took.** That sentence is the whole design; everything below is mechanism.
|
|
72
|
+
|
|
73
|
+
* **Two credentials, deliberately split.** The operator token authorises *dispatching* work; a
|
|
74
|
+
per-machine device token authorises *taking* and *reporting* it. Measured: reading a task back with
|
|
75
|
+
the operator token answers `401 missing or invalid device_token`. The credential that can order work
|
|
76
|
+
around cannot also read every machine's results.
|
|
77
|
+
* **Anchors make "same revision" provable.** The dispatcher fingerprints its work tree
|
|
78
|
+
(`git-temp-index-tree/v1`); each machine must reproduce it or the comparison is refused as
|
|
79
|
+
`unverifiable`. A peer that is merely on a different commit cannot produce a misleading "the results
|
|
80
|
+
differ".
|
|
81
|
+
* **The delivery ladder, recorded rung by rung.** A live channel, then a simultaneous two-way punch
|
|
82
|
+
(**A**), then a reverse dial for the result (**B**), then a tunnel (**C**), then the relay — with
|
|
83
|
+
`offer_path`, `result_path`, `p2p.opened_by` and per-rung reason codes saying which one served the
|
|
84
|
+
command and why the ones above it did not.
|
|
85
|
+
* **Verdicts are computed in a fixed order** (§6.3 of the protocol): refusal, then anchors, then
|
|
86
|
+
execution, then comparison. `divergent-platform` exists so that "the toolchains differ" is never
|
|
87
|
+
reported as "the results disagree".
|
|
88
|
+
* **Identity and state** live in files, not in a process: `device.json` per machine, an agent state
|
|
89
|
+
directory (spool and cursor), and the relay's `devices.json` + `ledger.jsonl`, which is why a relay
|
|
90
|
+
restart does not lose the fleet.
|
|
91
|
+
* **The eight tools are the interface.** A model calls `w2m_devices` → `w2m_run` → `w2m_wait` →
|
|
92
|
+
`w2m_report`; nothing about the transport is required knowledge to use it, and nothing about the
|
|
93
|
+
transport is hidden once you ask (`w2m_status`, `w2m_history`, the aggregate's `transport`).
|
|
94
|
+
|
|
95
|
+
## 4. Open items, stated plainly
|
|
96
|
+
|
|
97
|
+
1. **A/B have no live proof yet.** They are implemented and unit-tested (loopback crosses no NAT), but
|
|
98
|
+
the three-machine measurement needs all machines on this build at once.
|
|
99
|
+
2. **The MAC's state is unverified**, and one earlier episode had it online but not accepting work.
|
|
100
|
+
3. **WireGuard rung 4 is not end-to-end**: hub yes, clients no. When it is, the honest label is
|
|
101
|
+
"direct socket over a tunnel the VPS forwards" — not "the bytes avoided a server".
|
|
102
|
+
4. **The session-0 service is unexecuted**; the interactive task remains vulnerable to sign-out, and
|
|
103
|
+
its self-healing window is up to five minutes.
|
|
104
|
+
5. **The relay link from this machine is unreliable**, which is an environmental fact about a hotspot
|
|
105
|
+
behind carrier-grade NAT, not a property of this project — and it is precisely why A/B and C exist.
|
|
106
|
+
6. `deploy/` and `docs/` are inside the published package, so any change to them owes a release for
|
|
107
|
+
tag/artifact consistency; 0.5.2 is owed at the time of writing (this file included).
|
|
108
|
+
|
|
109
|
+
## 5. How to re-run this audit
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
# the eight tools exist in the packed artifact
|
|
113
|
+
node scripts/verify-installed.mjs "$(mktemp -d)" # after extracting dist/*.tgz into it
|
|
114
|
+
|
|
115
|
+
# the suites this project gates on (see release.yml for the authoritative list)
|
|
116
|
+
node --test --test-force-exit test/p2p-*.test.mjs test/agent.test.mjs test/tools.test.mjs \
|
|
117
|
+
test/e2e.test.mjs test/crossnetwork.test.mjs test/recovery.test.mjs test/outbound-only.test.mjs
|
|
118
|
+
|
|
119
|
+
# what path did a real dispatch take, per machine
|
|
120
|
+
node deploy/networking/acceptance.mjs --rabbit http://202.182.123.154:8787 \
|
|
121
|
+
--token <operator token> --project <fleet checkout> --command 'git rev-parse HEAD'
|
|
122
|
+
|
|
123
|
+
# the Windows side, locally
|
|
124
|
+
Get-ScheduledTask -TaskName 'W2M Localside agent' | Get-ScheduledTaskInfo
|
|
125
|
+
Get-NetUDPEndpoint -LocalPort 41235
|
|
126
|
+
Get-Content "$env:USERPROFILE\.dsh\w2m\supervisor-heartbeat.json"
|
|
127
|
+
|
|
128
|
+
# the tunnel
|
|
129
|
+
wg show # on the server: a `latest handshake` line per peer is the only proof
|
|
130
|
+
```
|