@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.7

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 (60) hide show
  1. package/CHANGELOG.md +1309 -0
  2. package/LICENSE +21 -0
  3. package/PROTOCOL-v0.1.2.md +165 -0
  4. package/PROTOCOL-v0.3.0.md +454 -0
  5. package/PROTOCOL-v0.3.9.md +207 -0
  6. package/PROTOCOL-v0.4.0.md +255 -0
  7. package/PROTOCOL.md +339 -0
  8. package/README.md +665 -2
  9. package/bin/w2m-localside.mjs +303 -0
  10. package/bin/w2m-rabbit.mjs +396 -0
  11. package/bin/w2m-stun.mjs +231 -0
  12. package/cordis.patch.yml +94 -0
  13. package/deploy/caddy/Caddyfile.example +118 -0
  14. package/deploy/docker/Dockerfile +144 -0
  15. package/deploy/docker/README.md +338 -0
  16. package/deploy/docker/docker-compose.yml +179 -0
  17. package/deploy/nginx/w2m.conf.example +196 -0
  18. package/deploy/shared-server/README.md +118 -0
  19. package/deploy/shared-server/install.sh +255 -0
  20. package/deploy/systemd/w2m-rabbit.service +150 -0
  21. package/deploy/systemd/w2m-stun.service +69 -0
  22. package/deploy/windows/README.md +115 -0
  23. package/deploy/windows/install-service.ps1 +195 -0
  24. package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
  25. package/deploy/windows/winsw/w2m-localside.xml +50 -0
  26. package/docs/DEPLOY.md +902 -0
  27. package/docs/LINT-AUDIT.md +492 -0
  28. package/docs/PIPELINE-DESIGN.md +95 -0
  29. package/docs/TROUBLESHOOTING.md +560 -0
  30. package/docs/UI-CARDS.md +81 -0
  31. package/lib/index.js +24 -0
  32. package/lib/tools.js +11 -0
  33. package/package.json +133 -4
  34. package/src/agent/agent.mjs +3317 -0
  35. package/src/agent/allowed-commands.mjs +116 -0
  36. package/src/agent/caps.mjs +437 -0
  37. package/src/agent/exec.mjs +356 -0
  38. package/src/agent/git.mjs +616 -0
  39. package/src/agent/identity.mjs +253 -0
  40. package/src/agent/p2p-node.mjs +1243 -0
  41. package/src/agent/p2p.mjs +605 -0
  42. package/src/agent/resolve.mjs +148 -0
  43. package/src/agent/spool.mjs +252 -0
  44. package/src/agent/stun.mjs +397 -0
  45. package/src/agent/url.mjs +127 -0
  46. package/src/plugin/auto-update.mjs +574 -0
  47. package/src/plugin/schedule.mjs +452 -0
  48. package/src/plugin/shared-config.mjs +264 -0
  49. package/src/plugin/tools.mjs +3891 -0
  50. package/src/plugin/update-install.mjs +764 -0
  51. package/src/plugin/update-source.mjs +686 -0
  52. package/src/relay/metrics.mjs +284 -0
  53. package/src/relay/peers.mjs +180 -0
  54. package/src/relay/persistence.mjs +207 -0
  55. package/src/relay/report.mjs +591 -0
  56. package/src/relay/server.mjs +1187 -0
  57. package/src/relay/state.mjs +2004 -0
  58. package/src/relay/stun-server.mjs +733 -0
  59. package/src/signing.mjs +359 -0
  60. package/src/util/cli.mjs +70 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,1309 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.4.7] — 2026-10-09
