@twinsearth/w2m-dsh-plugin 0.6.2 → 0.8.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +1057 -0
  2. package/README.md +9 -2
  3. package/bin/w2m-localside.mjs +0 -0
  4. package/bin/w2m-rabbit.mjs +6 -0
  5. package/bin/w2m-stun.mjs +0 -0
  6. package/deploy/server/README.md +109 -0
  7. package/deploy/server/deploy-w2m.sh +161 -0
  8. package/deploy/server/w2m-localside.service +31 -0
  9. package/deploy/server/w2m-rabbit.service +26 -0
  10. package/deploy/server/w2m-stun.service +26 -0
  11. package/docs/ACCOUNT-FLEET-DESIGN.md +369 -0
  12. package/package.json +33 -22
  13. package/src/chatops/account.mjs +361 -0
  14. package/src/chatops/actions.mjs +227 -0
  15. package/src/chatops/approval-broker.mjs +320 -0
  16. package/src/chatops/auth.mjs +376 -0
  17. package/src/chatops/bridge.mjs +344 -0
  18. package/src/chatops/channel.mjs +192 -0
  19. package/src/chatops/commands.mjs +409 -0
  20. package/src/chatops/config-route.mjs +332 -0
  21. package/src/chatops/device-signing.mjs +218 -0
  22. package/src/chatops/durability.mjs +202 -0
  23. package/src/chatops/file-safety.mjs +282 -0
  24. package/src/chatops/fleet-scope.mjs +176 -0
  25. package/src/chatops/fleet-wiring.mjs +199 -0
  26. package/src/chatops/fleet.mjs +272 -0
  27. package/src/chatops/handoff.mjs +258 -0
  28. package/src/chatops/ilink-protocol.mjs +165 -0
  29. package/src/chatops/ilink-transport.mjs +168 -0
  30. package/src/chatops/ilink.mjs +423 -0
  31. package/src/chatops/index.mjs +429 -0
  32. package/src/chatops/outbound-queue.mjs +282 -0
  33. package/src/chatops/policy.mjs +356 -0
  34. package/src/chatops/run-command.mjs +308 -0
  35. package/src/plugin/tools.mjs +1 -1
  36. package/src/relay/server.mjs +96 -0
  37. package/src/relay/state.mjs +2233 -2011
