@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.
- package/CHANGELOG.md +1309 -0
- package/LICENSE +21 -0
- package/PROTOCOL-v0.1.2.md +165 -0
- package/PROTOCOL-v0.3.0.md +454 -0
- package/PROTOCOL-v0.3.9.md +207 -0
- package/PROTOCOL-v0.4.0.md +255 -0
- package/PROTOCOL.md +339 -0
- package/README.md +665 -2
- package/bin/w2m-localside.mjs +303 -0
- package/bin/w2m-rabbit.mjs +396 -0
- package/bin/w2m-stun.mjs +231 -0
- package/cordis.patch.yml +94 -0
- package/deploy/caddy/Caddyfile.example +118 -0
- package/deploy/docker/Dockerfile +144 -0
- package/deploy/docker/README.md +338 -0
- package/deploy/docker/docker-compose.yml +179 -0
- package/deploy/nginx/w2m.conf.example +196 -0
- package/deploy/shared-server/README.md +118 -0
- package/deploy/shared-server/install.sh +255 -0
- package/deploy/systemd/w2m-rabbit.service +150 -0
- package/deploy/systemd/w2m-stun.service +69 -0
- package/deploy/windows/README.md +115 -0
- package/deploy/windows/install-service.ps1 +195 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
- package/deploy/windows/winsw/w2m-localside.xml +50 -0
- package/docs/DEPLOY.md +902 -0
- package/docs/LINT-AUDIT.md +492 -0
- package/docs/PIPELINE-DESIGN.md +95 -0
- package/docs/TROUBLESHOOTING.md +560 -0
- package/docs/UI-CARDS.md +81 -0
- package/lib/index.js +24 -0
- package/lib/tools.js +11 -0
- package/package.json +133 -4
- package/src/agent/agent.mjs +3317 -0
- package/src/agent/allowed-commands.mjs +116 -0
- package/src/agent/caps.mjs +437 -0
- package/src/agent/exec.mjs +356 -0
- package/src/agent/git.mjs +616 -0
- package/src/agent/identity.mjs +253 -0
- package/src/agent/p2p-node.mjs +1243 -0
- package/src/agent/p2p.mjs +605 -0
- package/src/agent/resolve.mjs +148 -0
- package/src/agent/spool.mjs +252 -0
- package/src/agent/stun.mjs +397 -0
- package/src/agent/url.mjs +127 -0
- package/src/plugin/auto-update.mjs +574 -0
- package/src/plugin/schedule.mjs +452 -0
- package/src/plugin/shared-config.mjs +264 -0
- package/src/plugin/tools.mjs +3891 -0
- package/src/plugin/update-install.mjs +764 -0
- package/src/plugin/update-source.mjs +686 -0
- package/src/relay/metrics.mjs +284 -0
- package/src/relay/peers.mjs +180 -0
- package/src/relay/persistence.mjs +207 -0
- package/src/relay/report.mjs +591 -0
- package/src/relay/server.mjs +1187 -0
- package/src/relay/state.mjs +2004 -0
- package/src/relay/stun-server.mjs +733 -0
- package/src/signing.mjs +359 -0
- 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
|