8
+
9
+ ### Fixed
10
+
11
+ * **The package root exported nothing, and the ecosystem checks the root.** `lib/index.js` was
12
+ deliberately documentation-only — the profile mounts `@twinsearth/w2m-dsh-plugin/tools` because
13
+ `cordis.patch.yml` names that subpath — so `import('@twinsearth/w2m-dsh-plugin')` returned
14
+ `Object.keys() === []` and `typeof apply === 'undefined'`. A plugin that mounts perfectly and
15
+ looks like an empty package to every tool that inspects it is a listing risk with no upside: the
16
+ DSH plugin listing requirements ask for a package that "exports an `apply(ctx)` module", and a
17
+ missing root export is one of the most common reasons a submission is refused. The root now
18
+ re-exports `apply`, `inject` and the test helper from `./tools.js` — one implementation, two
19
+ entry points — and `test/tools.test.mjs` asserts it, so it cannot silently regress.
20
+
21
+ ### Notes
22
+
23
+ * Nothing about the mounted plugin changed: the patch still names the subpath rather than relying on
24
+ the "root is the plugin" convention, which is what keeps the eight tools from being registered
25
+ twice.
26
+ * `tools.test.mjs`: 83 pass, 0 fail.
27
+
28
+ ## [0.4.6] — 2026-10-09
29
+
30
+ ### Added
31
+
32
+ * **Published to npm.** `@twinsearth/w2m-dsh-plugin` is now installable by name:
33
+
34
+ ```bash
35
+ dsh plugin --profile <profile-name> add @twinsearth/w2m-dsh-plugin
36
+ ```
37
+
38
+ which is the form the marketplaces and every reader's muscle memory expect, and the form the
39
+ README had to warn against until now. It is the **same file** the GitHub release publishes —
40
+ `release.yml` packs with `scripts/pack.mjs`, the release uploads the tarball and `SHA256SUMS`, and
41
+ the npm publish uploads that fetched-back tarball after checking it against those sums. Nothing
42
+ is repacked, so "which artifact did you install?" has one answer rather than three.
43
+ * **`release.yml` gained an `npm` job** with two ways to authenticate, preferred first: **trusted
44
+ publishing (OIDC)** — nothing stored in the repository, configured once on npmjs.com against this
45
+ repo and workflow — or an `NPM_TOKEN` repository secret. It is `continue-on-error` on purpose:
46
+ those credentials live outside the repository, so an unconfigured npm account must not paint
47
+ every future release red, but it must not pass silently either. It also skips cleanly when the
48
+ version is already published, so re-running a release is idempotent.
49
+
50
+ ### Changed
51
+
52
+ * The README's install section leads with the npm form, the version badge is replaced by a live npm
53
+ badge, and the "not from npm yet" warning is gone — it was true for exactly one release cycle and
54
+ would have been the first thing a visitor to the npm page read.
55
+
56
+ ## [0.4.5] — 2026-10-09
57
+
58
+ ### Fixed
59
+
60
+ * **The supervisor v0.4.4 shipped would have failed the lint job.** `deploy/windows/` was not in the
61
+ rule block that turns `no-console` off for entry points — and a supervisor's only output channel
62
+ *is* the console and the log it tees into — and it carried an unused import left over from an
63
+ earlier draft. CI #41 caught it after v0.4.4 was published; the release itself was fine, the
64
+ repository was not. `deploy/**/*.mjs` now shares the entry-point block with `bin/**/*.mjs`.
65
+
66
+ ### Notes
67
+
68
+ * Released as its own version rather than re-tagged, because `deploy/` is inside the published
69
+ package: re-pointing v0.4.4 at different bytes would make the tag and the tarball disagree, which
70
+ is exactly the drift this project's release notes have complained about before.
71
+ * The lesson is the same one v0.4.2 taught and it is worth repeating: a *filtered* view of the lint
72
+ output is not the lint output. The local run ended in `Select-Object -Last 1`, printed a blank
73
+ line, and 5 errors went past unread.
74
+
75
+ ## [0.4.4] — 2026-10-09
76
+
77
+ ### Added
78
+
79
+ * **`deploy/windows/` — the agent as a Windows service.** `install-service.ps1` registers one task
80
+ (`-AtStartup` + `-AtLogOn`, `LogonType S4U`) that runs a new
81
+ **`w2m-agent-supervisor.mjs`**: the agent runs in session 0 with no console, survives sign-out,
82
+ and the supervisor respawns it with a capped backoff and logs one line per run. `-Interactive`
83
+ registers the same thing without elevation (sign-in only). `winsw/w2m-localside.xml` is the
84
+ template for a real SCM service if you need an entry in `services.msc` — that path puts a
85
+ third-party binary on the machine as the service host, which is your decision and not the
86
+ project's, so it is documented rather than scripted.
87
+
88
+ ### Fixed
89
+
90
+ * **Three defects in the service path, all found by running it rather than reading it.**
91
+ 1. A JSON argument does not survive being passed to a task: `--allowed-commands
92
+ "[\"node --version\"]"` came back stored as `"[node --version]"`, the agent read `[node` and
93
+ exited 2 with `ALLOWED_COMMANDS_INVALID`. That is the third layer to eat those quotes
94
+ (`cmd`, PowerShell 5.1, Task Scheduler), so the allow-list now lives in
95
+ `%USERPROFILE%\.dsh\w2m\agent.json` and the command line only carries `--config <path>`.
96
+ 2. `-AgentArgs '--p2p-mode','auto'` through `powershell -File` arrived as the single token
97
+ `--p2p-mode,auto` (`ERR_PARSE_ARGS_UNKNOWN_OPTION`); it is now one string, split on commas and
98
+ whitespace.
99
+ 3. PowerShell 5.1's `Set-Content -Encoding utf8` writes a **BOM**, `JSON.parse` refuses it, and
100
+ the supervisor exited 78 *before opening its log* — a failure with no trace at all. The
101
+ installer writes without a BOM; the supervisor strips one if present, because the file is
102
+ meant to be hand-edited and Notepad writes them too.
103
+
104
+ ### Notes
105
+
106
+ * The supervisor also reads the camelCase keys a JSON file wants (`allowedCommands`, `agentArgs`)
107
+ as well as the kebab-case flags the command line uses. The first version looked only for the
108
+ kebab key in the file, so a correct config silently fell back to the default four-entry
109
+ allow-list — silent, because a missing key is not an error.
110
+ * Verified after installing through the script: the task reports `Running`, the agent logs the full
111
+ nine-entry allow-list, `stream ready` arrives, and a real fleet dispatch finishes `consistent`
112
+ on both machines.
113
+
114
+ ## [0.4.3] — 2026-10-09
115
+
116
+ Documentation and metadata only — no source change, and the protocol is untouched. The
117
+ reason for a release at all is the same reason the marketplace needs one: the ecosystem's
118
+ listing standard treats a version bump as the signal that something changed, so metadata
119
+ that changes without a bump is metadata nobody re-reads.
120
+
121
+ ### Changed
122
+
123
+ * **The install section now names targets that exist.** It led with
124
+ `dsh plugin ... add @twinsearth/w2m-dsh-plugin@0.4.0`, an **npm name that is not
125
+ published** — a copy-paste that fails with a registry 404. It now leads with the release
126
+ tarball (the form verified in this repository), documents the repository form a
127
+ marketplace's one-click installer uses (`install TwinsEarth/dsh-windows2macos`), and
128
+ says plainly that the npm-name form starts working the day the package is published.
129
+ Both language sections carry the current version instead of a two-releases-stale one.
130
+ * **`package.json` declares what the plugin is and what it touches.** `dsh` gains
131
+ `plugin: true` and `kind: "server"`, and a `disclosure` object states: cloud required,
132
+ the default endpoints, no offline mode, where the two credentials live, which
133
+ filesystem and network reaches are used, and that the reference deployment moves data
134
+ between CN and JP. The README carries the same table for a human, because a disclosure
135
+ a reader cannot find is not a disclosure.
136
+ * **`stateDir` in the documented config is an absolute path.** The example used
137
+ `!!js (process.env.DSH_HOME + '/xclient')`, which on this build evaluated to
138
+ `…/desktop/undefined/xclient` — a path that does not exist, silently, so the plugin had
139
+ no device identity and the direct path stayed down. Measured, not theorised.
140
+
141
+ ### Notes
142
+
143
+ * The repository already carried the `dsh-plugin` topic, which is the listing entry
144
+ point for the topic-driven marketplaces; they scan it on their own schedule. Nothing in
145
+ this release is an application to any of them.
146
+ * Still not on npm. Publishing needs the scope owner's token, which is a decision for a
147
+ human rather than something a release can assume.
148
+
149
+ ## [0.4.2] — 2026-10-09
150
+
151
+ ### Fixed
152
+
153
+ * **`w2m_update` could never be called.** Its `execute` returned the plain object
154
+ `{ ok, action, update }` while the host requires a **string** from a tool, so every
155
+ call was rejected before it ran:
156
+
157
+ tool "w2m_update" returned invalid output: "value" must be a string
158
+
159
+ `output: jsonOutput` is a renderer, not a serialiser — the tool still owns turning its
160
+ value into text, which is what the other seven do. It now returns
161
+ `JSON.stringify(value, null, 2)` on both branches. Broken in v0.4.0 and v0.4.1, found
162
+ by actually calling the tool rather than by reading it.
163
+
164
+ ### Added
165
+
166
+ * **One test that makes the whole class impossible to repeat**: it calls all eight tools
167
+ (including both `w2m_update` actions, with the updater's network lookup stubbed to
168
+ fail so nothing installs) and asserts every result is a string that parses as JSON.
169
+ `test/tools.test.mjs`: 82 pass, 0 fail.
170
+
171
+ ## [0.4.1] — 2026-10-09
172
+
173
+ Two defects that v0.4.0 shipped, both found by using it against a live two-machine
174
+ fleet rather than by reading it. No wire change, no new endpoint: protocol version
175
+ stays **1** and a v0.4.0 peer keeps working in both directions.
176
+
177
+ ### Fixed
178
+
179
+ * **The plugin's dispatch-time allow-list could never match a multi-token entry.**
180
+ `checkAllowedCommand` compared `argv[0]`'s basename against each entry *whole*, so
181
+ the documented configuration — `allowedCommands: ['node --test', 'git status
182
+ --porcelain']` — refused every dispatch:
183
+
184
+ COMMAND_NOT_ALLOWED: `git` is not in this plugin's allowedCommands (git rev-parse, node --test, …)
185
+
186
+ The agent's own matcher has always been token-wise (`['node','--test']` allows
187
+ `node --test --reporter=tap`, refuses `node -e …`), so the dispatcher's pre-flight
188
+ and the machine that actually runs the command disagreed about the same setting.
189
+ Both now use **one** implementation, moved to `src/agent/allowed-commands.mjs` and
190
+ re-exported from `src/agent/agent.mjs`, so the public surface and every existing
191
+ import are unchanged. The pre-flight stays *no stricter* than before: an entry
192
+ naming an executable with an extension (`Node.exe`) still matches, which is the
193
+ tolerance the old check had.
194
+ * **There was no way to pin the UDP punch port.** The node always bound an ephemeral
195
+ port, so a machine that has to *accept* a punch — the executor — needed its
196
+ firewall rule re-pointed after every restart. Measured on the shared server: with
197
+ the rule stale, every dispatch fell back to the relay (`P2P_PUNCH_TIMEOUT` on the
198
+ dispatcher, `offer_path: "relay"` in the ledger). `--p2p-port <n>` (and
199
+ `W2M_P2P_PORT`) now pins it, validated loudly: `0`, `65536` and a non-numeric value
200
+ are usage errors that name the setting, and an invalid value at the agent level is
201
+ a recorded, non-fatal `P2P_PORT_INVALID` start failure rather than a silently
202
+ ignored option. `p2pMode: relay` still binds no socket at all.
203
+
204
+ ### Added
205
+
206
+ * **`src/agent/allowed-commands.mjs`** — `parseAllowedCommands` and
207
+ `matchAllowedCommand`, one implementation for the agent and the plugin, with the
208
+ rule they encode written down next to it: a string entry splits on whitespace, the
209
+ executable is compared by path or by basename, and every later token is compared
210
+ verbatim.
211
+ * **`normalizeP2PPort`**, exported next to `normalizeP2PMode`, because the CLI and
212
+ the agent must not disagree about what a legal port is.
213
+ * **10 tests** across `p2p-node`, `tools`, `p2p-agent` and `outbound-only`: the
214
+ multi-token allow-list in both directions, the default bind being exactly
215
+ `{address:'0.0.0.0', port:0}`, a pinned port reaching the binder and being reported
216
+ in `status.local`, the real CLI pinning a real port, and a bad value exiting 2.
217
+
218
+ ### Verification
219
+
220
+ * Required suites (`p2p-node`, `agent`, `tools`, `p2p-plugin`, `p2p-agent`,
221
+ `outbound-only`): **297 tests, 294 pass, 2 skipped, 1 failure** — the failure is
222
+ the pre-existing `finds python in the bundled DSH runtime when PATH has none`,
223
+ reproducible at HEAD. Baseline on the same files: 287 tests, 284 pass → exactly
224
+ +10 tests, +10 passing.
225
+ * The whole repository (`test:all`): **896 tests, 888 pass, 3 failures, 5 skipped**,
226
+ and the three are the same pre-existing environment cases documented in
227
+ `RELEASE-STATUS.md` (`agent` python; `pipeline-e2e` × 2, an allow-list entry
228
+ containing a space plus Node installed under `C:\Program Files`).
229
+ * Not verified: the pinned port was exercised on loopback and through the CLI, not
230
+ against a live firewall. The claim it fixes — "a firewall rule no longer has to be
231
+ re-pointed after every restart" — is the mechanism, and it is stated as the
232
+ mechanism rather than as a measurement.
233
+
234
+ ## [0.4.0] — 2026-10-09
235
+
236
+ The direct path becomes the default, and the P2P feature that v0.3.9 shipped but
237
+ did not wire up finally carries the task.
238
+
239
+ v0.3.9's own §8 said it plainly: the transport existed, `p2p.mode` was documented
240
+ as defaulting to `auto`, **nothing read it**, the Localside announcer was not
241
+ implemented, and `w2m_run` still dispatched through the relay — so a deployment
242
+ could believe it was on a direct path while every byte crossed the relay. This
243
+ release is that gap closed, with the same rule as before: what is not measured is
244
+ written down as not measured.
245
+
246
+ ### Added
247
+
248
+ * **`src/agent/p2p-node.mjs`** — the announcer and dialer v0.3.9 did not have. One
249
+ UDP socket per process: STUN discovery, mapping classification, candidate
250
+ announcement with a 60 s TTL refreshed every 20 s on an `unref()`ed timer, a
251
+ persistent accept loop, and `dial(machineId)` that returns a `P2PChannel` or a
252
+ named failure. `start()`, `announceNow()`, `dial()` and `close()` never throw:
253
+ a machine that cannot punch must still work over the relay.
254
+ * **`accept()` in `src/agent/p2p.mjs`** — the responder half of `punch()`, so an
255
+ idle machine can receive a punch without having initiated one. `punch`,
256
+ `P2PChannel`, `connect` and `deriveSession` are unchanged.
257
+ * **`src/relay/stun-server.mjs` + `bin/w2m-stun.mjs`** — an RFC 5389 Binding
258
+ responder, so a fleet does not depend on three third-party STUN servers being
259
+ reachable from every network it runs in. `XOR-MAPPED-ADDRESS`,
260
+ `MAPPED-ADDRESS` and `SOFTWARE`; malformed datagrams are dropped and counted;
261
+ `0x0111`/400 only for an unknown comprehension-required attribute.
262
+ * **The shared server, as a default rather than an instruction.**
263
+ `SHARED_SERVER` in `src/agent/p2p-node.mjs`: `http://202.182.123.154:8787` for
264
+ the relay and `202.182.123.154:3478` as the first STUN server. An empty
265
+ `rabbitUrl` now means the shared server; `w2m_status` reports
266
+ `rabbit_source: config | env | shared-default`, so "it works" and "it works
267
+ because someone set it" stay distinguishable.
268
+ * **`deploy/shared-server/`** — one command to stand up the whole rendezvous
269
+ host: Node, the relay, the STUN responder, an SSE-safe nginx front on :80, the
270
+ ufw rules, and optionally the host itself as a fleet machine. The reference
271
+ instance was built with it.
272
+ * **`p2pMode` / `stunServers` configuration** on the plugin (`W2M_P2P_MODE`,
273
+ `W2M_STUN_SERVERS`) and `--p2p-mode` / `--stun-servers` on the Localside CLI.
274
+ Both are validated loudly at load: an unknown mode is a startup error naming
275
+ the setting, not a silent fallback to `auto`.
276
+ * **`transport` and `p2p` on every result**, plus a trailing `路径` column in the
277
+ markdown report: which path the offer took (`offer_path`), which path the result
278
+ took (`result_path`), the punch RTT, the peer address, and a named `reason` when
279
+ the direct path was not used.
280
+ * **`PROTOCOL-v0.4.0.md`**, `deploy/systemd/w2m-stun.service`, and four new test
281
+ suites (`p2p-node`, `p2p-transport-fields`, `p2p-plugin`, `p2p-agent`) plus
282
+ `stun-server`.
283
+
284
+ ### Changed
285
+
286
+ * **The direct path is the default connection method between machines.**
287
+ `p2pMode: auto` is now real behaviour: the dispatching machine announces,
288
+ punches to each target, and pushes the offer over the punched channel. The relay
289
+ **holds its own copy of that offer back for `p2pOfferGraceMs` (1200 ms)** and
290
+ offers it anyway if the punch does not land — without that hold the direct path
291
+ could never win, because an SSE push over an already-open connection beats a
292
+ punch that costs a round trip. Measured on loopback while writing it: the relay
293
+ copy won every time. The hold is cancelled by the executor's own lease
294
+ heartbeat, so no new message was needed to say "the direct copy arrived".
295
+ * **The result notice travels back over the same channel, and the payload does
296
+ not.** `{type:'task.result', task_id, machine_id}` is acknowledged with
297
+ `result.ack`; the envelope is not on that frame, because `result_path` is one of
298
+ the envelope's fields and `envelope_sha256` covers it, so the acknowledgement has
299
+ to be known before the envelope is built. The direct path saves the dispatcher the
300
+ wait for the relay's copy, **not the bytes** — stated here because "the payload
301
+ path is direct" would otherwise be read as more than it is. A task's channel is
302
+ released 30 s after its attempt (`p2pChannelLingerMs`), so duplicates of a
303
+ finished attempt are still answerable and sessions do not accumulate.
304
+ * **`w2m_run` now dispatches with intent**: `origin_machine_id` and `p2p: {mode}`
305
+ travel in `POST /v1/task`, so the executor knows who might dial it and the relay
306
+ records how the task was meant to travel.
307
+ * **`w2m_wait` and `w2m_status` surface the path** — per machine in `w2m_wait`,
308
+ and as a `p2p` block (mode, mapping, reflexive address, announcements, punches
309
+ in/out, direct-result inbox depth) in `w2m_status`.
310
+ * **`scripts/verify.ps1` / `verify.sh` pass `--test-force-exit`** and run the five
311
+ new suites. They previously ran every suite without it, which can hang a green
312
+ end-to-end suite on an open SSE stream — a local verification that hangs is
313
+ indistinguishable from one that never finished.
314
+ * **`release.yml` / `ci.yml` carry the new suites** in the explicit lists, and the
315
+ duplicated `recovery.test.mjs` entry in both e2e lines is gone.
316
+
317
+ ### Fixed
318
+
319
+ * **A STUN response's attribute padding.** The responder's first draft omitted the
320
+ mandatory 4-byte padding after a 15-byte `ERROR-CODE` value, so every `0x0111`
321
+ response was one byte short with a declared length of 39 instead of 40. The
322
+ existing client decoder did not notice and neither did a hand-written hex
323
+ expectation — both were written from the same wrong assumption. It was caught by
324
+ a vector built numerically from the RFC layout, and it is invisible on the
325
+ success path because all three success attributes are already multiples of four.
326
+ The regression is pinned by an explicit length assertion plus a whole-buffer
327
+ comparison.
328
+ * **The allow-list trap that hid a suite failure.** A string entry in
329
+ `--allowed-commands` is split on whitespace, so a single path containing a space
330
+ (`C:\Program Files\nodejs\node.exe`) can never match and has to be written as an
331
+ array entry. This is why `test/pipeline-e2e.test.mjs` passes on CI runners and
332
+ can fail locally on a Windows box whose Node lives under `Program Files`; it is
333
+ now documented in `docs/TROUBLESHOOTING.md` §10 rather than left to be
334
+ rediscovered.
335
+
336
+ ### Compatibility
337
+
338
+ * **Protocol version stays 1.** Everything here is additive: two optional task
339
+ fields, two optional result fields, three new files, one new CLI. A v0.3.9
340
+ agent against a v0.4.0 relay and the reverse both keep working, and a v0.3.9
341
+ result envelope with no `transport` still compares exactly as it did.
342
+ * **`transport`/`p2p` are deliberately NOT comparable fields.** A path is not a
343
+ result: two machines that ran identical bytes and differ only in how the offer
344
+ reached them stay `consistent`. A test asserts exactly that, because the
345
+ opposite would turn a network difference into a false bug report.
346
+ * **A p2p-mode difference is not silent.** Both sides validate the mode at load;
347
+ the relay refuses an unknown `p2p.mode` in a task body with `BAD_REQUEST` naming
348
+ the field, and creates no task.
349
+
350
+ ### Verification
351
+
352
+ * Every claim in this entry has a suite behind it. Run them with
353
+ `scripts/verify.ps1` (Windows) or `scripts/verify.sh` (macOS/Linux), which now
354
+ include the five new suites.
355
+ * **What is measured across a real network, and what is not.** One WAN hop was
356
+ exercised end to end: the dispatcher on a Chinese residential network behind a
357
+ **symmetric** NAT, the executor on the shared server in Tokyo. Measured, with
358
+ numbers rather than adjectives:
359
+ * the client network is `endpoint-dependent` — three STUN servers reported three
360
+ different mapped ports for one socket (36028, 5855, 26713), punchable: false —
361
+ so nothing can dial *into* it;
362
+ * a punch it **initiates** to a public peer still lands: `HELLO` → `HELLO_ACK` in
363
+ **331.61 ms**, over a channel the punched path opened, and the ledger for that
364
+ machine records `transport: "p2p"` with `result_path: "p2p"`;
365
+ * a machine that has to *accept* a punch needs its UDP port reachable. On the
366
+ shared server that meant one `ufw` rule for the agent's punch port, and the port
367
+ is ephemeral today — a fixed `--p2p-port` is the obvious follow-up, and the
368
+ absence of it is why this is a documented step rather than a one-liner.
369
+ * **Still unverified, and named as such:** a punch between two hosts behind two
370
+ *different* NATs (the second network that would make it possible was not
371
+ available), punching through a cone NAT, the direct path under real packet loss
372
+ or reordering, IPv6 (not implemented in the transport; the responder's IPv6
373
+ branch is unexercised), and the authenticity of the UDP channel itself — a
374
+ direct path is not more trustworthy than the relay, and `PROTOCOL-v0.4.0.md` §9
375
+ says so rather than implying otherwise.
376
+ * A loopback punch is not evidence of NAT traversal, and neither the tests nor
377
+ this entry claim it is. The WAN measurement is one hop with one NAT, and it is
378
+ labelled as one hop with one NAT.
379
+
380
+
381
+ ## [0.3.9] — 2026-10-09
382
+
383
+ P2P hole punching, so the payload path no longer has to cross the relay.
384
+
385
+ ### Added
386
+
387
+ * **`src/agent/stun.mjs`** — an RFC 5389 Binding client on `node:dgram`, no third-party
388
+ dependency. It discovers the reflexive address and classifies **mapping behaviour** by
389
+ comparing the mapped port across several servers: one port everywhere is
390
+ `endpoint-independent` and can be punched; a different port per destination is
391
+ `endpoint-dependent` (symmetric) and cannot.
392
+ * **`src/agent/p2p.mjs`** — hole punching plus a reliable, fragmenting channel over the
393
+ punched path: selective per-fragment ACK, retransmission with doubling RTO, an 8 MiB
394
+ ceiling, and named failures (`P2P_PUNCH_TIMEOUT`, `P2P_NO_CANDIDATES`,
395
+ `P2P_FRAGMENT_UNACKED`, `P2P_MESSAGE_TOO_LARGE`).
396
+ * **`src/relay/peers.mjs`** and two endpoints — `POST /v1/peer/announce` and
397
+ `GET /v1/peer/{machine_id}` — the candidate rendezvous. Announcements are ephemeral
398
+ and deliberately never persisted; `/healthz` reports `peers_announced`.
399
+ * **`PROTOCOL-v0.3.9.md`** — the wire contract, the frame format, and the path-reporting
400
+ rule.
401
+ * 56 tests across `test/p2p-stun.test.mjs`, `test/p2p-transport.test.mjs` and
402
+ `test/p2p-signaling.test.mjs`, wired into `npm test` and `npm run test:all`.
403
+
404
+ ### Changed
405
+
406
+ * **`p2p.mode` defaults to `auto`**: try the direct path, fall back to the relay, and
407
+ **report which path was used**. See "Why `auto` and not `direct`" below.
408
+
409
+ ### Why `auto` and not `direct`
410
+
411
+ Punching cannot succeed against a symmetric NAT, and that is not hypothetical: measured
412
+ on the authoring machine, behind an iPhone hotspot, three STUN servers reported three
413
+ different mapped ports for the same socket — `endpoint-dependent`, so no punch from that
414
+ network can work. A default that hard-required the direct path would break every
415
+ deployment on such a network the moment it upgraded.
416
+
417
+ The fallback is never silent. Every offer and result carries `transport: "p2p" | "relay"`
418
+ and, on the relay path, the reason it fell back. A fallback that is not reported is
419
+ indistinguishable from a slow direct path, and the operator then debugs the wrong thing.
420
+
421
+ ### Two mistakes the tests caught, kept here because they are the interesting part
422
+
423
+ 1. **The STUN address family was read from the wrong byte offset.** RFC 5389 has a
424
+ reserved byte before `Family`; `value[0]` yields the reserved byte, so every address
425
+ decoded to `null` — which is indistinguishable from a server that sent no address at
426
+ all. A self-written fake STUN server agreed with the wrong offset perfectly. Only a
427
+ real server disagreed, which is why the suite has a live layer and a frozen captured
428
+ response.
429
+ 2. **Both peers generated their own random session id**, so each discarded the other's
430
+ `HELLO` and the punch timed out looking exactly like a NAT failure. `punch()` now
431
+ requires an agreed id (or an explicit `allowSessionAdoption`), and **throws** rather
432
+ than inventing one — a misleading timeout is the most expensive possible
433
+ misdiagnosis here.
434
+
435
+ ### Not yet wired
436
+
437
+ The `p2p.mode` setting is defined and documented but nothing reads it yet; the Localside
438
+ announcer and direct-path dispatch are not implemented. **The task path therefore behaves
439
+ exactly as v0.3.8.** Stated here, in `PROTOCOL-v0.3.9.md` §8 and in the README, so that no
440
+ deployment believes it is on a direct path when it is not.
441
+
442
+ ### Verified / not verified
443
+
444
+ Verified on macOS 27.0 / arm64: STUN against Cloudflare, Google and Nextcloud; the
445
+ mapping classifier against a real symmetric NAT; the punch, fragmentation, selective ACK,
446
+ retransmission under injected loss, and every failure mode, over real UDP sockets.
447
+
448
+ **Not verified, and impossible on one machine:** a punch across a real NAT. Two sockets on
449
+ one host share a loopback path with no translator between them, so "the punch succeeded"
450
+ says nothing about traversal. That still needs two hosts behind two different NATs.
451
+
452
+ ### The v0.3.5 / v0.3.6 release gap
453
+
454
+ Both versions are tagged, and **neither was ever published**. The flake fixed above failed inside the
455
+ `Release` workflow, so those two tags have no GitHub release and no downloadable tarball. The evidence
456
+ for the cause is the release list itself: v0.3.4 published, v0.3.7 published, and the two in between
457
+ absent.
458
+
459
+ They cannot be published retroactively, and this is worth stating precisely rather than papering over:
460
+ a tag pins the tree that ran, and the tree at those tags **contains the flaky test**. Re-running their
461
+ `Release` workflow fails again for the same reason. Moving a tag forward to pick up the fix would
462
+ publish the right code under the wrong version — `package.json` at v0.3.5's commit says `0.3.5`, so the
463
+ tarball would be named 0.3.5 while containing later code.
464
+
465
+ What that means in practice: **v0.3.7 is the first published artifact that contains the shared config
466
+ (v0.3.5) and the metrics endpoint (v0.3.6).** Anyone installing from releases gets all three. The gap is
467
+ cosmetic in effect and real in the record, which is why it is written down instead of skipped.
468
+
469
+ The process lesson is the one that cost the most here: a flaky test does not just fail a build, it can
470
+ block every release behind it until someone notices that the failures share a cause. Three consecutive
471
+ release runs failed before the pattern was visible in the release list.
472
+
473
+ ## [0.3.8] — 2026-10-08
474
+
475
+ The last open v0.3.3 item, resolved by reading the shipped runtime rather than guessing.
476
+
477
+ ### The finding
478
+
479
+ **UI cards: there is nothing to add.** `defineTool` treats `presentCall`'s return value as an opaque
480
+ hint and never validates it —
481
+
482
+ ```js
483
+ if (userPresentCall) tool.presentCall = (args) => {
484
+ if (validate(args).length > 0) return void 0;
485
+ return userPresentCall(args);
486
+ };
487
+ ```
488
+
489
+ — which means two things. There is **no registry of card types** to choose from: nothing in the shipped
490
+ bundle reads `card` at all. And a wrong field **cannot be caught by the runtime**; it would fail
491
+ silently in the UI. That is precisely why guessing a richer card was the wrong move, and why this item
492
+ sat open rather than being closed by inventing something.
493
+
494
+ The shipped runtime contains exactly two reference implementations, and they are the only evidence for
495
+ what the fields mean:
496
+
497
+ | where | card | kind |
498
+ |---|---|---|
499
+ | built-in `run_code` (`dsh-tools/lib/index.js:1445`) | `"generic"` | `"execute"` |
500
+ | the plugin manager (`dsh-plugin-manager/lib/types/tools.js:85`) | `'generic'` | `action.startsWith('list_') ? 'read' : 'other'` |
501
+
502
+ Both use `generic`. `kind` varies by whether the action mutates. That is the whole vocabulary with a
503
+ reference behind it.
504
+
505
+ ### Fixed
506
+
507
+ - **`w2m_update` reported `kind: 'read'` for its check-and-install action**, which can install a new
508
+ version into the very profile it runs in. A false "read" is worse than no hint, because the hint is
509
+ what a user reads to decide whether a call is safe — the same class of defect as the `intervalDays`
510
+ option that silently did nothing, and fixed for the same reason. It now reports `other` for `check`
511
+ and `read` for `status`, matching the plugin manager's vocabulary for exactly that distinction.
512
+
513
+ `w2m_run` keeps `write` when `write: true`: it executes on other machines, which `read` would
514
+ understate. The other six tools were already accurate.
515
+
516
+ - **`rawInput` is now asserted never to carry a credential.** It is handed to the UI, so anything in it
517
+ is rendered — and the shared-config work in 0.3.5 was specifically about keeping tokens out of files
518
+ that travel. The same rule applies to a card.
519
+
520
+ `presentResult` was deliberately **not** adopted: it is a second, equally unvalidated hook whose
521
+ result-side contract appears nowhere in the shipped bundle. Adopting it would be the same guess this
522
+ release declines to make. The reasoning is recorded in `docs/UI-CARDS.md` so the question is not
523
+ re-opened from memory.
524
+
525
+ ### Verification
526
+
527
+ 626 unit tests, 624 pass, 2 skip, 0 fail, 0 todo; 21 suites wired into `ci.yml`, `release.yml` and
528
+ `package.json` with exhaustiveness checked. ESLint 0 errors, 56 warnings. Five new tests in
529
+ `test/tool-cards.test.mjs`, including the credential check and a call with no arguments — which is what
530
+ the runtime itself does on a schema-invalid call.
531
+
532
+ ### v0.3.3 is now complete
533
+
534
+ broadcast, pipeline, shared configuration, metrics and multi-arch images were delivered in 0.3.2
535
+ through 0.3.6; UI cards are settled here. The two items from the v0.3.0 batch that remain undone are
536
+ the optional WebSocket transport and the optional libp2p fallback package — both judged to duplicate
537
+ what HMAC signing plus the existing backoff already provide, and neither is required by any acceptance
538
+ criterion.
539
+
540
+ ## [0.3.7] — 2026-10-08
541
+
542
+ ### Fixed
543
+
544
+ - **A flaky assertion in the agent suite, which failed on CI and passed locally every time.**
545
+ `never publishes a half-written document` asserted `reads > 20` inside a fixed 1200 ms wall-clock
546
+ deadline. That is a **throughput** threshold, not the property under test: whether the loop manages 20
547
+ iterations depends on how loaded the runner is, so on a busy machine it failed at 15 reads while the
548
+ atomic-publish code was perfectly correct. It passed here every time because this machine is fast.
549
+
550
+ The test now waits until the writer has actually published the file — so the loop cannot pass
551
+ vacuously by reading a file that never existed — and asserts `reads > 0` plus `parsed === reads`,
552
+ which is the real invariant and a torn write would break it. Verified over five consecutive runs of
553
+ the specific test and a full agent suite pass.
554
+
555
+ Worth recording because of where it surfaced: CI, not here. A test that measures machine speed will
556
+ always pass on the author's machine, which is exactly why the timestamped evidence has to come from
557
+ somewhere other than the machine that wrote the code.
558
+
559
+ - **Two wrong metric-type and metric-set decisions corrected before release**, both pushed back on by
560
+ the teammate who implemented the renderer, and both worth stating because the reasoning is the
561
+ valuable part:
562
+
563
+ - `w2m_task_status_total{status}` is declared **`gauge`, not `counter`** despite the `_total` suffix. The
564
+ values are the *current* count of tasks in each state, so a task moving from `pending` to
565
+ `consistent` decrements one bucket and increments another. A counter is monotonic by definition, so
566
+ declaring this one would make `rate()` produce nonsense and violate the "counters reset on restart"
567
+ convention. The `_total` name is kept deliberately — a dashboard or recording rule may already point
568
+ at it — and this note is the record that the suffix is a name, not a claim about the type.
569
+ - `w2m_rtt_stale{machine_id}` was added (0/1, emitted only for machines that reported an RTT), because
570
+ `w2m_rtt_ms` alone cannot distinguish "genuinely slow" from "reported 40 ms once and then vanished
571
+ forever" — a stale series reads as a healthy machine. With both, the operator's query works:
572
+ `w2m_rtt_ms > 500 and on(machine_id) w2m_rtt_stale == 0`.
573
+
574
+ The implementer also **proved the null-versus-zero assertion has teeth by mutation**: pushing a
575
+ fabricated `rtt_ms: 0` for a silent machine turned three assertions red, then the mutant was deleted.
576
+
577
+ ### Verification
578
+
579
+ 621 unit tests, 619 pass, 2 skip, 0 fail, 0 todo; 63 end-to-end. 20 suites wired into `ci.yml`,
580
+ `release.yml` and `package.json` with exhaustiveness checked. ESLint: 0 errors, 56 warnings, all
581
+ pre-reviewed. The `Docker` workflow built both architectures successfully on the runner — which
582
+ upgrades the multi-arch images added in 0.3.5 from "unverified" to **built**, though still not pushed
583
+ anywhere.
584
+
585
+ ### Still not done
586
+
587
+ **UI cards** — the last of the four v0.3.3 items.
588
+
589
+ ## [0.3.6] — 2026-10-08
590
+
591
+ ### Added
592
+
593
+ - **`GET /metrics`**, Prometheus text format, off unless `--metrics` is passed. It is **token-free**
594
+ like `/healthz` and for the same reason: a scraper that needs the operator token puts that token in a
595
+ Prometheus config file, which is a credential in a place nobody reviews. It is likewise exempt from
596
+ signature checks, because the scraper has no secret to sign with — requiring one would make metrics
597
+ unreachable on exactly the hardened deployments that most want them.
598
+
599
+ Disabled answers **404, not 403**: a 403 tells a scanner the surface exists, and an endpoint that
600
+ appears without being asked for is a surface nobody chose.
601
+
602
+ The renderer was implemented separately from the route on purpose, and this release is the evidence
603
+ for why the two are different: the module had 16 green tests while `GET /metrics` still returned 401,
604
+ because a correct renderer behind an unreachable route is indistinguishable from an unimplemented
605
+ feature. `test/metrics-route.test.mjs` now covers the route itself — absent by default, token-free
606
+ when enabled, reachable under `--require-signature`, and never leaking the operator token or the
607
+ signing secret into a body that gets scraped into long-lived storage.
608
+
609
+ Where the renderer's rule lives: a machine that never reported an RTT has **no series at all**, not a
610
+ `0`. In Prometheus `0` is a real measurement meaning "instantaneous" while a missing series is
611
+ `absent()` — the same null-versus-zero distinction the relay-side RTT work turns on.
612
+
613
+ ### Fixed
614
+
615
+ - **`--signing-secret`, `--signing-secret-previous`, `--require-signature` and `--signature-skew` were
616
+ parsed but missing from `--help`**, so an operator could not discover them from the CLI. A flag
617
+ nobody can find is a flag that does not exist in practice. Found by the teammate building the Docker
618
+ images, who reported it rather than fixing it outside their scope.
619
+ - A fourth mojibake casualty: `cannot dispatch a tas` in the operator-token error, another word the
620
+ earlier GBK round trip truncated.
621
+
622
+ ### Verification
623
+
624
+ 621 unit tests, 619 pass, 2 skip, 0 fail, 0 todo. 20 suites wired into `ci.yml`, `release.yml` and
625
+ `package.json` with exhaustiveness checked. ESLint: 0 errors, 55 warnings, all pre-reviewed.
626
+ `/metrics` was also confirmed against a live relay process: 404 without `--metrics`, and 200 with the
627
+ correct content type and metric series with it.
628
+
629
+ ### Still not done
630
+
631
+ **UI cards** — the last of the four v0.3.3 items. The eight tools render through the generic card.
632
+
633
+ ## [0.3.5] — 2026-10-08
634
+
635
+ Two of the four remaining v0.3.3 items: shared configuration and multi-arch images.
636
+
637
+ ### Added
638
+
639
+ - **Shared configuration** (`src/plugin/shared-config.mjs`). One committed file, `.w2m.json` at the
640
+ project root, that every machine reads — so the allow-list, the timeout and the update schedule stop
641
+ drifting per machine. Layering is `.w2m.json` → `~/.w2m/machine.json` → the host's own config object,
642
+ highest wins key by key.
643
+
644
+ It lives **in the project, not on the relay**, because a relay-hosted config would mean the relay
645
+ holds the fleet's credentials or the config splits into secret and non-secret halves — and the
646
+ operator ruled that the relay must not hold credentials. A file in the project needs nothing from the
647
+ relay, works while the relay is unreachable, and is reviewed in the same pull request as the code it
648
+ governs.
649
+
650
+ Four decisions worth stating, each of which is a way config systems fail their users:
651
+
652
+ - **Secrets are refused, not ignored.** A key matching `token`/`secret`/`password`/`credential`/
653
+ `apikey` is rejected with a message naming the key. Silently dropping it would let the operator
654
+ believe a token is in effect, and a token in a committed file is a leak that survives every later
655
+ decision.
656
+ - **A typo'd key is refused.** A silently-dropped key is indistinguishable from a setting that works,
657
+ which is the worse of the two failures.
658
+ - **Absence is tolerated, breakage is not.** No file is the normal case and yields defaults; a file
659
+ that exists and does not parse is a loud error. Running on defaults while the operator believes
660
+ their settings apply is exactly the silently-wrong outcome this project refuses elsewhere.
661
+ - **Every effective value reports its source** through `w2m_status`. "The setting is in the file I
662
+ edited and something else wins" is the characteristic failure of layered configuration, and the
663
+ answer must be readable from one call instead of by bisecting the layers.
664
+
665
+ `timeout_ms` lost its schema `default` for a related reason: with a default in the parameter map, DSH
666
+ fills it in before `execute` runs, so a timeout set in the shared config would be silently overridden
667
+ on every call and the operator would see their setting do nothing.
668
+
669
+ - **Multi-arch Docker images** (`deploy/docker/`, `.github/workflows/docker.yml`) for `linux/amd64`
670
+ and `linux/arm64`. The relay's real import closure was computed to prove the image installs **no npm
671
+ packages**: 7 files, ~175 KiB, all `node:` builtins. The image is non-root with a `VOLUME` state dir
672
+ and a token-free `HEALTHCHECK`.
673
+
674
+ ### Verification
675
+
676
+ 600 unit tests, 598 pass, 2 skip, 0 fail, 0 todo. 19 suites wired into `ci.yml`, `release.yml` and
677
+ `package.json` with exhaustiveness checked. ESLint: 0 errors, 55 warnings, all pre-reviewed.
678
+
679
+ ### Not verified
680
+
681
+ - **No Docker image was built.** Docker, Podman and buildah are all absent from this machine, and
682
+ Docker Hub is unreachable from it, so every build/push/platform claim is unverified. What *was*
683
+ verified without Docker: all COPY sources exist, all 7 closure files are covered by the COPY set, no
684
+ package manager appears anywhere, the base tag is an exact patch version, and — the strongest one —
685
+ the container's `CMD` was executed directly against the same file set, starting the relay, answering
686
+ `/healthz` without a token, exiting 0 from the exact `HEALTHCHECK` command, and writing the pairing
687
+ code to stderr.
688
+ - The base tag `node:22.23.3-bookworm-slim` could not be confirmed against the registry. The version
689
+ itself was verified against `nodejs.org/dist/index.json`.
690
+ - `metrics` is implemented but **not yet routed**; `GET /metrics` still 401s. See v0.3.6.
691
+
692
+ ### Reported, not fixed
693
+
694
+ `--signing-secret` and `--require-signature` are parsed by `bin/w2m-rabbit.mjs` but missing from its
695
+ `--help` output, so an operator cannot discover them from the CLI. Outside the scope of the task that
696
+ found it.
697
+
698
+ ## [0.3.4] — 2026-10-08
699
+
700
+ ### Added
701
+
702
+ - **`pipeline` mode.** Every machine runs the whole chain from `stages`, in order, stopping at the
703
+ first stage that fails. The semantics were chosen against the alternative — fan stage 1 out, converge
704
+ its outputs into stage 2 — because that one has **no well-defined input**: on a fleet, stage 1
705
+ produces *n* different outputs, and "feed them all to stage 2" does not say in what order, or
706
+ whether stage 2 then runs once or *n* times. Worse, two machines finishing in a different order would
707
+ produce different stage-2 inputs, so one `task_id` would no longer describe one reproducible
708
+ execution — which is the property this system is built on. A pipeline is therefore `replicate`
709
+ applied to a sequence: no cross-machine data flow, so nothing can be silently dropped in transit.
710
+ The reasoning is in `docs/PIPELINE-DESIGN.md`.
711
+
712
+ The chain's `command_hash` binds **every** stage. Bound to stage 0 alone, two chains differing after
713
+ the first stage would share a hash, and an envelope produced by the wrong chain would still verify —
714
+ defeating the point of the anchor. The agent and the relay compute this hash with independent
715
+ implementations, and a test recomputes both to catch a drift rather than letting them agree silently.
716
+
717
+ A per-stage record travels in the envelope, so a reader can tell "stage 1 failed" from "stages 2..n
718
+ never ran" — a single exit code cannot, and the two call for different responses.
719
+
720
+ ### Fixed
721
+
722
+ - **`pipelineCommandHash` was never imported into the agent.** Referenced but not bound, so every
723
+ pipeline offer threw `ReferenceError` at envelope-build time and was reported as `crashed`. The
724
+ mistake survived an import check because a *comment* in the same file mentioned the name: the check
725
+ searched the file text instead of the import block, so it reported success. The check now matches the
726
+ import statement itself.
727
+
728
+ ### Verified
729
+
730
+ `test/pipeline-e2e.test.mjs` drives a real relay and a real agent: the stages append to a witness file,
731
+ so the ordering evidence is written by the stages themselves and cannot be right unless they ran in
732
+ that order. The second case proves the chain stops — a failing second stage means the third never
733
+ writes to the witness at all.
734
+
735
+ ### Notes on what the e2e had to get right
736
+
737
+ Five versions of this file failed before it passed, and every failure was the test reaching for
738
+ something that does not exist:
739
+
740
+ - `GET /v1/tasks/{id}/report` returns a *string* body, not parsed JSON, and the aggregate lives under
741
+ `body`. `GET /v1/tasks/{id}` returns parsed JSON — and requires a **device** token, not the operator
742
+ token, which is what the first version sent.
743
+ - `aggregate.machines` is a projection that deliberately omits envelopes, so
744
+ `aggregate.machines[0].envelope` is `undefined` by design. The per-stage shape is asserted in
745
+ `broadcast.test.mjs` against the state object instead; the e2e asserts only what the wire publishes.
746
+ - `base_tree` must be the agent's `treeFingerprint` (`git-temp-index-tree/v1`), not
747
+ `git rev-parse HEAD^{tree}`. A git tree SHA is a different value, and sending one made every task
748
+ `unverifiable`.
749
+ - The agent's allow-list is **default-deny**; without `--allowed-commands` every offer is refused
750
+ `COMMAND_NOT_ALLOWED`, correctly, with a startup warning saying so.
751
+
752
+ ### Verification
753
+
754
+ 578 unit tests, 576 pass, 2 skip, 0 fail, 0 todo; 61 end-to-end plus 2 pipeline end-to-end. All 17
755
+ suites are wired into `ci.yml`, `release.yml` and `package.json` with exhaustiveness checked. ESLint:
756
+ 0 errors, 54 warnings, all pre-reviewed.
757
+
758
+ ### Still not done
759
+
760
+ Shared config, UI cards, metrics and multi-arch images from the v0.3.3 batch remain untouched.
761
+
762
+ ## [0.3.3] — 2026-10-08
763
+
764
+ The CI hardening the v0.3.0 batch called for, plus a correctness fix those tests found.
765
+
766
+ ### Fixed
767
+
768
+ - **Every successful `split` was reported `divergent`.** `index` is a comparable field, the relay
769
+ hands each machine its own slice by index, and Step 3 of the aggregation flagged any comparable
770
+ field that differed. So a fan-out where every machine did exactly what it was asked produced a
771
+ verdict that `README.md` defines as "machines disagreed" — a false alarm on the normal case, every
772
+ time. Step 3 now compares **within a shard**: machines meant to produce the same output are
773
+ compared with each other, and machines given different slices are not compared at all.
774
+
775
+ The fix is a grouping rather than a special case, which is why it is safe: for `replicate` every
776
+ machine has index 0, so there is a single group and the behaviour is byte-identical. The 140
777
+ existing relay tests passing unchanged is the evidence for that, not a new test asserting it.
778
+ `step3.comparable` still reports each machine's index — it is simply not treated as disagreement.
779
+
780
+ Found by the stress suite written in the same release, not by inspection: no test anywhere covered
781
+ split aggregation, because the only split test asserted index *assignment*.
782
+
783
+ ### Added
784
+
785
+ - **`test/stress.test.mjs`** — bounded load, asserted on the ledger rather than on status codes: 200
786
+ concurrent dispatches (ids unique, all retrievable, `/healthz` and `GET /v1/tasks` agreeing), 24
787
+ concurrent redeliveries of one instance recorded exactly once (checked in `results`, `taskResults`,
788
+ `result_seqs` and `dedupeReport`, with a verified `envelope_sha256`), 50 devices with no lease lost
789
+ and split indices matching `i % index_total`, and a limit-3/burst-12 refusal test with two controls
790
+ — failed attempts consume the same budget, and with the limiter off the same burst yields zero 429s.
791
+ - **`test/recovery.test.mjs`** — the disconnect story against real processes: relay killed mid-task
792
+ and restarted, with the agent noticing on its own and the outcome still delivered; the witness file
793
+ showing the command ran exactly once across the reconnect; a cleanly closed stream treated as a
794
+ disconnect with a laddering backoff rather than a hot loop; and a 401 result staying spooled with
795
+ exactly one attempt.
796
+ - **`scripts/scan-mojibake.mjs`** — the corruption that hit user-visible strings earlier is invisible
797
+ in review because the bytes are valid UTF-8; they just spell the wrong thing. This scans for it.
798
+
799
+ ### Notes on what these tests had to get right
800
+
801
+ Both new suites produced failures that were in the tests, not the product, and the distinction
802
+ mattered each time:
803
+
804
+ - The recovery suite's `waitFor` used a truthiness check on an exit code, so `0` — success — read as
805
+ "not yet" and the case timed out while the product was working correctly. Confirmed with an
806
+ isolated repro before changing anything.
807
+ - The stress suite's split assertion initially encoded the buggy expectation (`differences` non-empty)
808
+ alongside the correct one. Fixing the product meant inverting that assertion, which is exactly the
809
+ signal that the recorded defect was real rather than a misreading.
810
+ - The stress suite's independent check read `m.envelope`, which `aggregateTask` deliberately omits
811
+ from its machine projection; it now reads `step3.comparable`, which is the field that actually
812
+ carries the compared values.
813
+
814
+ ### Verification
815
+
816
+ 568 unit tests, 566 pass, 2 skip, 0 fail, **0 todo**; 61 end-to-end. All 16 suites are wired into
817
+ `ci.yml`, `release.yml` and `package.json`, and that exhaustiveness is itself checked — an earlier
818
+ release had suites that no script ever ran. ESLint: 0 errors, 53 warnings, all pre-reviewed.
819
+
820
+ ### Not in this release
821
+
822
+ - **`pipeline`** — the semantics question is answered (the whole chain runs on one machine, so
823
+ `broadcast` chooses where and `pipeline` describes the sequence) but the implementation is not
824
+ written. The reasoning and the required changes are in `docs/PIPELINE-DESIGN.md`; the relay rejects
825
+ `mode: "pipeline"` with `BAD_REQUEST` rather than silently falling back to `replicate`.
826
+ - **Shared config, UI cards, metrics, multi-arch images** — untouched; the next batch.
827
+
828
+ ## [0.3.2] — 2026-10-08
829
+
830
+ The first half of the v0.3.3 batch: a third dispatch mode.
831
+
832
+ ### Added
833
+
834
+ - **`broadcast` mode.** One machine runs the command and every other machine is told the outcome
835
+ without running anything. Useful when the point is not redundancy but *notification* — a migration
836
+ applied once, a deploy, a cache purge — and the other machines need to know it happened.
837
+
838
+ The design is built around one hazard: **a machine that did not run must never be counted as
839
+ agreeing.** If observers were ordinary participants, a broadcast of a successful command would
840
+ aggregate to `consistent`, and a reader would reasonably conclude that the whole fleet had verified
841
+ the result when in fact one machine ran and the others were merely told. So observers hold an
842
+ `observing` lease, which Step 0 of the aggregation routes out of the participant set entirely:
843
+ they cannot pass, cannot refuse, and cannot contribute to a verdict. `step0.observing` lists them
844
+ explicitly so the report says who was informed rather than implying they were involved.
845
+
846
+ Three consequences worth stating, because each is a bug the obvious implementation would have:
847
+
848
+ - Without treating `observing` as "not a participant", an observer has no envelope and never will,
849
+ so the anchor step classifies it `pending` and **the task can never reach a verdict** — every
850
+ broadcast would hang. Covered by `does not hold the verdict open for a silent observer`.
851
+ - A broadcast whose executor has not reported yet must be `pending`, not `consistent`. With only
852
+ observers participating, a naive aggregation sees zero machines and zero failures, which reads as
853
+ success. Covered by `is pending, not consistent, while the executor has not reported`.
854
+ - The executor is chosen **deterministically** (the first machine the relay lists), not randomly. A
855
+ random leader makes a task impossible to reproduce from its own record — the same `task_id` would
856
+ run somewhere else on a retry — and being able to say exactly where something ran is the point of
857
+ this project.
858
+
859
+ `executor_machine_id` selects the machine explicitly and is **refused outside broadcast** rather
860
+ than ignored: the relay reads it only for `broadcast`, so accepting it under `replicate` would look
861
+ like "only this machine runs it" while the command ran everywhere. A restriction that is not
862
+ enforced is worse than no parameter. For the same reason `broadcast` with `index_total > 1` is
863
+ refused instead of coerced — running one shard while recording a split task would put work in the
864
+ report that never happened.
865
+
866
+ ### Verification
867
+
868
+ 559 unit tests, 557 pass, 2 skip, 0 fail; 57 end-to-end. Twelve new tests in
869
+ `test/broadcast.test.mjs`, written around the failure this mode can most easily produce rather than
870
+ around the happy path. ESLint 0 errors.
871
+
872
+ ### Not in this release
873
+
874
+ `pipeline` and the composable mode are **not implemented**. The relay rejects `mode: "pipeline"` with
875
+ `BAD_REQUEST` rather than falling back to `replicate`: silently running a chained request on every
876
+ machine would be an expensive misreading of what was asked.
877
+
878
+ ## [0.3.1] — 2026-10-08
879
+
880
+ Two tools that landed after 0.3.0 was published, so they get their own version rather than a moved
881
+ tag: a published release is a promise about a specific tarball, and rewriting it would break anyone
882
+ who already downloaded it.
883
+
884
+ ### Added
885
+
886
+ - **`w2m_history`** — recent tasks newest-first with their modes, shard counts, lease holders and
887
+ cancellation state, without fetching a single result envelope. It reports how many tasks the relay
888
+ *holds* alongside how many it returned, because a page emptied by a filter and an empty relay
889
+ produce identical lists and only one of them is a problem. The limit is clamped locally so a model
890
+ cannot make this machine allocate a million rows.
891
+ - **`w2m_stats`** — the fleet in one call: machine and task counts, aggregate RTT, whether the relay
892
+ restarted. It degrades rather than failing: if this machine cannot read the per-machine roster it
893
+ still reports the relay's own totals, with a note naming which half is missing.
894
+
895
+ In both, `null` means "the relay did not report it" and never "zero" — the same distinction the
896
+ 0.3.0 relay-side RTT work turns on, applied at the tool boundary.
897
+
898
+ The tool count is now **eight**, which is the number the original request asked for.
899
+
900
+ ### Verification
901
+
902
+ 545 unit tests, 543 pass, 2 skip, 0 fail; 57 end-to-end. `scripts/verify-installed.mjs` confirms
903
+ eight tools register in both the repository and the packed artifact, and the CI `lint` job reports
904
+ 0 errors.
905
+
906
+ ## [0.3.0] — 2026-10-08
907
+
908
+ ### Added
909
+
910
+ - **Cross-machine RTT, carried by the relay.** The round-trip time used to live only in a local
911
+ state file, so with the plugin on machine A and the agent on machine B — the normal cross-region
912
+ shape — there was no view at all. The heartbeat now carries `rtt_ms`; the relay stores it and
913
+ exposes it per device (`/v1/devices`), in aggregate (`/healthz.rtt`), and per machine
914
+ (`/v1/agents/{id}/status`).
915
+ - **Idle diagnostic heartbeats.** An agent with no work now reports every 60 s, because the moment
916
+ you look at a machine is the moment it has nothing to do. Without this the cross-machine view
917
+ freezes at whatever the last task measured — and once `rtt_stale` flipped, it read as "the machine
918
+ is up but slow", which is worse than "unknown".
919
+ - **Three new aggregate verdicts**, so the status field says what actually happened:
920
+
921
+ | verdict | replaces |
922
+ |---|---|
923
+ | `timeout` | `pending` forever, which made "nobody will ever answer" look like "still in flight" |
924
+ | `cancelled` | indistinguishable from `failed`, which reports a deliberate stop as a malfunction |
925
+ | `degraded` | `partial`, which also means "machines are still out" |
926
+
927
+ `partial` keeps its original meaning: work is outstanding. The three additions are verdicts, not
928
+ phase flags, which is why they sit in `AGGREGATE_STATUSES` rather than beside `pending`.
929
+ - **Request signing** (`src/signing.mjs`, wired into the relay in this same release). HMAC over the
930
+ method, the routed path, a timestamp, a nonce and the exact body bytes. It defends replay,
931
+ tampering and a leaked log. It is **not** end-to-end encryption and not a replacement for TLS, and
932
+ the module says so in its first paragraph — a security claim that overreaches is worse than none.
933
+ - **`w2m_history` and `w2m_stats`**, taking the tool count from six to eight. `w2m_history` lists
934
+ recent tasks newest-first with their modes, shard counts, lease holders and cancellation state,
935
+ without fetching a single result envelope; it reports how many tasks the relay *holds* alongside
936
+ how many it returned, because a page emptied by a filter must not be indistinguishable from an
937
+ empty relay. `w2m_stats` summarises the fleet in one call — machine and task counts, the aggregate
938
+ RTT, whether the relay restarted — and degrades to the relay-reported totals when this machine
939
+ cannot read the per-machine roster. In both, `null` means "the relay did not report it" and never
940
+ "zero".
941
+ - **A mechanical outbound-only check** (`test/outbound-only.test.mjs`). The project's central promise
942
+ is that no public IP, port forwarding or SSH server is needed because every machine only dials
943
+ out; that is easy to state and easy to break. The test runs real processes and asserts that a
944
+ registered agent binds nothing, that the relay's default bind is loopback and says so in its
945
+ banner, and that an unbindable host is a startup failure rather than a silent fallback.
946
+ - **One-click installers**: `scripts/install.ps1` and `scripts/install.sh`, both verifying the
947
+ published SHA-256 before writing anything, and both re-reading the installed package afterwards to
948
+ confirm the tools actually register ("it installed" is not "it loads").
949
+ - **ESLint and Prettier**, with `docs/LINT-AUDIT.md` recording the current state honestly: 1 real
950
+ defect, 27 style findings, 46 deliberately accepted. Prettier is configured but not applied —
951
+ measured, it would rewrite 23,715 of 26,383 lines (89.9 %), and obliterating `git blame` for the
952
+ whole repository to satisfy a formatter is a bad trade. The CI job reports the number instead.
953
+
954
+ ### Fixed
955
+
956
+ - **`resolveProfileDir` called `existsSync` without importing it.** Under ESM that is a
957
+ `ReferenceError`, so `apply()` threw for any plugin loaded from a profile — the normal installed
958
+ layout. It survived 458 green tests because no test ever executed that branch. ESLint's `no-undef`
959
+ found it. Fixed by importing it *and* by moving the function out of the closure into an exported,
960
+ directly testable helper with seven tests: the import alone would leave the next such mistake just
961
+ as invisible.
962
+ - **A signing-path disagreement between the two sides.** The agent signed the raw request URL; the
963
+ relay verified the routed path. Both unit suites passed. The mismatch breaks only the third
964
+ documented deployment — a reverse proxy that strips the prefix — where the relay never sees the
965
+ prefixed string at all, so no reconstruction is possible. The client now signs the routed path
966
+ (query included), and `test/signing-e2e.test.mjs` drives a real relay behind `--base-path` with a
967
+ real signature so the contract cannot drift again.
968
+ - **Mojibake in user-visible strings.** An earlier PowerShell edit round-trip wrote parts of
969
+ `src/plugin/tools.mjs` through GBK, turning em-dashes into CJK sequences inside error messages and
970
+ tool descriptions — text a model and a human actually read. Repaired with
971
+ `scripts/fix-mojibake.mjs`, which uses only escape sequences so it cannot be corrupted by the same
972
+ round-trip, plus `scripts/scan-truncations.mjs` to catch words the corruption had truncated
973
+ (`unavailable` had become `unavailabl`, and only a test noticed).
974
+ - **`npm test` and both verify scripts listed three suites**, so the v0.2.3 suites never ran through
975
+ them. Every `test/*.test.mjs` now appears in `ci.yml`, `release.yml` and `package.json`, and that
976
+ exhaustiveness is itself checked.
977
+ - **`engines` claimed Node >= 20.0.0** while ESLint 10 requires >= 20.19.0.
978
+ - **`PROTOCOL-v0.3.0.md` was not in `package.json` `files`**, so the compatibility matrix would not
979
+ have shipped.
980
+
981
+ ### Compatibility
982
+
983
+ - `PROTOCOL_VERSION` stays **1**. Everything here is additive: new optional request fields, new
984
+ response fields, one new endpoint, and optional verification.
985
+ - An unsigned request is accepted exactly as before unless a relay is given a signing secret **and**
986
+ `--require-signature`. The relay suite's pre-existing tests passed unchanged when signing was
987
+ wired in, which is the evidence for that claim rather than a new test asserting it.
988
+ - A v0.2.3 agent works against a v0.3.0 relay and vice versa. Two caveats, both documented in
989
+ `PROTOCOL-v0.3.0.md`: a v0.2.3 relay answers an idle heartbeat with `404` (the agent must tolerate
990
+ it, and does — as debug-only, with no retry storm), and a v0.3.0 relay reports `rtt_ms: null` for a
991
+ machine that never reports one. `null` means "never measured"; `0` means "very fast", and the two
992
+ are never conflated.
993
+
994
+ ### Verification
995
+
996
+ 539 unit tests, 537 pass, 2 skip (POSIX mode bits on NTFS); 54 end-to-end + crossnetwork.
997
+ ESLint reports **0 errors, 46 warnings**, every warning being one of the two classes
998
+ `docs/LINT-AUDIT.md` already classified as benign and deliberately accepted.
999
+
1000
+ Two of the lint findings were not cosmetic, and both were restored rather than deleted:
1001
+
1002
+ - An agent test counted stream attempts in `requests` and never read it. The test is named
1003
+ "keeps retrying gently instead of hot-looping" — the counter *was* the assertion, so an
1004
+ implementation that backed off for the log line while opening a connection per millisecond would
1005
+ have passed. The bound is back.
1006
+ - `auto-update.mjs` accepted an `intervalDays` option and never enforced it. A declared guard that
1007
+ silently does nothing is worse than no option at all, because it reads as protection. It is now
1008
+ enforced against the last *successful* install, checked before any network work.
1009
+
1010
+ The signing vectors from `PROTOCOL-v0.3.0.md` §9.4.1 are pinned in **both** suites and recomputed
1011
+ independently in each, so a drift in either implementation turns one of them red — a shared constant
1012
+ would let both agree on a wrong value.
1013
+
1014
+ > The `v0.3.0` tag was moved once, from the commit that first carried this version to the commit
1015
+ > containing these fixes. The first tag had no published release associated with it yet, and the
1016
+ > difference was in shipped source (a missing guard, three malformed error messages), so leaving the
1017
+ > tag behind would have published code that does not match this changelog.
1018
+
1019
+ ### Not verified
1020
+
1021
+ - **ESLint has not been run in this working tree** (no npm registry access, so `devDependencies` are
1022
+ not installed). The `lint` CI job runs it on the hosted runner. The one defect it found was fixed
1023
+ by inspection, and the `no-undef` rule is what will confirm that.
1024
+ - **`scripts/install.sh` has not been executed** — there is no macOS or Linux shell on the
1025
+ development machine. It is statically reviewed only, and the script says so itself.
1026
+ - **macOS remains unverified on real hardware.** CI runs the agent logic on `macos-latest`, which is
1027
+ not the same as two machines reaching one relay.
1028
+ - **No real signed deployment has been exercised across a proxy.** The sub-path case is covered
1029
+ against a real relay with `--base-path`; a real nginx/Caddy stripping the prefix is not available
1030
+ here.
1031
+
1032
+ ## [0.2.3] — 2026-10-08
1033
+
1034
+ ### Added
1035
+
1036
+ - **Daily self-update.** A machine running the plugin checks its own GitHub
1037
+ releases at **00:00, 03:00 and 05:00 Beijing time** and installs a newer
1038
+ version, verifying the published SHA-256 before writing anything. Off by
1039
+ default; `autoUpdate: true` turns it on. New tool **`w2m_update`** reports the
1040
+ schedule, the installed version, the last check and whether a restart is
1041
+ pending, and can run one cycle on demand.
1042
+ - **A timezone-aware daily scheduler** (`src/plugin/schedule.mjs`), used by the
1043
+ updater. The host's own `@deepseek-ai/dsh-schedule` was evaluated and rejected
1044
+ for this job: its reminders are bound to an Agent Session and delivered as
1045
+ inbox messages, and its README states it "cannot be mounted alone in a
1046
+ headless or SDK-only composition" and that the shipped Web composition carries
1047
+ no `schedule` row. A plugin-owned recurring job has no Session to bind to, so
1048
+ it owns its timer — reversibly, via `ctx.effect`.
1049
+
1050
+ ### Why the updater is built the way it is
1051
+
1052
+ Each of these is a decision, not an implementation detail:
1053
+
1054
+ - **Never a downgrade.** `isNewer` is a strict-greater test, and a prerelease is
1055
+ refused separately: `0.2.0-rc.1` outranks `0.1.2` by SemVer, so a comparison
1056
+ alone would ship candidates to stable installs. The comparison answers "which
1057
+ is higher"; the policy layer answers "may we install it".
1058
+ - **Verify, then write.** The tarball must match the `SHA256SUMS` published with
1059
+ that release. A mismatch writes zero bytes — no staging directory, no backup.
1060
+ - **Back up and roll back.** `package.json` and `pnpm-lock.yaml` are restored
1061
+ byte-for-byte on failure, through the supported `dsh plugin` entry point rather
1062
+ than by editing `node_modules` by hand.
1063
+ - **Say what a rollback really guarantees.** Restoring the manifest does not undo
1064
+ what pnpm already did to `node_modules`. Rather than claim "rolled back", the
1065
+ result carries `rollbackComplete` (does the installed tree match the restored
1066
+ manifest again?) plus a `reconciliation` command, and the plugin exposes it as
1067
+ `reconciliation_needed`.
1068
+ - **A lookup failure is not "up to date".** They look identical in a log and only
1069
+ one of them is a problem, so a failed check is recorded as an error.
1070
+ - **The updater does not restart DSH.** Installing changes what loads next start.
1071
+ Rewriting a module that a live Cordis container has already loaded is not
1072
+ something a plugin may do safely, so the status says `restart_required`
1073
+ instead of implying the new code is live.
1074
+
1075
+ ### Fixed
1076
+
1077
+ - **`ctx.effect` was called through optional chaining.** `ctx.effect?.(...)` in
1078
+ `apply()` meant that on any host without it the scheduled job would be
1079
+ registered nowhere and released never — no error, no log, just a daily task
1080
+ that silently does not exist. It is now a required call that fails loudly by
1081
+ name (`W2M_NO_EFFECT`) when the feature is enabled, and is skipped entirely
1082
+ when it is not. Verified against the real runtime that Cordis 4.0.4 does
1083
+ provide it, so the strict call does not break the current host.
1084
+ - **The plugin's own version is now baked in at pack time.** `PLUGIN_VERSION` is
1085
+ a placeholder in the repository and is substituted from `package.json` by
1086
+ `scripts/pack.mjs`, which fails if the placeholder goes missing. A
1087
+ hand-maintained version string would eventually disagree with the release, and
1088
+ the updater compares against exactly that string.
1089
+ - **A malformed update setting fails loudly.** `autoUpdateTimes`,
1090
+ `autoUpdateTimeZone` and `updateRepo` are validated at load and name
1091
+ themselves in the error. Silently falling back to a default would move the
1092
+ check to an hour nobody chose, and nothing would say so.
1093
+
1094
+ ### Compatibility
1095
+
1096
+ - `PROTOCOL_VERSION` stays **1**. This release adds no wire changes: everything
1097
+ here is local to the plugin, plus one new tool.
1098
+ - The tool count goes from five to six with `w2m_update`. Nothing existing
1099
+ changed shape.
1100
+
1101
+ ### Verification
1102
+
1103
+ 435 unit tests, 434 pass, 1 skip (POSIX mode bits are meaningless on NTFS):
1104
+
1105
+ | suite | tests | covers |
1106
+ |---|---|---|
1107
+ | relay | 91 | unchanged from 0.1.2 |
1108
+ | agent | 105 | unchanged from 0.1.2 |
1109
+ | plugin | 73 | eight tools registered, operator token, sub-path, diagnostics, plus config validation and `ctx.effect` ownership |
1110
+ | schedule | 39 | zone maths, exact UTC instants for each slot, DST zones, a year of consecutive arming, catch-up collapse |
1111
+ | auto-update | 34 | the install/skip decision, no downgrade, prerelease refusal, unverified tarball refused, lookup failure recorded as an error, token never persisted |
1112
+ | update-source | 50 | version ordering, streaming SHA-256, timeouts, rate limits, one real GitHub call |
1113
+ | update-install | 28 | zero writes on hash mismatch, byte-for-byte restore, atomic staging, dry run |
1114
+ | update-wiring | 15 | config validation is loud, one `ctx.effect` owns the timer, its disposer stops it |
1115
+
1116
+ The schedule is checked against a whole simulated year: every arming must advance,
1117
+ land exactly on its configured slot, and never shift the Beijing offset — the
1118
+ invariant a drifting implementation breaks slowly and invisibly.
1119
+
1120
+ ### Not verified
1121
+
1122
+ - **No real install has been performed against a live DSH profile by this
1123
+ release's test suite.** The installer's integration tests use throwaway
1124
+ profiles in temp directories; `C:\Users\fangw\.dsh` was never written to.
1125
+ - **The end-to-end "a new release is published and a running machine picks it up
1126
+ by itself" path has not been exercised against a real new release.** Every
1127
+ stage is covered in isolation and with injected transports; the loop closing in
1128
+ production is the first thing to watch on the next version bump.
1129
+ - The updater has not been observed through a full sleep/wake cycle on real
1130
+ hardware; the catch-up rule is covered by tests against a simulated clock.
1131
+ - The real-GitHub test is **opt-in** (`W2M_NETWORK_TESTS=1`) rather than part of
1132
+ the default run. It failed on CI with HTTP 403 `RATE_LIMITED` because GitHub
1133
+ allows 60 unauthenticated requests per hour *per IP* and hosted runners share
1134
+ IPs — a gate that depends on someone else's rate-limit budget eventually blocks
1135
+ a release for an unrelated reason. CI still exercises it in a separate
1136
+ non-blocking job with the workflow token; the same code paths are covered by
1137
+ mocked tests in the blocking matrix.
1138
+
1139
+
1140
+ All notable changes to this project are documented here.
1141
+ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1142
+
1143
+ ## [0.1.2] — 2026-10-07
1144
+
1145
+ **Cross-region, cross-network.** The relay can now live on a Tailscale network, a
1146
+ public VPS behind TLS, or behind a tunnel, and the three deployment shapes are
1147
+ documented end to end in [docs/DEPLOY.md](docs/DEPLOY.md).
1148
+
1149
+ ### Breaking
1150
+
1151
+ - **`POST /v1/task` now requires an operator token.** Receiving work and
1152
+ authorising work are different privileges: a machine that can be told what to
1153
+ do should not automatically be able to tell the others. Device tokens keep
1154
+ working for every other endpoint.
1155
+ - The relay generates the token on first start and writes it to
1156
+ `<state>/operator-token.txt` (mode 0600), printing it once.
1157
+ - The plugin needs it as `operatorToken` (or `W2M_OPERATOR_TOKEN`).
1158
+ - To restore the old behaviour deliberately, start the relay with
1159
+ `--operator-token ''`; it will warn loudly and report
1160
+ `operator_token_required: false` on `/healthz`. Intended for a trusted LAN,
1161
+ not for anything reachable from the internet.
1162
+
1163
+ ### Fixed
1164
+
1165
+ - **Sub-path deployments were completely broken.** Endpoints were built with
1166
+ `new URL('/v1/stream', rabbitUrl)`, which discards any path prefix:
1167
+ `new URL('/v1/stream', 'https://host/w2m')` resolves to
1168
+ `https://host/v1/stream`. A relay mounted at `/w2m` answered 404 to everything.
1169
+ URLs are now joined by concatenation, so `rabbitUrl` may carry a sub-path.
1170
+ - **`rabbitUrl` was not validated.** A URL carrying a query string or fragment
1171
+ silently produced wrong endpoints; it now fails at startup, naming the setting.
1172
+ - **Work dispatched while a machine's event stream was down was lost forever.**
1173
+ An offer is a single event. If it was published in the window after a relay
1174
+ restart but before the machines re-attached their streams, nothing ever
1175
+ re-delivered it: the lease sat at `offered` until the sweep expired it, while
1176
+ both machines reported `online=true` and idle. The relay now re-delivers every
1177
+ unclaimed (`queued`/`offered`) lease when a stream attaches — `running` is
1178
+ excluded so a task that has already begun can never be handed out twice.
1179
+ - **The re-delivery path itself was broken and had never run.** It called
1180
+ `this.logger(...)` unconditionally — where `logger: null` is the documented
1181
+ "quiet" setting — after the response headers were already sent, so the
1182
+ `TypeError` was caught by the top-level handler, which then tried to answer a
1183
+ second time and produced `Cannot write headers after they are sent`. Any
1184
+ reconnect with pending work destroyed the event stream. Logging now goes
1185
+ through a call that tolerates a missing, null or throwing logger, and an error
1186
+ raised after headers are out ends the stream and records the stack instead of
1187
+ attempting a second response. The previous behaviour is also why the original
1188
+ bug hid for so long: the secondary error masked the real one.
1189
+ - **A cursor from a previous relay process silently swallowed events.** Sequence
1190
+ numbers restart at 0, so an agent reconnecting with `Last-Event-ID: 42` made
1191
+ the new process believe those events had been consumed — dropping not only the
1192
+ offer but every `task.cancel` and `notice` in that range. The relay now
1193
+ recognises a cursor beyond its own last sequence, reports
1194
+ `REPLAY_TRUNCATED` (with `reason: cursor_ahead_of_relay`) and re-aligns the
1195
+ replay to the head of its window; the agent no longer sends a cursor that was
1196
+ issued by a different relay process, and re-attaches without one instead.
1197
+ > A delivery guarantee must come from re-delivery, which is cursor-independent.
1198
+ > Desynchronisation detection is a diagnostic, not a safety net: the
1199
+ > `from > lastSeq + 1` test was shown to miss a stale cursor once a few events
1200
+ > filled the gap.
1201
+ - **`last_available_seq` and `oldest_available_seq` described different
1202
+ windows** — one counted the un-buffered `ready` frame — so a client computing
1203
+ its re-alignment point from them could land in the wrong place. Both now use
1204
+ the ring-buffer window.
1205
+
1206
+ ### Added
1207
+
1208
+ - **Deployment base path** (`--base-path`) for reverse proxies and tunnels that
1209
+ forward the prefix through unchanged.
1210
+ - **Proxy awareness** (`--trust-proxy`) for `X-Forwarded-Proto` and
1211
+ `X-Forwarded-For`. Off by default, and deliberately so: trusting those headers
1212
+ without a proxy in front lets a client forge its address and bypass rate limits.
1213
+ - **TLS termination in the relay** (`--tls-cert` / `--tls-key`) as an alternative
1214
+ to terminating at a proxy.
1215
+ - **Persistence.** `devices.json` (atomic snapshot) plus `ledger.jsonl`
1216
+ (append-only) mean a restarted relay keeps its paired machines and its task
1217
+ history. A corrupt line is skipped and reported rather than being fatal; a
1218
+ corrupt device file starts empty *and says so*. `--no-persist` restores the old
1219
+ in-memory behaviour.
1220
+ - **Pairing rate limit**, 5 attempts per IP per minute by default, counting
1221
+ successes too — otherwise successful requests would refill the budget.
1222
+ - **`/healthz` is now an operations surface**: `relay_id`, `started_at`,
1223
+ `effective_scheme`, `base_path`, `operator_token_required`, `pair_rate_limit`
1224
+ and a `persistence` block. The three questions that matter when machines are in
1225
+ different regions are "is it up", "has it restarted", and "who is connected",
1226
+ and they should be answerable without reading logs.
1227
+ - **Restart is visible to clients.** `ready` carries `relay_id`; an agent that
1228
+ sees it change warns and drops its event cursor, because sequence numbers from
1229
+ a previous process are meaningless.
1230
+ - **RTT visibility.** Agents measure heartbeat round-trip time and expose it
1231
+ through `w2m_status` — the first question about a distant relay is how far away
1232
+ it is.
1233
+ - **Anti-buffering response headers** on the event stream
1234
+ (`X-Accel-Buffering: no`, `Cache-Control: no-cache, no-transform`). A buffering
1235
+ proxy turns "connected" into "connected but silent", which is the single most
1236
+ confusing failure in tunnel deployments.
1237
+ - **`REPLAY_TRUNCATED` now carries `oldest_available_seq`** so a client that fell
1238
+ outside the replay window knows where to re-align instead of only learning that
1239
+ it cannot catch up.
1240
+ - Deployment assets: systemd unit, Dockerfile + compose, nginx and Caddy
1241
+ examples, and [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md).
1242
+
1243
+ ### Compatibility
1244
+
1245
+ - `PROTOCOL_VERSION` stays **1**: everything above is an additive change except
1246
+ the operator token, which is called out under Breaking.
1247
+ - A 0.0.1 client can still pair, take work and report results against a 0.1.2
1248
+ relay. It cannot submit tasks, because it does not know about operator tokens.
1249
+
1250
+ ### Verification
1251
+
1252
+ 315 tests, 314 pass, 1 skip (POSIX mode bits are meaningless on NTFS):
1253
+ relay 91, agent 105, plugin 73, end-to-end + crossnetwork 46. CI runs the matrix
1254
+ on Windows (node 20 and 22), macOS and Linux, plus a job proving the release
1255
+ artifact is reproducible from source.
1256
+
1257
+ The lost-offer bug above is covered by a regression test that reproduces the
1258
+ exact sequence — stream attached, relay killed, task dispatched into the gap,
1259
+ stream reconnecting with a stale cursor — and asserts the offer arrives. It is
1260
+ deliberately not a timing-dependent test: the window it exercises is the one that
1261
+ was broken.
1262
+
1263
+ ## [0.0.1] — 2026-10-07
1264
+
1265
+ First release. Scope: **one DeepSeek account, one instruction, several machines running the same project.**
1266
+
1267
+ ### Added
1268
+
1269
+ - **Rabbit relay** (`w2m-rabbit`) — a `node:http` server with no third-party
1270
+ dependencies. Device registry, task ledger, long-lease-with-progress-heartbeat
1271
+ scheduling, `dedupe_key` deduplication, SSE event stream with `seq` replay, and
1272
+ six-state aggregation.
1273
+ - **Localside agent** (`w2m-localside`) — runs on every participating machine.
1274
+ Pairs with the relay, keeps an SSE connection open with jittered exponential
1275
+ backoff, claims leases, applies the capability gate, collects the three
1276
+ anchors, executes argv directly (never through a shell), and writes results to
1277
+ a local spool before reporting them.
1278
+ - **DSH plugin** (`@twinsearth/w2m-dsh-plugin/tools`) — five model-facing tools:
1279
+ `w2m_devices`, `w2m_run`, `w2m_wait`, `w2m_report`, `w2m_status`.
1280
+ - **Two coordination modes** — `replicate` (every machine runs the same command;
1281
+ results are compared side by side) and `split` (`explicit` / `modulo` sharding).
1282
+ - **Rotating pairing codes** — a successful pairing consumes the code and the
1283
+ relay immediately mints and prints the next one, so several machines can join
1284
+ without restarting the relay. `pairingCodeReusable: true` keeps one code alive
1285
+ for the whole TTL for scripted onboarding.
1286
+ - **Three-anchor reproducibility** — `base_commit` + `pre_tree_fingerprint`
1287
+ (algorithm `git-temp-index-tree/v1`) + `command_hash`. A mismatch is reported
1288
+ as `unverifiable`, which is accounted separately from `divergent`.
1289
+ - **Capability gate** — toolchain and platform requirements are checked on the
1290
+ relay side before dispatch; an unsatisfied machine answers `refused`, which is
1291
+ not a failure.
1292
+ - **Zero third-party runtime dependencies** — only `node:` built-in modules.
1293
+
1294
+ ### Known limitations
1295
+
1296
+ - **Not verified on a real macOS machine.** The code is platform-neutral and
1297
+ uses `node:path` throughout, but every measurement in this release was taken
1298
+ on Windows. Treat macOS support as "expected to work, unverified".
1299
+ - **No TLS.** Authentication is the pairing code plus per-device bearer tokens.
1300
+ Run the relay on a trusted network, or put a reverse proxy in front of it.
1301
+ - **`write: true` does not merge.** A write-enabled task records the branch it
1302
+ used; combining branches is left to the operator.
1303
+ - **No cross-machine browser dashboard.** Out of scope for 0.0.1; the design note
1304
+ for it lives in the project's design document.
1305
+ - **Pairing code is single-use and shown once** at relay start. If you lose it,
1306
+ delete `state.json` in the relay's state directory and restart.
1307
+
1308
+ [0.0.1]: https://github.com/TwinsEarth/dsh-windows2macos/releases/tag/v0.0.1
1309
+ [0.1.2]: https://github.com/TwinsEarth/dsh-windows2macos/releases/tag/v0.1.2