package/CHANGELOG.md CHANGED
@@ -4,6 +4,1063 @@ 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.8.0] — 2026-10-14
8
+
9
+ **The first release on npm since 0.6.3.** Eight versions of work — account identity, fleet
10
+ isolation, device signing, lease-takeover guards, `/run`, the durability policy, and the server's
11
+ own configuration — now reachable by `npm install` rather than only by git tag.
12
+
13
+ ### What is in it, and what each part actually guarantees
14
+
15
+ **Account identity and the SN** (0.7.5). A machine derives a serial number from
16
+ `account code + hardware code + time code`. The account code is `sha256(userId)` truncated, so it
17
+ can be handed to a relay without disclosing the account id — which is what makes the next part
18
+ possible on a server that has no account login of its own.
19
+
20
+ **Fleet isolation** (0.7.6, wired in 0.7.8). `createTask` no longer selects every device on the
21
+ relay. It selects the requester's fleet, where the fleet is read **from the pairing code** — a
22
+ device cannot name its own fleet, because a device that could would join any fleet and every
23
+ signature would faithfully attest to it.
24
+
25
+ The migration is two-mode and both modes are tested against the relay's real state:
26
+
27
+ | relay | requester | reaches |
28
+ |---|---|---|
29
+ | unscoped | operator, or a fleetless device | **every device — the pre-0.7.8 rule, unchanged** |
30
+ | scoped | operator | the relay's fleet |
31
+ | scoped | a device in that fleet | that fleet only |
32
+ | scoped | a fleetless device | **nothing**, with the reason named |
33
+
34
+ **Deploying cannot be an outage; scoping cannot be a silent grant.**
35
+
36
+ **Per-device Ed25519 signing** (0.7.10, verified by the relay in 0.7.11). The relay already signed
37
+ requests, but with **HMAC over a shared secret** — everyone who can sign can verify, so that proves
38
+ only that the sender knows the secret, which is every device. A device key proves *which* device.
39
+
40
+ The relay now tries the device key first and converges one machine at a time:
41
+
42
+ | device | outcome |
43
+ |---|---|
44
+ | sends no signature | shared secret, byte-for-byte unchanged |
45
+ | stored key, good signature | verified by the key |
46
+ | stored key, **bad** signature | **refused, and not retried against the shared secret** |
47
+
48
+ That third row is the whole point: falling back would make the strong mechanism *optional* for
49
+ anyone able to produce a malformed signature, which is everyone.
50
+
51
+ **Lease-takeover guards** (0.7.9). `_takeover` would re-offer a dead machine's index to **every idle
52
+ device the relay holds**, with no fleet filter and no consideration of whether the task writes. So a
53
+ device in another account could be handed the work, and a writing task could be run twice. Both are
54
+ now refused with a named reason.
55
+
56
+ **`/run` with two privilege levels** (0.7.11).
57
+
58
+ | form | who | what it does | capability |
59
+ |---|---|---|---|
60
+ | `/run <argv>` | expert | dispatches those arguments, uninterpreted | `fleet.dispatch` |
61
+ | `/run --ask <words>` | beginner | asks; the agent answers and **proposes** | `operate` |
62
+
63
+ A beginner describing what they want is **not** executing anything: the request goes to a session,
64
+ and if the session wants something run it emits `/run <argv>`, which still needs `fleet.dispatch`.
65
+ **A low-privilege window can ask freely and cannot cause a fleet execution by asking.**
66
+
67
+ **Durability policy** (0.7.11). The requirement — *"10× redundancy, tolerate losing 90% of nodes"* —
68
+ is two different claims, and the arithmetic decides the architecture:
69
+
70
+ ```
71
+ n= 3 copies=3/10 loss=100% basis=server_complete
72
+ n= 100 copies=10/10 loss=100% basis=server_complete
73
+ --- with no server copy ---
74
+ n= 3 loss=67% meets=false <- three devices cannot reach 90% alone
75
+ n= 10 loss=90% meets=true
76
+ ```
77
+
78
+ At three devices the guarantee is **the server's complete copy**; device redundancy alone gives
79
+ **67%**. So the server is the **authority at every tier**, not one more replica to drop once devices
80
+ are plentiful.
81
+
82
+ **`deploy/server/`**. The three systemd units as the live host runs them, and the deployment script
83
+ whose step order is the safety argument: checksum before unpacking, back up in place, entry-point
84
+ check before swapping, then restart, health-check, and automatic rollback on failure.
85
+
86
+ ### The secrets guard
87
+
88
+ `scripts/scan-for-secrets.mjs` fails a release on a credential shape in any **tracked file**. It
89
+ exists because `.gitignore` refuses a *path*, while the ways a credential escapes are pasting one
90
+ into a source file, a fixture, or an example. The relay's `operator-token.txt` — full control of the
91
+ fleet — is the thing it protects.
92
+
93
+ Writing it exposed **six defects in the guard itself**, every one a silent-off failure that looks
94
+ like success: an unconditional `statSync` that made an injected reader unreachable; a relative path
95
+ that made the scan report zero findings across zero files; a token regex that missed camelCase; a
96
+ slug rule that rejected `p2p-crypto-operator` over a digit; a duplicate rule; and a test that
97
+ hard-coded rule names and so measured the name rather than the behaviour.
98
+
99
+ ### WeChat, measured
100
+
101
+ Reading and sending both work against the live service. The body is `item_list[0].text_item.text`,
102
+ the cursor is `get_updates_buf`, login confirms as `confirmed` (not `scaned`), the token is valid
103
+ **>280 s**, and a send nests under `msg` with `message_type: 2` and `message_state: 2`, returning a
104
+ `message_id` and no `ret`. Four wrong guesses on the wire format made a working feature look broken,
105
+ including a false conclusion that the token lived only 30 seconds.
106
+
107
+ ### The honest gaps
108
+
109
+ * **`shouldHandOff()` and `reconcileResults()` are written and tested but not wired.** The fleet and
110
+ write guards are in the relay's takeover path; single-successor selection is not.
111
+ * **The relay defaults to unscoped**, so accounts are mixed unless an operator sets `--fleet`. Doing
112
+ so requires an account code, which a headless server cannot produce, and a re-pair of every
113
+ device — during which a fleetless device reaches nothing.
114
+ * **`/run` is not yet wired to the WeChat channel.** The parser, the renderer, and the capability
115
+ split are complete and tested; the last link is the adapter.
116
+ * **The Mac cannot be updated remotely.** Its allow-list refuses anything but `node -e`, and a DSH
117
+ restart would kill the only channel to it.
118
+ * **`/v1/devices` is not narrowed to the caller's fleet** — a device can still *list* another
119
+ fleet's machines, though it cannot *reach* them.
120
+
121
+ ### Verified
122
+
123
+ **542 chatops cases across twenty-two suites**, and the relay's own **168 unchanged**.
124
+
125
+ ## [0.7.11] — 2026-10-14
126
+
127
+ **The relay verifies device signatures, `/run` reaches the fleet from a chat window, the durability
128
+ policy is written down, and the server's own configuration is in the repository.**
129
+
130
+ ### Relay verifies device Ed25519 signatures
131
+
132
+ `verifySignature` now tries the per-device path before the shared secret. The design goal is
133
+ **convergence**: a fleet updates one machine at a time, with no flag and no coordination.
134
+
135
+ | device | outcome |
136
+ |---|---|
137
+ | sends no signature | shared secret — byte-for-byte unchanged |
138
+ | has a stored key, good signature | verified by the key, `device: true` |
139
+ | has a stored key, **bad** signature | **refused, and not retried against the shared secret** |
140
+
141
+ That third row is the one that matters. Falling back would make the strong mechanism *optional* for
142
+ anyone able to produce a malformed signature, which is everyone.
143
+
144
+ *Defect found while writing it:* `NonceCache` exposes `accept`, not `remember`. The test caught it.
145
+
146
+ ### `/run` — two forms, two privilege levels
147
+
148
+ | form | who | what it does | capability |
149
+ |---|---|---|---|
150
+ | `/run <argv>` | expert | dispatches those arguments, uninterpreted | `fleet.dispatch` |
151
+ | `/run --ask <words>` | beginner | asks; the agent answers and **proposes** | `operate` |
152
+
153
+ The asymmetry is the point. A beginner describing what they want is **not** executing anything: the
154
+ request goes to a session, the session answers in prose, and if it wants something run it emits
155
+ `/run <argv>` — which still needs `fleet.dispatch`. So a low-privilege window can ask freely and
156
+ **cannot cause a fleet execution by asking**.
157
+
158
+ The form is **declared** (`--ask`), never inferred from how the text reads. An inferred distinction
159
+ gets it wrong in both directions, and the direction that matters is dispatching a sentence as if it
160
+ were a program.
161
+
162
+ Shell metacharacters are refused rather than passed through, because the fleet takes an argv array
163
+ and never a shell string — `|` would arrive as a literal argument and quietly do something other
164
+ than what was typed. **A command that runs and does the wrong thing is worse than a refusal.**
165
+
166
+ ### Durability policy
167
+
168
+ The requirement was given as *"10× redundancy, tolerate losing 90% of nodes"*. Those are two
169
+ different claims, and conflating them yields a system that looks compliant and is not:
170
+
171
+ * **10 replicas** is a storage-layout property, and is **impossible** below 10 nodes;
172
+ * **surviving 90% loss** counts every copy, including the authoritative one.
173
+
174
+ Measured, with the arithmetic in tests:
175
+
176
+ ```
177
+ n= 3 copies=3/10 loss=100% basis=server_complete
178
+ n= 100 copies=10/10 loss=100% basis=server_complete
179
+ n=1000 copies=10/10 loss=100% basis=server_complete
180
+ --- with no server copy ---
181
+ n= 3 loss=67% meets=false
182
+ n= 10 loss=90% meets=true
183
+ ```
184
+
185
+ So at three devices the 90% guarantee is met **by the server's complete copy**, and device
186
+ redundancy alone would give only 67%. That is why the policy treats the server as the **authority at
187
+ every tier** rather than as one more replica that can be dropped once devices are plentiful.
188
+ `durabilityReport()` names the basis in a note, so a health number cannot be read without its
189
+ provenance, and `server_copy_required` is true at every fleet size.
190
+
191
+ Tiers, inclusive at the bottom as measured by tests: `<100` server, `100–999` hybrid, `>=1000`
192
+ distributed with the server as a fallback that is still written to.
193
+
194
+ ### The server's configuration is now in the repository
195
+
196
+ `deploy/server/` holds the three systemd units **as the live host actually runs them** —
197
+ `w2m-rabbit`, `w2m-stun`, `w2m-localside` — plus `deploy-w2m.sh`, the deployment script that
198
+ performed the last two upgrades.
199
+
200
+ The script's step order *is* the safety argument: verify the checksum **before** unpacking (a
201
+ truncated upload must never become a service), back up in place (rollback must not depend on the
202
+ network), check for entry points **before** swapping (a `git archive` of the wrong directory
203
+ unpacks cleanly and contains no `bin/`), then restart, health-check, and **roll back automatically**
204
+ on failure.
205
+
206
+ **`/var/lib/w2m/rabbit/` is not in the repository and must never be.** It holds `operator-token.txt`
207
+ — full control of the fleet — and the task ledger. Two mechanisms keep that true, because one is
208
+ not enough: `.gitignore` refuses the **paths**, and `scripts/scan-for-secrets.mjs` reads the
209
+ **content** of every tracked file. A `.gitignore` refuses a path; the ways a credential actually
210
+ escapes are pasting one into a source file, a fixture, or an example.
211
+
212
+ ### The secrets guard, and the six defects it caught in itself
213
+
214
+ The guard is only worth having if it works, so it was tested against the real repository — and it
215
+ failed in ways worth recording, every one of them a **silent-off** failure that would look like
216
+ success:
217
+
218
+ 1. **`statSync` was called unconditionally**, so an injected reader could never be reached — the
219
+ stat hit the real filesystem, threw, and the `catch` swallowed it;
220
+ 2. **a relative path was passed to `readFile`**, so every file threw and the scan reported
221
+ **zero findings across zero files**;
222
+ 3. **the regex `operator[_-]?token` missed camelCase** — `operatorToken`, which is how this project
223
+ actually spells it;
224
+ 4. **a slug rule requiring only `[a-z]`** rejected `p2p-crypto-operator` because of the digit in
225
+ `p2p`;
226
+ 5. a **duplicate rule** reported one match twice;
227
+ 6. a test **hard-coded rule names**, so renaming a rule broke a test that was measuring the name
228
+ rather than the behaviour.
229
+
230
+ Findings on the real tree went from 3 false positives → 5 exposed fixtures → **0 blocking, 14 for
231
+ review** (all ordinary 64-hex hashes). `REVIEW` is deliberately **not fatal**: a scanner whose false
232
+ positives block releases is a scanner someone deletes.
233
+
234
+ ### Verified
235
+
236
+ **542 chatops cases across twenty-two suites**, the relay's own **168 unchanged**.
237
+
238
+ ### Still not done
239
+
240
+ * **`handoff.mjs`'s `shouldHandOff()` and `reconcileResults()` remain unwired** — the fleet and write
241
+ guards are in the relay's takeover path, the single-successor selection is not.
242
+ * **The Linux server and Windows plugin are on v0.7.10**; the Mac is on **v0.6.3** and cannot be
243
+ updated remotely (its allow-list refuses anything but `node -e`, and a DSH restart would kill the
244
+ only channel to it).
245
+ * **The relay is unscoped**, so accounts remain mixed at runtime.
246
+ * **npm has not been published since 0.6.3** and no credential is available on this host.
247
+ * **WeChat: reading works, sending works, `/run` is not yet wired to the channel.**
248
+
249
+ ## [0.7.10] — 2026-10-14
250
+
251
+ **Per-device Ed25519 request signing — the part of the signature chain that can be built without
252
+ locking anyone out.** The relay does not enforce it yet, deliberately.
253
+
254
+ ### Why a second signing mechanism
255
+
256
+ The relay already has request signing, and it is **HMAC over a shared secret**: everyone who can
257
+ sign can also verify, so the relay holds a credential that every device holds. That is fine when
258
+ one operator owns every machine, and it is the wrong shape for what this workstream is about —
259
+ proving that *this specific device* is in *this account's* fleet. **A shared secret proves only
260
+ that the sender knows the secret, which is every device.**
261
+
262
+ `src/chatops/device-signing.mjs` signs with a **device-held Ed25519 private key**, so the relay
263
+ verifies with a public key it stores and never holds anything that could impersonate the device.
264
+
265
+ ### The defect that would have made all of it useless
266
+
267
+ The signed bytes are the **relay's own canonical string**, reproduced byte for byte:
268
+
269
+ ```
270
+ v1\n<METHOD>\n<path>\n<timestamp>\n<nonce>\n<sha256-of-body>
271
+ ```
272
+
273
+ The first version hashed the body with `createHash('sha256', …)`. **The relay uses
274
+ `createHmac('sha256', Buffer.alloc(0))`**, and those are not the same function: HMAC wraps the key
275
+ with its own padding, so an empty body hashes to `b613679a…` rather than `e3b0c442…`.
276
+
277
+ **Every signature would have failed, forever, with no clue why.** A probe that signed four requests
278
+ with both implementations and compared the strings is what caught it — and that comparison is now
279
+ a test, because it is the only reason this is correct.
280
+
281
+ ### Added
282
+
283
+ * **`src/chatops/device-signing.mjs`** — a device signer, a verifier, and the shared canonical
284
+ string. Compatibility with the existing mechanism is deliberate: one verified string rather than
285
+ two that must be kept in step, and the relay can tell the two apart by **whether the device has a
286
+ public key** — no protocol flag, no version negotiation, no second header a client could forget.
287
+ * **`pair()` accepts and stores `public_key`.** Not validated there on purpose: a malformed key
288
+ fails at verification with a named reason, while rejecting a pairing over it would lock out every
289
+ existing device the moment the field appeared. A device that sends no key keeps working, with
290
+ `public_key: null` meaning *"signs with the shared secret or not at all"*.
291
+ * **`publicDevice` reports `public_key_present`** as a boolean, not the key. The key is not a
292
+ secret, but a device list is read by every device, and verification happens relay-side.
293
+ * `test/chatops-device-signing.test.mjs` — 22 cases.
294
+
295
+ ### The test that matters most
296
+
297
+ `canonicalString` is compared against **the relay's own implementation, imported** — not against a
298
+ reimplementation of the expectation. A test that asserts what the author believed the format to be
299
+ would have passed for the broken version.
300
+
301
+ ### ⚠️ Not enforced, and that is the point
302
+
303
+ **The relay does not verify device signatures yet.** A device that signs today is signing into a
304
+ relay that does not check it. Two reasons this is the right order:
305
+
306
+ 1. **Enforcement is what would lock people out.** A relay that *requires* a signature refuses every
307
+ device that has not been updated — including all four current machines. Landing verification and
308
+ enforcement together is how a safety feature becomes an outage.
309
+ 2. **The fleet rollout is blocked anyway** (see v0.7.9): the shared relay cannot produce an account
310
+ code, and the Mac cannot be updated without physical access.
311
+
312
+ So the sequence is: ship the signing, ship the verification, update every device, then turn
313
+ enforcement on — with `requireSignature` already meaning *"insist"* in the relay's existing
314
+ vocabulary, so the switch is one flag rather than a new mechanism.
315
+
316
+ ### Verified
317
+
318
+ **459 chatops cases across eighteen suites**; the relay's own **146 unchanged**.
319
+
320
+ ### Still not done
321
+
322
+ * **No signature is verified by the relay.** `verifyDeviceSignature` has no caller in `src/relay/`.
323
+ * **Hand-off is guarded but not refined** — `shouldHandOff()` and `reconcileResults()` remain
324
+ unwired; the fleet and write guards from v0.7.9 are in the relay's takeover path.
325
+ * **The Linux server, the Mac and the Windows plugin are still on older versions**, and the relay
326
+ is unscoped, so accounts remain mixed at runtime.
327
+ * **No message has yet gone from a phone through WeChat to the fleet.**
328
+
329
+ ## [0.7.9] — 2026-10-14
330
+
331
+ **Two safety guards on lease takeover, and an honest account of what is still not connected.**
332
+
333
+ ### What was already there, and what was missing
334
+
335
+ `_takeover` existed and already ran on `halt: 'now'`, so hand-off was **not absent** — v0.7.7's
336
+ note that it "is not implemented" was about `handoff.mjs` not being called, and this release
337
+ corrects the record: the relay's own takeover path was live and had been since v0.1.x.
338
+
339
+ **What it lacked was the two guards**, and both are reachable defects rather than theory:
340
+
341
+ 1. **A device in another account could be handed the work.** `_takeover` iterated
342
+ `this.devices.values()` with no fleet filter, so on a relay serving several fleets a takeover
343
+ **crossed accounts** — the isolation this whole series is about, broken in the one path that is
344
+ easy to miss because it only runs when something has already gone wrong.
345
+ 2. **A writing task could be run a second time.** Hand-off makes execution at-least-once. Harmless
346
+ for `node --test`; a duplicated write for anything else, applied on a machine with no idea the
347
+ first one already ran.
348
+
349
+ Both are now refused with a named reason (`LEASE_TAKEOVER_REFUSED`, `write_not_idempotent`).
350
+
351
+ ### Deliberately not changed
352
+
353
+ The re-offer **shape** is untouched. PROTOCOL v0.0.1 defines no per-index lease reassignment, so
354
+ takeover is expressed as a re-offer to every idle machine — a fan-out, not a single successor.
355
+ Narrowing it to one successor is what `handoff.mjs`'s `shouldHandOff()` would do, and it is a
356
+ **protocol revision**, not a wiring change, so it is not made here. `reconcileResults()` is
357
+ likewise ready and unused: nothing calls it yet.
358
+
359
+ ### The prerequisite this release discovered, which blocks the fleet rollout
360
+
361
+ **The shared Linux server cannot produce an account code.** Its `.dsh` holds no account
362
+ credentials — it is a headless VPS running the relay, STUN and a localside agent, not a signed-in
363
+ DSH client. So `--fleet <account code>` has **no source on the server**, and the value must be
364
+ computed on a machine that *is* signed in and then handed over.
365
+
366
+ That is workable — the account code is an opaque `sha256(userId)` prefix, so passing it does not
367
+ disclose the account id — but it means the rollout is:
368
+
369
+ 1. read the account code on a signed-in machine,
370
+ 2. restart the relay with `--fleet <code>`,
371
+ 3. **re-pair every device**, because a device only receives a fleet from the pairing code, and
372
+ every existing device paired without one.
373
+
374
+ **Step 3 is the risk**: on a scoped relay a fleetless device reaches **nothing**, so all four
375
+ current devices go silent until each is re-paired. That is the intended fail-safe direction, and it
376
+ is still an outage until the re-pairing completes.
377
+
378
+ ### Added
379
+
380
+ * `test/chatops-takeover.test.mjs` — 5 cases, booting a real relay: a write task is refused and
381
+ names why; a read task is still re-offered; a takeover skips a device in another fleet; it skips
382
+ a fleetless device on a scoped relay; and an unscoped relay behaves exactly as before.
383
+
384
+ ### Verified
385
+
386
+ **437 chatops cases across seventeen suites**, and the relay's own 165 unchanged.
387
+
388
+ ### Still not done
389
+
390
+ * **`shouldHandOff()` and `reconcileResults()` are not wired.** The guards above are in the
391
+ relay's takeover path; the richer single-successor selection and the late-result rule are not.
392
+ * **No device generates a keypair and no request carries a signature.** The relay's existing HMAC
393
+ signing is a different mechanism and remains untouched, so the signature chain is **not enforced**.
394
+ * **`/v1/devices` is not narrowed to the caller's fleet.**
395
+ * **No message has yet gone from a phone through WeChat to the fleet.**
396
+
397
+ ## [0.7.8] — 2026-10-14
398
+
399
+ **The wiring.** v0.7.5–v0.7.7 shipped modules that nothing called. This release connects them, so
400
+ account isolation and fleet scoping are **in effect** rather than merely tested.
401
+
402
+ ### What changed in the relay
403
+
404
+ * **`src/relay/state.mjs`** — `createTask` no longer selects `[...this.devices.values()]` with no
405
+ filter. It calls `targetsFor()` from `fleet-wiring.mjs`, which applies the fleet rule.
406
+ * **`createPairingCode` now carries the relay's fleet**, and `pair()` writes it onto the device.
407
+ This is the link the whole scheme hangs on, and it is easy to omit: `pair()` reads the fleet
408
+ *from the code*, so a code with no fleet pairs a device into no fleet — and a device in no fleet
409
+ reaches nothing on a scoped relay. `--fleet X` would then have had **no effect on anything,
410
+ silently**.
411
+ * **`publicDevice` carries `fleet_id` and `fleet_source`.** `devices.json` is written from this
412
+ shape, so a fleet that lived only on the in-memory record would have vanished on the next
413
+ restart — which is precisely when an operator looks at it. A live test caught that.
414
+ * **`bin/w2m-rabbit.mjs`** gains `--fleet <id>`.
415
+
416
+ ### The migration, which is the part that could have gone wrong
417
+
418
+ The shared relay holds four devices that paired before fleets existed, none with a `fleet_id`.
419
+ The rule is therefore two-mode, and both modes are tested against the relay's *actual* state:
420
+
421
+ | relay | requester | reaches |
422
+ |---|---|---|
423
+ | unscoped (`--fleet` absent) | operator, or a fleetless device | **every device — byte-for-byte the old behaviour** |
424
+ | scoped (`--fleet X`) | operator | **fleet X** |
425
+ | scoped | a device in fleet X | **fleet X only** |
426
+ | scoped | a fleetless device | **nothing**, with the reason named |
427
+
428
+ **Deploying this cannot be an outage**, and **scoping it cannot be a silent grant** — the second
429
+ row of that table is what makes the first row safe to ship.
430
+
431
+ ### Three defects found while wiring, all by tests
432
+
433
+ 1. **`NO_ELIGIBLE_DEVICE` was invented.** It broke the one relay test that pins the contract, and
434
+ worse, it would have broken **callers**: `chatops/fleet.mjs` reads `NO_ONLINE_DEVICE` to
435
+ produce *"start an agent on each machine"*, and an unrecognised code falls through to a generic
436
+ refusal. The protocol's code table is part of the protocol; the new information went into
437
+ `detail` instead.
438
+ 2. **An absent requester was refused as an unknown one.** An operator is not a device —
439
+ `authorizeTaskDispatch` returns `{ role: 'operator', machine_id: null }` — so treating "no
440
+ device" as "unknown device" turned every operator dispatch into an error and broke **74 relay
441
+ tests at once**.
442
+ 3. **An operator dispatch on a scoped relay was refused.** Configuring the fleet made the operator
443
+ unable to dispatch, which is the least useful possible outcome. The relay's own `fleetId` *is*
444
+ a statement about which fleet it serves, so an operator dispatch has an unambiguous scope and
445
+ lands inside it.
446
+
447
+ ### Verification
448
+
449
+ * **432 chatops cases across sixteen suites**, including **ten that boot a real relay** and assert
450
+ the three links end to end: `--fleet` → the code carries it → the device carries it → dispatch
451
+ reaches only that fleet.
452
+ * **The relay's own 168 tests pass unchanged**, which is the migration guarantee: the legacy path
453
+ reproduces the rule it replaced.
454
+ * The repository's wider suite keeps its baseline of one environmental failure (a missing
455
+ `node_modules/eslint`).
456
+
457
+ ### Deployed
458
+
459
+ The Linux server (`vultr`, `202.182.123.154`) was updated **v0.6.3 → v0.7.7** with a scripted
460
+ deploy: checksum verified before unpacking, the previous tree backed up, the new tree staged and
461
+ checked for entry points, then swapped, restarted and health-checked, with automatic rollback on
462
+ failure. Relay state (`/var/lib/w2m/rabbit`) was untouched — the 839 KB ledger and the operator
463
+ token both survived.
464
+
465
+ **It runs unscoped**, so its four devices keep working exactly as before. Setting `--fleet` is
466
+ what turns isolation on, and it is now a one-line change plus a re-pair.
467
+
468
+ ### Still not done
469
+
470
+ * **Hand-off is not wired into the relay's lease sweep.** `handoff.mjs` is complete and tested;
471
+ `shouldHandOff()` is not yet called on lease expiry. A silent machine is still simply silent.
472
+ * **Device-side signals are not sent.** No device generates a keypair in a real run, and no request
473
+ carries a signature. The relay's existing HMAC signing (`signing.mjs`) is a different mechanism
474
+ and is untouched.
475
+ * **`/v1/devices` is not narrowed to the caller's own fleet.** A device on a relay can still list
476
+ every fleet's machines; it cannot *reach* them, which is the part that matters for isolation.
477
+ * **No message has yet gone from a phone through WeChat to the fleet.**
478
+
479
+ ## [0.7.7] — 2026-10-14
480
+
481
+ **Hand-off: what happens when the machine holding a task does not answer.** `ACCOUNT-FLEET-DESIGN.md`
482
+ §4, built.
483
+
484
+ > **Two things this release does not do, said first.**
485
+ >
486
+ > 1. **The relay still does not call any of the v0.7.6/v0.7.7 modules.** Dispatch is still
487
+ > `[...this.devices.values()]`, no keypair is generated, no statement is issued, no request is
488
+ > signed, and no lease is ever handed off. **At runtime, accounts remain mixed and a silent PC
489
+ > is still simply silent.** These releases are the mechanism.
490
+ > 2. **The Linux server was not updated.** Its SSH credentials were not available to me
491
+ > (`ssh_run.py` deliberately reads them from the environment with no fallback in the file), so
492
+ > the relay there still runs v0.6.3-era code. Updating it needs `W2M_PASS`.
493
+
494
+ ### Added
495
+
496
+ * **`src/chatops/handoff.mjs`** — the hand-off decision, and the sharp edge it names.
497
+ * **`write: true` plus `halt: 'now'` is refused outright.** Hand-off turns execution from
498
+ at-most-once into **at-least-once**: a command may now run on two machines. Harmless for
499
+ `node --test`, and a second application of the same change for anything that writes — on a
500
+ machine with no idea the first one already ran. Refused, not warned about.
501
+ * **A late result never overwrites its successor.** The winner is the **highest attempt**, not
502
+ the last to arrive: arrival order is a property of the network, and a verdict that depends on
503
+ it is a verdict nobody can reason about. Superseded results are **kept, not dropped** — a late
504
+ result from the machine that went silent says it was slow, not dead, and discarding it turns a
505
+ diagnosable situation into an unexplained one.
506
+ * **The machine that just went silent is never offered its own slice again** — the one
507
+ reassignment guaranteed to be useless.
508
+ * **A slow machine is not pre-empted.** Two separate questions, in order: is the lease still
509
+ valid, and has the grace elapsed since it expired.
510
+ * **Every refusal is named**, because *"why did nothing take over"* has several answers with
511
+ different fixes: cancelled, `halt: 'never'`, a writing task, no other machine, every machine
512
+ already tried, not yet expired.
513
+
514
+ * `test/chatops-handoff.test.mjs` — 29 cases.
515
+
516
+ ### Three defects found in this module by its own tests
517
+
518
+ Worth recording, because each was invisible in the code and obvious in a failure:
519
+
520
+ 1. **The grace-period arithmetic was on the wrong side.** The first version asked
521
+ `now + grace <= until`, which declines when the grace is *short* and takes over when it is
522
+ *long* — the exact inverse. Two questions were being collapsed into one expression; they are
523
+ now separate branches.
524
+ 2. **`ALL_TRIED` was unreachable.** It was decided by asking "is there an untried machine", which
525
+ is always false in the all-tried case, so the reason could never fire and the two refusals were
526
+ indistinguishable. The real question is how many machines *exist*, not how many are untried.
527
+ 3. **A missing attempt number was treated as the newest.** `?? Infinity` made a frame with no
528
+ attempt win the aggregate — letting a stale result from a superseded lease override its
529
+ successor, which is the precise inversion `reconcileResults` exists to prevent. A missing
530
+ attempt is an **old** frame: it now sorts as `0`.
531
+
532
+ ### Still not connected
533
+
534
+ `auth.mjs`, `fleet-scope.mjs` and `handoff.mjs` are complete and tested, and **nothing in `src/`
535
+ outside `src/chatops/` imports them.** The relay must still:
536
+
537
+ * bind pairing codes to a fleet and sign a statement per device,
538
+ * verify the statement and the request signature on every call,
539
+ * replace the target selection with `selectTargets()`,
540
+ * call `shouldHandOff()` on lease expiry, and `reconcileResults()` when results arrive.
541
+
542
+ Until that is done, the isolation and the hand-off are **designed and tested, not in effect**.
543
+
544
+ ## [0.7.6] — 2026-10-14
545
+
546
+ **The authorisation chain and the fleet scoping rule.** `ACCOUNT-FLEET-DESIGN.md` §3, built.
547
+
548
+ > **Read this before trusting the isolation.** The two modules below are complete and tested, but
549
+ > **the relay does not call them yet.** `src/relay/state.mjs` still selects targets with
550
+ > `[...this.devices.values()]`, so at runtime accounts remain mixed. Wiring it is the next step
551
+ > and is not in this release. Saying "isolation is implemented" now would be false.
552
+
553
+ ### Added
554
+
555
+ * **`src/chatops/auth.mjs`** — the device keypair and the authorisation statement.
556
+ * A device generates an **Ed25519** keypair on first run; the private key is exported for
557
+ persistence and the caller is told to write it 0600 and never log it.
558
+ * The relay seals a statement over `(fleet_id, machine_id, sn, public_key, issued_at, expires_at)`.
559
+ * Every request carries a signature over `(method, path, timestamp, nonce, body_hash)`.
560
+ * **The canonical encoding is built by hand, with length-prefixed fields and a fixed field
561
+ order.** Two JSON encodings of one object can differ, so a verifier that re-serialised what it
562
+ received would be verifying its own encoding; and without length prefixes `{a:'x',b:'yz'}` and
563
+ `{a:'xy',b:'z'}` concatenate identically. The wire form carries **the exact bytes that were
564
+ signed**, so nothing is ever reassembled.
565
+ * **Expiry is mandatory** — a statement without one is a permanent credential. Checked *before*
566
+ the canonical form is recomputed, so a missing expiry reports `no_expiry` rather than the
567
+ technically-true-but-useless `malformed`. A test found that ordering.
568
+ * Replay defence is a **bounded** nonce window, injected: an unbounded set is a memory leak a
569
+ peer can drive, and the timestamp window already rejects anything old enough to fall out.
570
+ * Fleet keys are **derived** per fleet from one master secret, so a relay serves many accounts
571
+ without holding a key per account and one fleet's key does not sign for another.
572
+
573
+ * **`src/chatops/fleet-scope.mjs`** — which devices a request may reach.
574
+ * **The fleet comes from the pairing code, never from the device.** A device that could name its
575
+ own fleet would join any account, and every signature would faithfully attest to a lie.
576
+ * **An absent fleet is not a wildcard.** `undefined === undefined` is explicitly **not** a match.
577
+ This is the most dangerous available mistake: two ungrouped devices matching each other would
578
+ silently restore the exact "everything in one pool" behaviour being removed, and every test
579
+ that only ever sets a fleet would keep passing. There is a test named for it.
580
+ * **A foreign target is refused with the same answer as a nonexistent one**, so a caller cannot
581
+ enumerate another account's machine ids by trying them and reading the error.
582
+ * **A partial dispatch is refused whole** — silently dropping a named target leaves the caller
583
+ believing it dispatched somewhere.
584
+ * Offline devices are **counted, not silently dropped**: "we tried and they were offline" and
585
+ "there are none" produce the same empty result and different fixes.
586
+
587
+ * `test/chatops-auth.test.mjs` (33 cases) and `test/chatops-fleet-scope.test.mjs` (25 cases).
588
+
589
+ ### The tests are written against the attack, not the happy path
590
+
591
+ Each names the failure it prevents, and three of them assert a **weakening** rather than a
592
+ strength: that a device editing its own `fleet_id` fails verification, that swapping in its own
593
+ public key fails, and that an ungrouped device matches nothing. Those are the changes that would
594
+ look correct in a diff and quietly undo the isolation.
595
+
596
+ ### Not in this release
597
+
598
+ * **The relay does not call either module.** No device keypair is generated in a real run, no
599
+ statement is issued, no request is signed, and dispatch is still unfiltered. This release is the
600
+ mechanism; connecting it is next.
601
+ * **The relay does not yet issue pairing codes bound to a fleet**, so `fleetFromPairingCode` has no
602
+ producer in a live relay.
603
+ * **Hand-off is not implemented** — that is v0.7.7.
604
+ * **No message has yet gone from a phone through WeChat to the fleet.**
605
+
606
+ ## [0.7.5] — 2026-10-14
607
+
608
+ **Stage 1 of the account-fleet design: this machine now knows which account it belongs to, and
609
+ has a serial number.** The relay and the dispatch path are deliberately untouched — scoping
610
+ dispatch by fleet is v0.8.0, because it changes the protocol.
611
+
612
+ ### Added
613
+
614
+ * **`src/chatops/account.mjs`** — the identity root.
615
+ * **The account code comes from the host, not from a fetch and not from config.**
616
+ `ctx.deepseekAccount.getDeviceIdentity()` returns `userId`, which is hashed to an 8-hex
617
+ account code. There is deliberately **no way to set it from configuration**: a value an
618
+ operator can type is a value an attacker can type, so a hand-entered "account code" would be
619
+ a forgeable account code.
620
+ * **The SN is `W2M-<ACCT>-<HWID>-<TS>`, 40 characters**, defined to the character, with the
621
+ timestamp fixed at first generation so a restart cannot change a machine's identity.
622
+ * **Hardware sources per platform, all injectable**, so a Windows-shaped machine is exercised
623
+ on any host: `MachineGuid` / SMBIOS UUID, `IOPlatformUUID`, `/etc/machine-id`.
624
+ * **Placeholder values are refused before hashing.** Firmware that reports an all-zeroes UUID
625
+ would otherwise give *every such machine the same hardware code* — far worse than having none,
626
+ because it silently merges distinct machines.
627
+
628
+ * `test/chatops-account.test.mjs` — 42 cases.
629
+
630
+ ### Two things the tests assert on purpose
631
+
632
+ * **That a forged SN passes the shape check.** `isWellFormedSn` is named for what it does — it
633
+ answers "is this well formed", and an SN is computed by the party being checked, so nothing
634
+ about it is verifiable by inspection. The test *documents* this rather than papering over it,
635
+ so a future change that turns the shape check into an authorisation check fails a test instead
636
+ of building on sand.
637
+ * **That two accounts get different fleets and two machines in one account get the same fleet.**
638
+ The property the whole scheme exists for, asserted directly rather than inferred.
639
+
640
+ ### Fixed
641
+
642
+ * **The published v0.7.4 release notes claimed the SN is 41 characters. It is 40**
643
+ (4 + 8 + 1 + 12 + 1 + 14). The design document said the same thing in one place. Both are
644
+ corrected, and the release notes were edited in place. The wrong number came from arithmetic
645
+ done by hand; the unit test that asserts the length is what caught it.
646
+
647
+ ### Not in this release
648
+
649
+ * **The relay still does not scope by fleet.** `docs/ACCOUNT-FLEET-DESIGN.md` §3 is designed and
650
+ unimplemented: dispatch targets are still `[...this.devices.values()]` with no filter, so
651
+ accounts remain mixed. That is v0.8.0 and it is a breaking protocol change.
652
+ * **No device keypair yet.** The SN exists; the signature chain that makes account membership
653
+ *verifiable* rather than merely *asserted* does not.
654
+ * **No message has yet gone from a phone through WeChat to the fleet.**
655
+
656
+ ## [0.7.4] — 2026-10-14
657
+
658
+ **No code changed.** This release is an audit and a design document, and it exists because the
659
+ audit found that the answer to *"is each account's fleet isolated?"* is **no** — in a way worth
660
+ writing down before anything is built on top of it.
661
+
662
+ ### What the audit found, every line with its source
663
+
664
+ | question | answer | where |
665
+ |---|---|---|
666
+ | Is a device identity bound to an account? | **No.** `machine_id` is `randomUUID()` — no account, no hardware | `src/agent/agent.mjs` |
667
+ | Does an SN exist? | **No.** The concept is not in the codebase | entire repo |
668
+ | Is there a hardware identifier? | **No.** `caps` probes os/arch/node/python but never enters identity or authorisation | `src/agent/caps.mjs` |
669
+ | Does the relay use `user_id` for isolation? | **No — it stores it and never reads it.** Five references, every one a store or load | `src/relay/state.mjs` L507/736/981/993, `persistence.mjs` L170 |
670
+ | Does the plugin reference `user_id`? | **Zero times** | `src/plugin/tools.mjs` |
671
+ | Are dispatch targets filtered by account? | **No.** With no `target_machines`, `targets = all` — every device on the relay | `src/relay/state.mjs` L1259-1262 |
672
+ | Device table shape | **One flat Map**, no grouping dimension | `src/relay/state.mjs` L435 |
673
+
674
+ Measured on the shared relay (`a1b59a3b04dcd180`): **`devices=4`, all in one pool, with a single
675
+ pairing code.** Any machine holding that code joins the same pool, and any caller holding a valid
676
+ operator token has its task broadcast to every one of those four machines.
677
+
678
+ ### Added
679
+
680
+ * **`docs/ACCOUNT-FLEET-DESIGN.md`** — the design for account-scoped fleets, in three parts as
681
+ asked: the exact SN format, the relay authorisation model, and the hand-off timing.
682
+
683
+ The identity source is **verified, not assumed**: DSH already exposes `ctx.deepseekAccount`,
684
+ whose `getDeviceIdentity()` returns `{ deviceId?, userId?, osVersion }` and is documented as
685
+ *"read existing login identity without creating a device or returning credentials."* The
686
+ account's unique identifier therefore needs **no account API call** — and a hand-typed account
687
+ code in a config row is explicitly ruled out, because a value an operator can type is a value
688
+ an attacker can type.
689
+
690
+ The SN is defined to the character: `W2M-<ACCT>-<HWID>-<TS>`, 41 characters, with `ACCT` a
691
+ truncated `sha256(userId)` rather than the raw id (it ends up in logs, chat messages and device
692
+ lists), `HWID` from platform-stable sources only — MAC addresses, mount points and package
693
+ lists are excluded because they change and would make one machine look like two — and `TS`
694
+ fixed at first generation.
695
+
696
+ ### Three things the design says out loud that a proposal usually does not
697
+
698
+ 1. **An SN cannot be a credential.** It is computed and asserted by the client, so anyone who
699
+ knows or guesses an account id can forge one. The document states that the relay must never
700
+ trust an SN, and that the timestamp provides **no** cryptographic uniqueness — uniqueness is
701
+ `(ACCT, HWID)`, and the timestamp only distinguishes re-registrations and makes the string
702
+ auditable. A string scheme presented as hardware-grade protection is worse than none, because
703
+ it is believed.
704
+ 2. **Hand-off makes execution at-least-once.** A command may run on two machines. Harmless for
705
+ `node --test`, harmful for anything with `write: true` — so the design **forbids automatic
706
+ hand-off for writing tasks** until a fencing token exists.
707
+ 3. **The relay is the trust root.** Compromising it forges any fleet's authorisation. The design
708
+ accepts that rather than adding an online account check, which would stop offline machines
709
+ from working at all.
710
+
711
+ ### Not in this release
712
+
713
+ * **Nothing from the design is implemented.** It lands in **v0.8.0**, because it changes the relay
714
+ protocol — a breaking change that needs an explicit refusal path for older devices rather than
715
+ a silent failure.
716
+ * **No message has yet gone from a phone through WeChat to the fleet.** The adapter is verified
717
+ against live WeChat and the executor against a live relay; **the two have not been joined by a
718
+ human typing.**
719
+
720
+ ## [0.7.3] — 2026-10-14
721
+
722
+ **The fleet reader used the wrong credential and the wrong field names.** v0.7.2 shipped an
723
+ executor that had never been pointed at a live relay. Found by running it against one.
724
+
725
+ The agent's own log proved the task really ran:
726
+
727
+ ```
728
+ [agent] offer 01M4VC7KWKF5WA5Q6BBDNH4AXM attempt 1 {"argv":["node","src/facts.mjs"]}
729
+ [agent] result delivered for 01M4VC7KWKF5WA5Q6BBDNH4AXM {"status":"nonzero_exit"}
730
+ [agent] result accepted for 01M4VC7KWKF5WA5Q6BBDNH4AXM {"status":"nonzero_exit"}
731
+ ```
732
+
733
+ — while the poll loop printed erdict=undefined thirty times.
734
+
735
+ ### Fixed
736
+
737
+ * **GET /v1/tasks/<id> needs the device token, not the operator token.** With the operator
738
+ token it answers 401 {"code":"UNAUTHORIZED","message":"missing or invalid device_token"}.
739
+ Dispatching and reading take different credentials; the executor now holds both, and a refusal
740
+ names which one it wanted, because *"your operator token is wrong"* and *"this install is not
741
+ paired"* have different fixes.
742
+ * **The verdict is at ody.aggregate.status, not the top level.** The per-machine rows are
743
+ ody.leases[] on a status and ody.machines[] on a report, and the machine field is
744
+ machine_name. The first version read ody.verdict and ody.results, so every machine
745
+ printed as ?: ? while the task completed perfectly.
746
+ * /v1/devices wants the device token too on this relay build.
747
+ * A lease row carries state rather than outcome until the result lands, so describeMachines
748
+ shows the outcome when there is one and the lease state when there is not.
749
+
750
+ ### Verified end to end
751
+
752
+ Against a live relay and a live Localside agent on Windows:
753
+
754
+ ```
755
+ 任务 01M4VCET — unverifiable
756
+ 命令:node src/facts.mjs
757
+ win-desktop: unverifiable status nonzero_exit exit 1
758
+ ```
759
+
760
+ 272 chatops cases pass.
761
+
762
+ ### Still not claimed
763
+
764
+ No message has been sent from a phone through WeChat to the fleet. The adapter is verified
765
+ against live WeChat and the executor is now verified against a live relay, but **the two have
766
+ not yet been joined by a human typing**.
767
+ ## [0.7.2] — 2026-10-14
768
+
769
+ **Run the fleet from a chat message, and get the machines' own verdict back.**
770
+
771
+ v0.7.1 shipped a working WeChat adapter and could still do nothing with it: `/run` answered
772
+ *"this install has no fleet"*, because the executor did not exist. This adds it.
773
+
774
+ ### The gap was architectural
775
+
776
+ `w2m_run` and `w2m_wait` are DSH **tools**: their bodies live inside the plugin's `ctx.tools`
777
+ registration and are shaped for a model to call — a schema, a card, an error type the host
778
+ renders. The chat bridge needs the same work with none of that: an argv in, a verdict and a
779
+ sentence out.
780
+
781
+ The bridge cannot call the tools. A tool call needs `@deepseek-ai/dsh-tools`, which exists
782
+ **only inside `app.asar`** — a separate process cannot complete one, and an in-process call would
783
+ have to fabricate the host's own calling convention. So the fleet work is now expressed once
784
+ against the relay's HTTP API, and the tool and the chat bridge are both callers of it.
785
+
786
+ ### Added
787
+
788
+ * **`src/chatops/fleet.mjs`** — `POST /v1/task`, `GET /v1/tasks/<id>`, `/report`, `/devices`, the
789
+ same paths the plugin's own tools use. A refusal is returned as a value: the relay's refusals are
790
+ answers, and the two that matter get their own sentences, because *"start an agent on each
791
+ machine"* and *"your operator token is wrong"* are different instructions. A wait that expires
792
+ reports `timeout` as a **verdict** rather than throwing — a fleet with one dead machine never
793
+ reaches a terminal state, and silence is the worst possible reply to a phone.
794
+ * **`src/chatops/actions.mjs`** — the action set `commands.mjs` calls, plus the phone-sized
795
+ formatting rules.
796
+ * **`/where <task id>`** and **`/report <task id>`**, so a task id from a reply is actionable.
797
+ * `test/chatops-fleet.test.mjs` — 28 cases.
798
+
799
+ ### Two properties worth naming
800
+
801
+ * **The argv is never joined into a string.** A chat message is attacker-influenced text, and the
802
+ array is what keeps a shell from ever seeing it. A formatter that joined and re-split would work
803
+ for every command until the first one with a space in an argument.
804
+ * **`divergent` and `divergent-platform` are not collapsed.** They mean different things — one is
805
+ a bug, the other a platform difference — and paraphrasing either into "failed" is how a
806
+ portability difference becomes an incident.
807
+
808
+ ### Changed
809
+
810
+ * `createBridge` now accepts a policy **instance** as well as the overrides to build one. The
811
+ action set has to share the bridge's policy, or `/trust` widens a copy that admission never
812
+ consults and the command silently does nothing. Duck-typed on `admit()` so the two modules need
813
+ not import each other's constructors.
814
+ * `apply()` builds the executor from the same `rabbitUrl` / `operatorToken` row the tools read, so
815
+ there is one setting rather than two, and resolves the token in the same order (row, then
816
+ `W2M_OPERATOR_TOKEN`). An install with no `rabbitUrl` gets named refusals — *"no rabbitUrl is
817
+ configured"* — instead of an absent action.
818
+
819
+ ### Verified
820
+
821
+ **271 chatops cases pass**, 28 of them new.
822
+
823
+ ### Not yet done — the honest caveat
824
+
825
+ The round trip has been verified against **live WeChat as far as the adapter**, and against
826
+ **the relay as far as the executor**. But **no command has been sent from a phone to the fleet end
827
+ to end.** That is the next thing to test, and this release does not claim it.
828
+
829
+ ## [0.7.1] — 2026-10-14
830
+
831
+ **v0.7.0's WeChat channel could not send a single message.** This release fixes it, and the
832
+ way it was found is the point.
833
+
834
+ ### What was wrong
835
+
836
+ v0.7.0's `ilink.mjs` was written by *reading* the audited plugin's TypeScript. Every field name
837
+ in it was wrong — nine separate mismatches — and all 237 of its tests passed, because those tests
838
+ drove an injected transport and asserted on the requests we *constructed* rather than the
839
+ requests WeChat *accepts*. **A self-consistent suite cannot falsify its own assumptions.**
840
+
841
+ The first real request, made with a real QR scan and a real bot, returned:
842
+
843
+ ```json
844
+ {"errcode":-14,"errmsg":"session timeout"}
845
+ ```
846
+
847
+ That message is a lie about its own cause, which is why it took as long as it did:
848
+
849
+ | # | v0.7.0 | Measured |
850
+ |---|---|---|
851
+ | 1 | *(absent)* | **`AuthorizationType: ilink_bot_token`** |
852
+ | 2 | *(absent)* | **`X-WECHAT-UIN`**, a per-request random |
853
+ | 3 | `to_user` | `to_user_id` |
854
+ | 4 | bare `{msg}` | `{msg, base_info}` |
855
+ | 5 | `item_list[].text_item` | also `type: 1`, `from_user_id`, `client_id`, `message_type: 2`, `message_state: 2` |
856
+ | 6 | `getupdates` body `{}` | `{get_updates_buf, base_info}` |
857
+ | 7 | `bot_id` / `owner_user_id` | `ilink_bot_id` / `ilink_user_id` |
858
+ | 8 | success = HTTP 2xx | **`ret === 0`** |
859
+ | 9 | — | failures use **`errcode`/`errmsg`** and **still return HTTP 200** |
860
+
861
+ **#1 is the one that cost the most time.** Without `AuthorizationType`, every authenticated
862
+ endpoint answers `-14 session timeout` — *including a request carrying no `Authorization` header
863
+ at all*, because the server rejects on the missing type before it reads the credential. That made
864
+ it look like an expiry bug, and an expiry bug is investigated by checking clocks, re-scanning
865
+ codes and re-minting tokens. It is not one.
866
+
867
+ **#8 and #9 are the dangerous ones.** A refusal arrives with HTTP 200, so a status-code check
868
+ reports a failed send as a delivered one — the exact silent-failure shape this release series
869
+ exists to remove, and it had been reintroduced in the adapter. `send()` now rejects on
870
+ `errcode`, so the outbound queue can never count a refusal as a delivery.
871
+
872
+ ### Also fixed
873
+
874
+ * **The transport did not exist.** v0.7.0 required the host to inject `chatopsIlinkRequest` and
875
+ shipped no default, so no request could ever be sent and `apply()` could never mount a working
876
+ channel. `ilink-transport.mjs` is now the default, and injecting one only overrides it.
877
+ * **64-bit ids were rounded.** A live `message_id` is 7515840255213248776 — past
878
+ `Number.MAX_SAFE_INTEGER` — so `JSON.parse` returned `7515840255213249000`. The transport now
879
+ recovers the exact digits. Rounding is harmless for de-duplication and not harmless if an id is
880
+ ever persisted as a key or sent back.
881
+ * **The host allow-list was wider than the measurement.** v0.7.0 allowed `weixin.qq.com` and
882
+ `api.weixin.qq.com` on a guess; the login response returns exactly `ilinkai.weixin.qq.com`. An
883
+ allow-list wider than what was observed is not caution, it is the absence of the check.
884
+ * **A precision bug in the fix itself**, caught by the suite it was written for: the first
885
+ `repairMessageIds` mutated in place, so a shared test fixture was rewritten by whichever case
886
+ ran first.
887
+
888
+ ### Added
889
+
890
+ * `src/chatops/ilink-protocol.mjs` — the measured wire format in one place, each constant
891
+ annotated with the live response it came from.
892
+ * `src/chatops/ilink-transport.mjs` — the real HTTP transport, with per-request deadlines, the
893
+ `ret`/`errcode` distinction returned as data, and `undici`'s `cause` unwrapped so a network
894
+ fault does not read as a protocol fault.
895
+ * `test/chatops-ilink-contract.test.mjs` — **36 cases pinned to fixtures taken verbatim from live
896
+ responses**. The rule it enforces: if a field name changes, a test here must fail. That is the
897
+ mechanism that would have caught all nine.
898
+ * `scripts/ilink-login-and-probe.mjs`, `scripts/ilink-qr-poll.mjs`, `scripts/ilink-talk.mjs` —
899
+ the probes that found this. Kept because they are the only way to check the adapter against
900
+ reality rather than against itself.
901
+
902
+ ### Changed
903
+
904
+ * `test/chatops-ilink.test.mjs` **removed**. It asserted the wrong protocol — `body.msg.to_user`,
905
+ `msg_list` — so keeping it would have pinned the v0.7.0 defects back in. The contract suite
906
+ replaces it.
907
+
908
+ ### Verified against live WeChat
909
+
910
+ A real QR scan, a real bot, a real exchange in both directions:
911
+
912
+ * `get_bot_qrcode` → `{"qrcode":…,"qrcode_img_content":…,"ret":0}`
913
+ * `get_qrcode_status` → `{"status":"confirmed","bot_token":…,"baseurl":…,"ilink_bot_id":…,"ilink_user_id":…}`
914
+ * `notifystart` → `{"ret":0}`
915
+ * `getupdates` → the peer's reply, read verbatim
916
+ * `sendmessage` → `{"message_id":7515840555615131656}`, received on the phone
917
+
918
+ ### Not fixed, and not claimed
919
+
920
+ * **Feishu, DingTalk and WeCom are still not implemented.** They need vendor SDKs this project
921
+ cannot vendor or review. The contract they must satisfy is `channel.mjs`.
922
+ * **Only the WeChat path has been exercised against a live platform.** That is one of four, and it
923
+ is the one that happens to need no SDK.
924
+ * **No command has been run through the bridge from a phone yet.** The adapter is verified; the
925
+ end-to-end path from a chat message to `/run` on the fleet is not.
926
+
927
+ ## [0.7.0] — 2026-10-14
928
+
929
+ The release that answered "can we drive the fleet from a phone?" with **not yet, and
930
+ here is exactly why** — and then built the two pieces that had to exist first, under
931
+ test.
932
+
933
+ ### Why this is not the feature, and why that is the honest release
934
+
935
+ The goal was to fold [`dsh-chatops`](https://github.com/ZhuoSir/dsh-chatops) 0.1.0 —
936
+ an IM bridge for WeChat/Feishu/DingTalk/WeCom — into this plugin as a feature. It was
937
+ read in full instead of trusted: ~168 KB of TypeScript across 40 files, plus the
938
+ published 3,544-line bundle, which is byte-identical to the committed source modulo
939
+ CRLF (`minify:false`, so identifiers and comments survive — the audit is on the real
940
+ artifact).
941
+
942
+ **It is not safe to adopt as written, and the reasons are not stylistic:**
943
+
944
+ * The approval answerer registers `(req) => …` on the `approval/request` **waterfall**
945
+ and never calls `next()`. This host documents that event as *"Return an outcome to
946
+ claim the request or call `next()` to delegate"*, and cordis's `waterfall` consults
947
+ the next listener **only when the current one calls `next()`**. The plugin's listener
948
+ is also `async`, so it hands back a Promise, and the harness takes any non-`undefined`
949
+ value as the final answer. The host's own GUI answerer is therefore never consulted
950
+ for a session that has an IM window bound, and the request is held until the plugin's
951
+ own 300 s deadline. Whether it happens at all depends on listener **registration
952
+ order**, which the plugin does nothing to control. This is the one defect that reaches
953
+ beyond the plugin.
954
+ * The first private message on any of three channels makes its sender a **permanent
955
+ owner**, before any authorisation runs, with no pairing code and no allow-list, and
956
+ persists the claim to `bindings.json`. All four channels ship enabled by default. Once
957
+ adopted, that identity can enumerate every workspace and session, inject prompts,
958
+ read transcripts via `/log`, exfiltrate workspace files via `/send`, approve, and
959
+ switch the sandbox to `danger-full-access`.
960
+ * `GET /chatops/api/config` returns four platforms' credentials in cleartext behind a
961
+ loopback-only check with no token and no Origin validation; the settings page re-polls
962
+ it every 3 s. `POST` to the same path merges an unvalidated body into the config and
963
+ writes the profile's `cordis.patch.yml` — and `puppet` is passed to `await import()`,
964
+ so that path is arbitrary module load.
965
+ * Pending approvals were keyed by window alone, so a second concurrent request
966
+ overwrote the first and the older became unanswerable from IM.
967
+ * The workspace file fence is lexical only (`resolve` + `startsWith`); `statSync`
968
+ follows symlinks, so a link inside the project ships any readable file to IM.
969
+
970
+ Shipping that as a headline feature would have been a regression in every user's
971
+ safety, so it is not shipped. The audit is published instead, and the two modules that
972
+ had to exist first are built and tested.
973
+
974
+ ### Added
975
+
976
+ * **`src/chatops/approval-broker.mjs`** — the approval seam, designed so the defect
977
+ above cannot recur:
978
+ * **declining is synchronous and never touches `next`**, so a bridge with nothing to
979
+ ask leaves the host's answerers to answer;
980
+ * **a deadline delegates instead of deciding** — a phone that did not answer is not
981
+ an operator who said no, so the timeout calls `next()` and lets the answerers behind
982
+ it decide;
983
+ * **pending requests are keyed by approval id and queued per window**, so two
984
+ concurrent requests cannot overwrite each other;
985
+ * ids carry full entropy, where 0.1.0 minted `randomUUID().slice(0, 8)` — 32 bits held
986
+ for five minutes;
987
+ * `dispose()` settles everything outstanding, so a reload leaves no timer that can
988
+ resolve a promise belonging to a fiber that no longer exists;
989
+ * the clock **and** the timer are injectable. That pairing is the whole reason the
990
+ suite is deterministic: an injected clock with a real `setTimeout` is two clocks, and
991
+ the first version of this test hung rather than failed.
992
+ * **`src/chatops/policy.mjs`** — who may command this machine, fail-closed:
993
+ * **nothing is trusted because it arrived.** An identity is trusted only by being
994
+ seeded in config or by redeeming a single-use, expiring, per-identity rate-limited
995
+ pairing code. There is no path that trusts a sender because nobody else had spoken;
996
+ * identity is `(channel, id)`. 0.1.0 held one flat `Set` matched after stripping the
997
+ channel prefix, so the same raw id was the same person on every platform;
998
+ * **a room is not a person**: an allowed room is never sufficient — the speaker must
999
+ also be individually trusted, where 0.1.0 returned `true` for every member of an
1000
+ allowed room whenever `allowContacts` was empty;
1001
+ * revocation exists, which 0.1.0 had no way to do short of deleting its state file;
1002
+ * `CHANNELS` is **one** registry. The same information lived in four places that had to
1003
+ agree, and a channel missing from the authorisation regexes had every message from it
1004
+ silently dropped;
1005
+ * a well-formed key naming an unenabled channel is a **named** refusal
1006
+ (`channel_unknown`), because a silent drop is indistinguishable from a bot that is
1007
+ simply broken.
1008
+ * `test/chatops-approval.test.mjs` (24 cases) and `test/chatops-policy.test.mjs`
1009
+ (24 cases), both wired into `test` and `test:all`.
1010
+
1011
+ ### Not in this release
1012
+
1013
+ * **Only one channel adapter ships, and it is the WeChat one.** `ilink.mjs` is here because
1014
+ its protocol is JSON over HTTPS with no vendor SDK — the other three channels need SDKs
1015
+ this project cannot vendor or review. Feishu, DingTalk and WeCom are not implemented; the
1016
+ channel contract they must satisfy is `channel.mjs`, checked by the same validator `ilink`
1017
+ passes.
1018
+ * **Nothing has been exercised against a live WeChat account.** The adapter's whole network
1019
+ surface is one injected `request()`, so its request shaping, response mapping,
1020
+ de-duplication, state machine and host allow-list are all asserted — 30 cases. What that
1021
+ suite cannot tell you is whether WeChat accepts the requests. That needs a QR scan and a
1022
+ real bot, and is not claimed.
1023
+ * The audit's own limits: the chatops dependency trees were not resolved (no `node_modules`
1024
+ was installed, and none of its code was executed), and its Feishu card-action signature path
1025
+ lives inside a vendor SDK that was not available to read.
1026
+
1027
+ ### Note on the other half of the work
1028
+
1029
+ The three-machine comparison this fleet exists for still needs one commit that all
1030
+ three machines can reach. The Mac runs the project at `a1e7dc04` on a clean tree with
1031
+ `--project /Users/fang/w2m-project` — already correct, and already the same path its own
1032
+ DSH uses; the mismatch is that this is not the commit the Windows host and the Tokyo
1033
+ relay are on. Its `git ls-remote` to GitHub times out while `curl api.github.com`
1034
+ answers 200, so the reachable common anchor is `origin/main` (`6e7ba0c9`, the v0.6.3
1035
+ tag) rather than the fixture commit used for the local runs.
1036
+
1037
+ ## [0.6.3] — 2026-10-13
1038
+
1039
+ One fix, and it is the half of v0.6.2 that made the other half usable.
1040
+
1041
+ ### Fixed
1042
+
1043
+ * **`w2m_status` now reports `encryption.relay_serves_keys`.** v0.6.2 taught the node to tell a relay
1044
+ that drops peer keys apart from a peer that has none — and then did not carry the answer to the one
1045
+ surface an operator actually reads. The plugin builds its encryption block from an explicit field
1046
+ list, and the new field was not on it, so a fleet in exactly the state v0.6.2 exists to explain still
1047
+ showed `peers_known: 0` with no way to see why.
1048
+
1049
+ Measured on the machine that built the release: `w2m_status` returned
1050
+
1051
+ ```json
1052
+ "encryption": { "enabled": true, "public_key": "cQshuntt…", "peers_known": 0,
1053
+ "handshakes": 0, "encrypted_channels": 0, "plaintext_channels": 0 }
1054
+ ```
1055
+
1056
+ while the node underneath it was reporting `relay_serves_keys: false` against the same relay. The
1057
+ feature worked and was invisible — which is the failure mode the field was added to remove, one
1058
+ layer up.
1059
+
1060
+ `?? null`, not `?? 0`, because the value is three-valued and collapsing it would accuse the relay
1061
+ before it had been tested: `null` means no announcement has proved it either way (or this node has
1062
+ no key of its own, so it never asked), `false` means the relay dropped the key we sent.
1063
+
7
1064
  ## [0.6.2] — 2026-10-13
8
1065
 
9
1066
  The release that stops a broken *relay* from being reported as an out-of-date *peer*, and stops a