@twinsearth/w2m-dsh-plugin 0.6.3 → 0.8.2

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