@twinsearth/w2m-dsh-plugin 0.6.3 → 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.
- package/CHANGELOG.md +1030 -0
- package/README.md +9 -2
- package/bin/w2m-localside.mjs +0 -0
- package/bin/w2m-rabbit.mjs +6 -0
- package/bin/w2m-stun.mjs +0 -0
- package/deploy/server/README.md +109 -0
- package/deploy/server/deploy-w2m.sh +161 -0
- package/deploy/server/w2m-localside.service +31 -0
- package/deploy/server/w2m-rabbit.service +26 -0
- package/deploy/server/w2m-stun.service +26 -0
- package/docs/ACCOUNT-FLEET-DESIGN.md +369 -0
- package/package.json +33 -22
- package/src/chatops/account.mjs +361 -0
- package/src/chatops/actions.mjs +227 -0
- package/src/chatops/approval-broker.mjs +320 -0
- package/src/chatops/auth.mjs +376 -0
- package/src/chatops/bridge.mjs +344 -0
- package/src/chatops/channel.mjs +192 -0
- package/src/chatops/commands.mjs +409 -0
- package/src/chatops/config-route.mjs +332 -0
- package/src/chatops/device-signing.mjs +218 -0
- package/src/chatops/durability.mjs +202 -0
- package/src/chatops/file-safety.mjs +282 -0
- package/src/chatops/fleet-scope.mjs +176 -0
- package/src/chatops/fleet-wiring.mjs +199 -0
- package/src/chatops/fleet.mjs +272 -0
- package/src/chatops/handoff.mjs +258 -0
- package/src/chatops/ilink-protocol.mjs +165 -0
- package/src/chatops/ilink-transport.mjs +168 -0
- package/src/chatops/ilink.mjs +423 -0
- package/src/chatops/index.mjs +429 -0
- package/src/chatops/outbound-queue.mjs +282 -0
- package/src/chatops/policy.mjs +356 -0
- package/src/chatops/run-command.mjs +308 -0
- package/src/plugin/tools.mjs +1 -1
- package/src/relay/server.mjs +96 -0
- package/src/relay/state.mjs +2233 -2011
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,1036 @@ 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
|
+
|
|
7
1037
|
## [0.6.3] — 2026-10-13
|
|
8
1038
|
|
|
9
1039
|
One fix, and it is the half of v0.6.2 that made the other half usable.
|