@twinsearth/w2m-dsh-plugin 0.5.8 → 0.6.1

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 CHANGED
@@ -1,3 +1,376 @@
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.6.1] — 2026-10-11
8
+
9
+ ### Fixed
10
+
11
+ * **v0.6.0 was tagged and never published, because its release pipeline ran a pre-flight that could not
12
+ complete on the platform that builds the artifact.** Nothing was wrong with the v0.6.0 *code*: the
13
+ tarball this release publishes is the v0.6.0 tree plus the repairs below, and the section underneath
14
+ describes what is in it. What was wrong was the pipeline's order and two of its checks, all three
15
+ invisible on the developer machine that wrote them. This is a fix-forward release rather than a re-tag:
16
+ v0.6.0 stays tagged and unpublished, and its content ships here.
17
+ * **The pre-flight ran before the pack, so on a fresh checkout it could never be green.** `dist/` is
18
+ gitignored, so a clean checkout has no tarball, and three assertions in `test/claims.test.mjs` about the
19
+ *packed* tree skip when there is none -- while `scripts/preflight.mjs` fails any skip whose reason is not
20
+ on `ACCEPTED_SKIPS`. Measured by moving `dist/` aside: "10 pass, 1 fail, 1 unverifiable -- RED", naming
21
+ `no dist/ in this checkout: the packed tree was not verified here`. It looked green on a developer
22
+ machine only because `dist/` still held a tarball from an earlier pack. `ci.yml`'s `preflight` job and
23
+ `release.yml` now pack first, and `release.yml`'s checksums moved below the pre-flight so `SHA256SUMS`
24
+ describes the bytes the pre-flight left in `dist/` -- the ones unpacked, verified and published. The skip
25
+ was deliberately **not** added to `ACCEPTED_SKIPS`: that would have traded three real assertions about
26
+ the published artifact for a green light.
27
+ * **The matrix job ran `npm test` without `npm install`, and the gate inventory names a `node_modules`
28
+ path.** `test/claims.test.mjs` asserts that every artifact `GATE_INVENTORY` names exists in the tree --
29
+ the check that catches a gate deleted from the pre-flight -- and the first row of that inventory is the
30
+ eslint entry point at `node_modules/eslint/bin/eslint.js`. The matrix job never installed dev tooling, so
31
+ all four legs failed that assertion with `the inventory names files that are not in the tree: eslint entry
32
+ point (node_modules/eslint/bin/eslint.js)`. It could not reproduce locally, where `node_modules/` exists,
33
+ and it was newly exposed by v0.6.0's own wiring change: the hand-listed 22-path subset it replaced had
34
+ never run `claims`. The matrix job now installs dev tooling before `npm test`, and was measured green on
35
+ all four legs afterwards.
36
+ * **The pre-flight read the claims suite's counts without pinning the reporter, so on node 22 it read
37
+ none.** `checkClaims()` parsed the child's stdout with the spec reporter's summary in mind, while
38
+ `checkTestSuite()` has always pinned `--test-reporter=spec --test-reporter-destination=stdout`. Node's
39
+ default reporter depends on the runtime: measured, the same command prints the spec summary under node 24
40
+ and TAP's `# tests 48` under node 22 when stdout is a pipe -- which is what a CI runner is. The check
41
+ therefore reported `the runner printed no test count at all` for a suite that had passed, on the version
42
+ every workflow runs, and **every local run on node 24 was green: only the runner's own output named it.**
43
+ The reporter is now pinned exactly as the declared-suite check pins it.
44
+ * **The pre-flight now names its own failing checks in the CI interface.** Its verdict is its exit status
45
+ and its output went only to the step log, so a red pre-flight read as one line -- "Process completed with
46
+ exit code 1" -- to anyone without log access, including the API-only tools this project audits a run
47
+ with. The `FAIL` and `UNVERIF` lines it printed are now `::error::` annotations, the convention
48
+ `compat.yml`'s verdict program already used. The exit status is unchanged: the wrapper reads the
49
+ pre-flight's own status out of `PIPESTATUS` and exits with it, because a pipeline ending in `tee` would
50
+ otherwise report success for a red gate.
51
+
52
+ ### Notes
53
+
54
+ * **Two skips are accepted because a CI runner has no DSH installation, and nothing else was
55
+ pre-accepted.** `update-install: real pnpm against a throwaway profile` needs the pnpm that ships
56
+ *inside* a DSH runtime (`$W2M_PNPM`, or `$DSH_HOME/dsh-runtimes/*/dependencies/pnpm/bin/pnpm.mjs`), so on
57
+ a runner both of its assertions skip with `runtime pnpm not found`; they are accepted as
58
+ `no-runtime-pnpm`, capped at two. The entry carries no platform qualifier, because this is not a platform
59
+ fact -- Windows runners lack the runtime exactly as Linux ones do -- and the cap is the bound: a third
60
+ skip in that describe fails the check, and this reason on a host that *does* have a runtime would be a
61
+ defect in resolution rather than an environment fact. The alternative, building a synthetic runtime in CI
62
+ the way `compat.yml` builds a host for the loader gate, was rejected on purpose: it would exercise the
63
+ installer against a pnpm fetched from npm instead of the one the Desktop ships, a weaker claim about the
64
+ artifact plus a network dependency inside the release gate. The platform-conditional pair in
65
+ `test/agent.test.mjs` is qualified the other way round and **by platform**, so accepting the POSIX side
66
+ cannot excuse the same reason on Windows, where that assertion is expected to run.
67
+ * **A hypothesis that was tested and did not survive, recorded because the next reader deserves it.** The
68
+ standing explanation for the four red matrix legs was "the runner has no DSH installation, so suites that
69
+ need one behave differently there". It was tested by rebuilding the runner's conditions -- a clone with no
70
+ `node_modules/`, no `dist/`, no DSH installation reachable, no `~/.dsh`, node 22, one `npm test` -- and it
71
+ did not produce a failure: the loader gate skips (accepted), and the bundled-python assertion is guarded
72
+ by `PATH` and had already passed in v0.5.9's CI on these same runners. The real mechanism was a path in
73
+ the gate inventory on a job that never ran `npm install`. A wrong hypothesis that was tested and
74
+ discarded is worth as much to the next reader as the right one.
75
+ * **A node-22 difference this release does not fix, stated with its bounds.** With the pre-flight's shaped
76
+ `DSH_HOME`, `test/agent.test.mjs` is 147 tests / **0 fail under node 24** and **13 fail under node 22**
77
+ (`'refused' !== 'ok'`, around `test/agent.test.mjs:3870` and `:3915`). `engines` declares `>=20.19.0`, so
78
+ node 22 is a supported runtime and this is a real difference rather than a curiosity. Two facts bound it:
79
+ it does not fire in CI today, because a runner cannot shape a home -- `prepareSuiteEnv()` finds no runtime
80
+ source there and the suite runs bare, which is why no agent failure appears on ubuntu -- and it will
81
+ surface the moment the pre-flight job is given a runtime layout. It is a behaviour question rather than
82
+ wiring, so it is recorded here and left to a separate change.
83
+ * `PROTOCOL.md` §9's correction note said there is no such release as v0.6.0; that was true when it was
84
+ written and the tag made it false. It now still says what it was written to say -- the P2P data-plane
85
+ crypto shipped in **v0.5.3**, not v0.6.0 -- without contradicting the tag.
86
+
87
+ ## [0.6.0] — 2026-10-11
88
+
89
+ ### Added
90
+
91
+ * **The pre-flight became a ledger, with a self-test of its own.** `scripts/preflight.mjs` grew from 886
92
+ lines to 2,077 and the claims suite it reuses from 775 to 1,581. Every gate it delegates to is now an
93
+ **artifact bound to the registered check that measures it, in both directions**: a gate with no check is
94
+ a finding, a check with no gate is a finding, and a registered check that produces no result at all is
95
+ a `FAIL` rather than a silence. The ledger that enforces that is itself exercised on every run — a
96
+ silent check, a throwing check and an honest one must classify `FAIL`/`FAIL`/`PASS`, and an
97
+ `UNVERIFIABLE` whose reason is not on the accepted list is refused rather than reported. Measured on
98
+ this tree: 12 artifacts inventoried, 11 registered checks, 0 unbound.
99
+ * **The packed tarball is checked against an expectation derived from the tree, not a hand-written list.**
100
+ Every document in the tree is either shipped or named in `DOCUMENT_EXCLUSIONS` **with a reason** (18 in
101
+ the tree, 17 shipped, 1 excluded, 0 neither), the packed `package.json` is compared with the tree's
102
+ **field for field**, and every manifest-declared path must ship. Both directions are asserted, so "the
103
+ packer stopped shipping a document" and "a document was added and the packer was not told" fail the
104
+ same check instead of one of them passing quietly.
105
+ * **Workflow wiring is a check rather than a convention.** `ci.yml` and `release.yml` must each run every
106
+ suite `package.json#scripts.test` names and must not hand-list a suite set, and the pre-flight asserts
107
+ that the loader gate keeps its triggers on the two occasions that matter — every pull request and every
108
+ release. That gate was the one a hand-edited suite list quietly dropped, which is how six suites that
109
+ exist and pass came to run in no workflow at all.
110
+ * **Accepted reasons are named per claim, and every skip is printed with its reason.** `UNVERIFIABLE` is
111
+ not a pass: each accepted entry carries the exact text it accepts and the one claim it may be used for.
112
+ On this host the declared suite produces 4 skips, each on the accepted list, each printed in the
113
+ pre-flight's own output rather than summarised as a count.
114
+ * **The compatibility gate classifies every result line, and reads the assertions out of the test file.**
115
+ `.github/workflows/compat.yml`'s verdict program used to check nine hand-listed titles plus the page
116
+ half, so a tenth assertion that skipped could leave the job green; and it decided the accepted reason
117
+ from the **runner** (`BROWSER === ''`), so a page assertion skipped for a reason that is a property of
118
+ our own composition printed ``Accepted reason `no-browser` `` and exited 0. The accepted list is now
119
+ matched against the **skip directive's own text**, only the page half may use it, the assertions the
120
+ file declares are read from the file, and an assertion that produced no result line at all is a `FAIL`.
121
+ * **The operator token is no longer printed on every start.** The relay prints it in its banner **only on
122
+ the start that generated it**; every other start prints `OPERATOR TOKEN — not printed (this start did
123
+ not generate it)` with the path and the mode to read it from. That banner lands in journald, a
124
+ container log or a CI transcript, and this token authorises dispatching to the whole fleet, so a
125
+ restored token in a log is a credential in a log. `--print-operator-token`
126
+ (`W2M_PRINT_OPERATOR_TOKEN=1`, or `printOperatorToken: true` for an embedder) asks for the old
127
+ behaviour explicitly, and `docs/DEPLOY.md` states that on the generating start the value still appears
128
+ **twice on purpose** — once on stderr as the log copy, once in the CLI's own box on stdout as the
129
+ handoff a human reads.
130
+
131
+ ### Fixed
132
+
133
+ * **A punch could be refused by a datagram that was not a refusal at all — about one handshake in 256.**
134
+ `decodeRefuse` accepted exactly one body length and validated nothing else, and a `REFUSE` rides inside
135
+ the same `HELLO_ACK` frame as a handshake answer. A genuine `HELLO_ACK`'s byte 6 is the first byte of
136
+ the responder's random ephemeral, so when that byte happened to equal the length the parser expected,
137
+ an honest encrypted answer was read as a refusal: reported with a refusal reason the peer never sent,
138
+ and — in the codes that are terminal — handing back no channel at all. It was found while adding the
139
+ `k_ref` seal to `REFUSE`. The parse now checks the version byte, both legal lengths and that the
140
+ machine id carries no control bytes, and a test bends a real acknowledgement's length byte into that
141
+ exact collision.
142
+ * **`REFUSE` is sealed, and a refusal that cannot be corroborated is dropped.** A `REFUSE` may be
143
+ unauthenticated by design — it is sent precisely when the two ends could not agree a key — which made
144
+ it a downgrade oracle: anything able to forge one could turn "the peer never answered" into "the peer
145
+ refused". `k_ref = HKDF(ECDH(e_i, s_r), REFUSE_MAC_LABEL)` is a key the initiator derives as easily as
146
+ the responder, over a label distinct from every handshake MAC and over a **different input**
147
+ (`ECDH(e_i, s_r)`, not `ECDH(s_i, s_r)`); a refusal must now come from the address and the session the
148
+ punch agreed and carry a `k_ref` MAC. Four codes name the **ignored datagram** rather than a failed
149
+ punch (`P2P_CRYPTO_REFUSE_SESSION_MISMATCH`, `..._UNPUNCHED_SOURCE`, `..._BAD_MAC`,
150
+ `..._UNCORROBORATED`), so a refusal that was thrown away is never reported as a refusal that happened.
151
+ * **A started node held the process open, and the comment claimed an `unref()` that existed nowhere.**
152
+ With no `ctx.effect` — an older Cordis, or a bare test context — the plugin composed a node and never
153
+ released the handles that keep a Node.js process alive: the bound UDP socket, the announce refresh
154
+ timer, and the accept loop's pending wait timeout. `src/agent/p2p-node.mjs` now has a real `unref()`,
155
+ **remembered rather than applied once** (a `close()` followed by a `start()` binds a second socket and
156
+ arms a second wait, so a call that took effect only on the first would not be a promise), the accept
157
+ loop reads the flag on every round, and the plugin calls it when no disposer will ever be called.
158
+ Found by an independent verifier against the `w2m_status` test's bare context.
159
+ * **A failed state publish left its temp file behind: one live state directory had accumulated 266
160
+ `agent-state.json.tmp-*` files.** `publishState()` renames a temp file over `agent-state.json` while
161
+ another process may be reading it, and on Windows that contention comes back as `EPERM`/`EACCES` from
162
+ the rename although both paths are fine and the condition clears within milliseconds. The rename is now
163
+ retried (5 attempts, 3 ms × attempt, **retryable errors only** — a permanent failure is rethrown
164
+ exactly as a bare `renameSync` would be), and a publish that fails outright unlinks its own temp file.
165
+ * **`test:agent` masked a hang for more than 283 seconds.** The suite had finished asserting and could
166
+ not exit; without `--test-force-exit` the verdict was a timeout rather than a result, and the same was
167
+ true of `test:relay` and `test:tools`. All three carry the flag now, as `test`, `test:e2e` and
168
+ `test:all` already did.
169
+ * **`test/p2p-crypto.test.mjs` was still not in `test:all`.** 33 tests covering the data-plane
170
+ encryption rode `npm test` and not the suite a release runs, so the crypto suite did not ride the full
171
+ gate. The one path the v0.5.9 entry asked its owner for is appended after
172
+ `test/mdns-discover.test.mjs`.
173
+ * **The allow-list is one rule at both ends, and it is a grant rather than a sandbox.** The plugin's
174
+ pre-flight reduced `argv[0]` to a basename before matching, so it would wave `E:\Temp\evil\node.exe`
175
+ through while the agent refused it; the argv is handed over verbatim now, to the same matcher the agent
176
+ uses. Three narrower rules sit on top of the prefix match, each with its own code because each has its
177
+ own remedy: `COMMAND_REPARSING_ARGUMENT` refuses code-loading **trailing** arguments
178
+ (`--test-reporter`, `--import`, `--require`, `-r`, `--experimental-loader`, `-e`/`--eval`,
179
+ `-p`/`--print`, `python -c`/`-m`, `bash -c`, `git -c`/`--exec-path`, `make -f`, `tar --to-command`, and
180
+ their equivalents in the interpreters in common use) unless the entry names them;
181
+ `COMMAND_EXECUTABLE_PATH_MISMATCH` admits an absolute `argv[0]` only when it resolves to the same file
182
+ the entry's executable does; and `COMMAND_EXECUTABLE_RELATIVE_PATH` refuses a relative `argv[0]`
183
+ outright, because the working directory comes from the offer. **The limit is stated rather than
184
+ implied: an entry is a code-execution grant, not a sandbox.** `node --test` allows
185
+ `node --test <any file>`, and the test runner loads and runs whatever file it is handed. The list says
186
+ which program may run, not what that program can be told to do.
187
+ * **The Windows supervisor: one supervisor, one agent, and the punch port held by the agent that is
188
+ actually running.** Measured on this machine: **fourteen `w2m` processes, nine of them `w2m-localside`
189
+ agents sharing one identity, with port 41235 held by an agent from thirteen hours earlier** — so every
190
+ dispatch fell back to the relay (`P2P_UNREACHABLE`) while the heartbeat said nothing. A second
191
+ generation could start while a healthy one ran, and the start-time reap could only ever see the single
192
+ pid the heartbeat remembered. There is now a **single-instance lock** (`open(…, 'wx')`, i.e.
193
+ `O_CREAT|O_EXCL`, so of two generations racing exactly one creates the file): a generation that finds
194
+ it held logs a no-op line and exits without spawning, and a lock whose holder is gone or whose file is
195
+ unreadable is **taken over loudly** rather than blocking the machine forever. The reap matches **by
196
+ identity** — process table plus command line naming the same state directory, with the supervisor
197
+ excluded — because a supervisor's own command line carries the agent stub's path and an identity-only
198
+ reap killed its own program (measured: it did). `orphans_reaped`, `orphans_reaped_pids` and
199
+ `lock.takeovers_total` are in the heartbeat, so the machine's own account of what it reaped is readable
200
+ without a debugger.
201
+ * **The scheduled task was registered `MultipleInstances IgnoreNew`, which cannot recover from a
202
+ supervisor death.** IgnoreNew refuses the second start — the right-looking answer and the wrong one,
203
+ because the watchdog tick that should have replaced a dead generation was itself the start being
204
+ ignored, and the machine stayed down until a human noticed. `install-service.ps1` now registers
205
+ **`MultipleInstances Parallel`**, which is safe precisely because the supervisor's own lock is what
206
+ prevents a second *agent*, and it prints the setting it wrote so the correction is visible in the
207
+ install transcript rather than inferred. `deploy/windows/verify-supervisor.mjs` is the reproducible,
208
+ no-elevation check of the whole property: it builds the broken state on purpose — orphan agents already
209
+ holding a free port, a lock left behind by a killed supervisor — and then asserts that exactly one agent
210
+ is left, that it owns the port, and that the heartbeat names the pids it reaped.
211
+ * **Documentation corrections, each of which was a false statement in a shipped document.**
212
+ * **`SECURITY.md` had been telling users to install a six-release-old build.** Its supported-version
213
+ table read "v0.5.3 and everything before it" and listed v0.5.6-and-later as "not yet released" —
214
+ three releases after v0.5.6 had shipped. It names **v0.5.9** as the supported line now and says why
215
+ v0.5.4 and v0.5.5 are withdrawn (they fail during DSH's web boot and take the whole application with
216
+ them, all eight tools included), and the install commands in `SECURITY.md` and `TROUBLESHOOTING.md`
217
+ point at a release that exists.
218
+ * **`TROUBLESHOOTING.md` named the wrong cause for a vanished `cordis.patch.yml`.** `dsh plugin add`
219
+ does **not** rewrite it — the CLI shares the plugin manager's package operations, which write
220
+ `package.json` and `pnpm-lock.yaml`. What removes the file is Desktop's fatal-recovery
221
+ `sanitizeProfile`, which renames it to `cordis.patch.yml.bak-<unix-ms>`; the `.bak-<timestamp>`
222
+ sibling is the receipt to look for, and the backup habit matters more before an upgrade, not less.
223
+ The same section now shows the `insert:` form and states what a bare `- id:` actually does — replace
224
+ fields on a row that already exists, so against a row nothing created it is a silent no-op and the
225
+ loader says `patch: entry "w2m" not found` while the install reports success.
226
+ * **`docs/P2P-CRYPTO.md` dated the data plane's encryption to a release that does not exist.** Stage 1
227
+ shipped in **v0.5.3**, and the file said v0.6.0. This is the first v0.6.0, and the reference now says
228
+ v0.5.3.
229
+ * **The `p2pOfferGraceMs` line in the v0.4.0 entry said 1200 ms and the shipped constant is 3000 ms.**
230
+ The widening rode *inside* v0.4.0 (commit `eea80c7`, the commit the tag points at) without its own
231
+ CHANGELOG line, which `CONTRIBUTING.md` §5 requires for a behaviour change. The line matches the
232
+ shipped constant now and the omission is recorded rather than left to be rediscovered from a diff;
233
+ `README.md` and `PROTOCOL-v0.4.0.md` carry the same correction.
234
+ * **`docs/PIPELINE-DESIGN.md` opened with "unresolved, and `pipeline` is deliberately not
235
+ implemented"**, which had been false since v0.3.4, and it recommended Option A while **Option C** is
236
+ what shipped. The analysis is kept as written and the divergence is stated: editing the argument to
237
+ match its conclusion would destroy the only thing that file is good for.
238
+ * **`deploy/networking/LESSONS.md` stated the tunnel invariant in a form that contradicts the
239
+ ledger.** It said "tunnelled traffic must never be reported as `p2p`"; `transport` is the **socket
240
+ path**, rung 4 *is* a socket between the two machines, and calling that a lie would make the field
241
+ useless. The rule that actually holds is that the **tunnel must be named explicitly** wherever it
242
+ carried the traffic, and that "direct" must never be read as "the bytes avoided a server".
243
+ `deploy/networking/README.md` states it in the same words, and the two files now agree with each
244
+ other and with `docs/AUDIT.md`.
245
+
246
+ ### Notes
247
+
248
+ * **What is new behaviour here, and what is a document catching up to behaviour that already shipped.**
249
+ The REFUSE parse and its `k_ref` seal, the node `unref()`, the state-publish retry and cleanup, the
250
+ shared allow-list matcher and its three narrower rules, the operator-token printing gate, the
251
+ supervisor's lock / reap-by-identity / `MultipleInstances` correction, the pre-flight's ledger, the
252
+ compat gate's structural classification, and the `test:all` + `--test-force-exit` wiring are **code
253
+ changes in this release**. The client-entry requirement (`id: w2m`, the **bare** package name, and the
254
+ `insert:` form) and the loader driver's **registry-wide failure sweep** shipped in v0.5.4–v0.5.6; what
255
+ is new here is that both are documented as requirements a reader can act on, and that the compatibility
256
+ gate requires the sweep's own result line (`sweeps every fiber in the process and finds no failure`) by
257
+ name instead of trusting nine hand-listed titles.
258
+ * **LAN discovery remains opt-in, and it is unverified on a real network.** Every socket in
259
+ `test/mdns-wire.test.mjs` and `test/mdns-discover.test.mjs` is loopback, and no mDNS packet has crossed
260
+ a real LAN in this project's testing; `README.md` names both suites in its table now rather than
261
+ leaving them to be found. Discovery is a **hint, never an authorisation**: a machine the LAN announced
262
+ but the relay does not list is not a target.
263
+ * **One check is `UNVERIFIABLE` on this machine and is printed as such rather than passed quietly.** This
264
+ host's `%DSH_HOME%` holds no `dsh-runtimes\`, while `src/agent/resolve.mjs` looks for the bundled
265
+ runtime at `dsh-runtimes/dsh-primary-runtime/dependencies` and the application bundle carries it at
266
+ `E:\DS\resources\runtime\primary-runtime\dependencies`. The suite is run against a throwaway home with
267
+ the documented layout, and the host mismatch is printed next to the verdict.
268
+ * **Unverified from this machine, stated rather than implied.** The GitHub Release assets behind the
269
+ documented tarball URLs (`test/claims.test.mjs` skips that claim without `W2M_NETWORK_TESTS=1`), and
270
+ **the first CI run of the compatibility gate in its new form**: its verdict program is extracted out of
271
+ the workflow and exercised by `test/claims.test.mjs` against synthetic TAP captures — an honest
272
+ no-browser skip, an injected extra assertion, an unaccepted reason — but the gate itself is judged by a
273
+ runner that is not this machine.
274
+ * The numbers in `README.md`'s suite table are the **v0.5.9** measurements and stay labelled that way.
275
+ Adding `test/p2p-crypto.test.mjs` to `test:all` changes that row's suite and test counts, and neither
276
+ was re-measured for v0.6.0 on this host — a full `test:all` on this machine takes long enough that
277
+ running it to refresh a table would be the wrong trade.
278
+
279
+ ## [0.5.9] — 2026-10-11
280
+
281
+ ### Added
282
+
283
+ * **A release pre-flight, as one entry point: `node scripts/preflight.mjs`.** A release is now
284
+ something you start by running it, and a green run is the precondition for the version bump. It
285
+ checks, in order: that every gate it delegates to exists on disk; that lint is at 0 errors; that the
286
+ mojibake scan is clean over the whole tree; that the suite declared in `package.json#scripts.test`
287
+ runs and passes; that the **packed tarball** — not the tree — contains every path `package.json#files`
288
+ names, names the tree's version, carries no forbidden path (`node_modules`, `test/`, `scripts/`,
289
+ `.git`, `*.proof-backup`/`*.orig`/`*.bak`) and registers its eight tools when executed from the
290
+ extracted copy; that the version, bundle-patch and document claims in `test/claims.test.mjs` still
291
+ hold; and that the CHANGELOG's title sits above its newest entry and its entries run newest-first.
292
+ Every check reports `PASS`, `FAIL` or `UNVERIFIABLE` **with a name and a reason**, and the run fails
293
+ if any registered check produces no result at all: "the gate ran" is itself a check, and the ledger
294
+ that enforces it is itself exercised on every run against a silent check, a throwing check and an
295
+ honest one — because a self-authored gate that reports success while executing zero checks is a
296
+ defect this project studied in another one. The first draft of that ledger counted a coerced `FAIL`
297
+ as a produced result, and the self-test caught it on its first run.
298
+ * **A wiring check that would have caught two of the defects below.** Every `test/*.test.mjs` on disk
299
+ must be named by some npm script, and every path an npm script names must exist. Measured now: 36
300
+ suites on disk, 36 named by a script, 0 orphans, 0 named-but-absent.
301
+ * **A CI compatibility gate** (`.github/workflows/compat.yml`, with its own section in
302
+ `CONTRIBUTING.md`). Three jobs. *mount-and-boot* builds a pinned host in `RUNNER_TEMP`
303
+ (`@deepseek-ai/dsh@0.2.0-rc.2`, `dsh-app-boot`, `dsh-client-modules`, `cordis@4.0.4`,
304
+ `cordis-plugin-loader@1.0.5`), runs the existing `test/loader-entry.test.mjs` against it **unmodified**
305
+ and classifies the TAP capture with nine named assertions, of which a `SKIP` counts as a failure.
306
+ *bare-import* packs, installs into an empty project, and asserts `dependencies === {}`, that no host
307
+ package resolves, and that the root export is a callable `apply`. *uninstall* asserts the composition
308
+ is byte-identical before and after, and documents why the manifest key set is not compared. Triggers:
309
+ PRs to main, pushes to main, `v*` tags, weekly, and dispatch; `contents: read`, no secrets. No-browser
310
+ is the single entry on `ACCEPTED_UNVERIFIABLE` and reports `UNVERIFIABLE` with its reason rather than
311
+ `PASS`. Validated end to end against a synthetic installation built with the workflow's own commands
312
+ (10/10 pass) and in the failing direction with `W2M_ROUTE_DEFECT=1`, which reports `FAIL` naming the
313
+ route assertion and the fiber sweep — the same both-directions rule the rest of this release follows.
314
+ * **Refusal codes, and the exposure boundary written down as executable text.** 26 codes in three
315
+ greppable tables (`REFUSAL_CODE` in `src/plugin/tools.mjs`, `MATCH_REFUSAL` in
316
+ `src/agent/allowed-commands.mjs`, `EXEC_REFUSAL` in `src/agent/exec.mjs`), with
317
+ `MATCH_REFUSAL.NOT_ALLOWED === REFUSAL.COMMAND_NOT_ALLOWED` asserted so the two ends of a dispatch
318
+ cannot drift apart. The defect this closes is that `applied: false` conflated three different facts:
319
+ a stored selection naming no machine was indistinguishable from "the whole fleet ran it", and that
320
+ state now has its own code (`SELECTION_UNUSABLE`). Every refusal carries `reason_code` and a `detail`
321
+ saying what would change it. The boundary itself — no tool parameter may name a credential, an
322
+ allow-list, a key path or a state directory — is stated in `src/plugin/tools.mjs`'s header and pinned
323
+ by four canaries run across all eight tools, proving that no result and no request body carries a
324
+ token. Proven by 17 mutations, 17 red, in a scratch copy outside the repository.
325
+
326
+ ### Fixed
327
+
328
+ * **`CHANGELOG.md` had carried a mangled character into the published package.** v0.5.8's own entry —
329
+ the one describing the mojibake repair — contained the two garbled CJK sequences it was complaining
330
+ about, reproduced literally inside backticks. `CHANGELOG.md` is in `package.json#files`, so v0.5.8
331
+ shipped the damage it was apologising for, and `scripts/scan-mojibake.mjs` was red on the tree. The
332
+ line is ASCII now and the scan is clean over the whole tree.
333
+ * **The CHANGELOG's title and introduction were in the middle of the file.** The v0.5.8 entry was
334
+ prepended above `# Changelog` itself, which is the same mistake as the out-of-order entries v0.5.8
335
+ fixed, one level up: "newest first" was true of the version headings while the document's own header
336
+ had been pushed to line 21. The entry is below the title again, and the pre-flight now asserts the
337
+ shape rather than only the order.
338
+ * **The entire declared unit suite had not been running.** Wiring the new suites into
339
+ `package.json#scripts.test` and `#scripts.test:all` dropped one space, producing the path
340
+ `test/claims.test.mjstest/fleet-target.test.mjs`. `node --test` exits 1 on a missing path, so
341
+ `npm test` was failing immediately and *no* unit suite — not just `fleet-target` — was running. This
342
+ one is mine: it came from the edit that wired the new suites in, and it means the declared unit suite
343
+ was not being run at all between that edit and this release.
344
+ * **`test/p2p-crypto.test.mjs` was named by no script at all.** 33 tests, green, and wired into
345
+ nothing — the exact defect v0.5.7's release note identified in someone else's repository. A suite
346
+ that never runs protects nothing, and that is now asserted rather than assumed. **It is in `test`,
347
+ and as of this entry it is still *not* in `test:all`** — that is a wiring gap, not a rounding
348
+ detail, because `test:all` is the suite a release runs: 35 paths are named there and
349
+ `test/p2p-crypto.test.mjs` is not one of them, so the crypto suite still does not ride the full
350
+ gate. This entry previously claimed both; the claim is now the measured one. The fix is one path in
351
+ `package.json#scripts["test:all"]`, appended after `test/mdns-discover.test.mjs` — the file is owned
352
+ by the integrator, so the line is reported rather than edited here.
353
+
354
+ ### Notes
355
+
356
+ * This release exists because three releases in one hour — v0.5.6, v0.5.7, v0.5.8 — each fixed a
357
+ defect the previous one introduced. **Every one of those defects was found after the tag was pushed**,
358
+ by a gate that already existed and simply was not run before the release. The gates were never
359
+ broken; the process around them was missing. That process is this release's subject.
360
+ * One check is `UNVERIFIABLE` on this machine and is reported as such rather than passed quietly: the
361
+ host has no `%DSH_HOME%\dsh-runtimes\`, while `src/agent/resolve.mjs` looks for the bundled runtime at
362
+ `dsh-runtimes/dsh-primary-runtime/dependencies` and the application bundle carries it at
363
+ `E:\DS\resources\runtime\primary-runtime\dependencies` instead. The suite's bundled-python claim is
364
+ measured against a throwaway home with the documented layout, and the host mismatch is printed next
365
+ to the verdict, as an open question rather than a silent pass.
366
+ * Two checks are known to be sensitive to a loaded machine rather than to the artifact, and both are
367
+ named here rather than discovered later: `test/relay.test.mjs`'s `runs a complete signed task end to
368
+ end`, whose `lease_until` is computed by the relay during the request and recomputed by the test
369
+ after the await, so the two can land in different seconds; and the capability probe in
370
+ `test/agent.test.mjs`, which measures the host's own runtime layout as well as the resolver. A red
371
+ run naming only these is a re-run, not a release defect — and a report that hides them behind a green
372
+ verdict is the kind of green this release exists to avoid.
373
+
1
374
  ## [0.5.8] — 2026-10-11
2
375
 
3
376
  ### Fixed
@@ -8,7 +381,8 @@
8
381
  newest-first again, and the anchor is the top of the file rather than a version I have to remember.
9
382
  * **Mojibake in shipped code.** `src/agent/mdns-wire.mjs` is packed, and its comments carried an em dash
10
383
  and a section sign that had been decoded as the wrong encoding and re-saved, so a reader would have
11
- seen `鈥` and `搂` where the RFC references belong. The same damage was in `test/mdns-discover.test.mjs`.
384
+ seen the two garbled CJK sequences where the RFC references belong. The same damage was in
385
+ `test/mdns-discover.test.mjs`.
12
386
  All five mDNS files are ASCII now, and the repository's own mojibake scan is clean.
13
387
 
14
388
  ### Notes
@@ -18,11 +392,6 @@
18
392
  the gates working, but it is also a release process with no pre-flight of its own — which is the next
19
393
  thing this project should fix rather than the next thing it should be proud of.
20
394
  * No runtime change: the code in v0.5.8 is the code in v0.5.6.
21
- # Changelog
22
-
23
- All notable changes to this project are documented here. The format follows
24
- [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
25
- adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
26
395
 
27
396
  ## [0.5.7] — 2026-10-11
28
397
 
@@ -697,12 +1066,20 @@ written down as not measured.
697
1066
  * **The direct path is the default connection method between machines.**
698
1067
  `p2pMode: auto` is now real behaviour: the dispatching machine announces,
699
1068
  punches to each target, and pushes the offer over the punched channel. The relay
700
- **holds its own copy of that offer back for `p2pOfferGraceMs` (1200 ms)** and
1069
+ **holds its own copy of that offer back for `p2pOfferGraceMs` (3000 ms)** and
701
1070
  offers it anyway if the punch does not land — without that hold the direct path
702
1071
  could never win, because an SSE push over an already-open connection beats a
703
1072
  punch that costs a round trip. Measured on loopback while writing it: the relay
704
1073
  copy won every time. The hold is cancelled by the executor's own lease
705
1074
  heartbeat, so no new message was needed to say "the direct copy arrived".
1075
+ The first value was **1200 ms and it was too tight** — measured on a 1-vCPU
1076
+ shared host, two agent tests saw the relay's copy overtake the direct one, which
1077
+ under `p2pMode: direct` refuses an offer that was about to arrive. This entry said
1078
+ 1200 ms for the whole of v0.4.0; the widening to 3000 ms rode **inside** v0.4.0
1079
+ (commit `eea80c7`, the commit the tag points at) **without its own CHANGELOG
1080
+ line**, which `CONTRIBUTING.md` §5 requires for a behaviour change. The line now
1081
+ matches the shipped constant, and the omission is recorded here rather than left
1082
+ for a reader to rediscover from a diff.
706
1083
  * **The result notice travels back over the same channel, and the payload does
707
1084
  not.** `{type:'task.result', task_id, machine_id}` is acknowledged with
708
1085
  `result.ack`; the envelope is not on that frame, because `result_path` is one of
package/CONTRIBUTING.md CHANGED
@@ -49,6 +49,16 @@ throwaway profile under a throwaway `DSH_HOME` and boots it with the real `runPr
49
49
 
50
50
  Each line is checkable against a diff.
51
51
 
52
+ 0. **A release starts with `node scripts/preflight.mjs`, and a green run is the precondition for a
53
+ version bump.** `scripts/preflight.mjs` is the only sanctioned way to start a release: it runs the
54
+ lint, the mojibake scan, the suite declared in `package.json#scripts.test`, the pack plus the
55
+ assertions against the **packed tarball**, and the claim checks in `test/claims.test.mjs`, in that
56
+ order, and prints `PASS`/`FAIL`/`UNVERIFIABLE` with a reason for each. It exits non-zero on any
57
+ failure, and also when a check produces no result at all — "the gate ran" is itself checked. Do not
58
+ bump a version, tag, or publish on a red or unread run; `UNVERIFIABLE` is not a pass either, so read
59
+ the reason it prints. The three releases of 2026-10-11 (v0.5.6, v0.5.7, v0.5.8) each fixed a defect
60
+ the previous one introduced, every one of them found *after* the tag was pushed by a gate nobody had
61
+ run. This line exists so that does not happen again.
52
62
  1. **Zero runtime dependencies.** `dependencies === {}` in `package.json`. Node built-ins only.
53
63
  2. **Argv execution, never a shell string.** A command is an array of arguments passed to
54
64
  `spawn`/`execFile` with no shell. No `exec`, no `shell: true`, no string interpolation into a
@@ -121,3 +131,7 @@ Therefore:
121
131
 
122
132
  Do not open a public issue for a vulnerability. See [SECURITY.md](SECURITY.md) for the private
123
133
  channel and what to include.
134
+
135
+ ## The compatibility gate
136
+
137
+ `.github/workflows/compat.yml` is the CI half of rule 7, on every pull request, every push to `main`, every `v*` tag and a weekly schedule: it packs the tree, installs the tarball into a scratch profile with the host's own `dsh plugin add`, and asserts the deterministic artifact — the plugin's row composed by the real Loader, all eight `w2m_*` tools registered, no failed fiber anywhere in the composed tree, the client half activated through the real module system — plus a bare import with no host present and an install/uninstall that returns a profile to its previous composition; **a red compat run blocks a release** exactly as a red preflight does, and its `UNVERIFIABLE` outcomes are never a pass — read the reason it prints.
@@ -100,11 +100,18 @@ essentially always, and `transport` reads `relay` on networks where the punch su
100
100
  was measured on loopback while writing this contract: the direct copy consistently lost.
101
101
 
102
102
  So when — and only when — the task carries both `origin_machine_id` and a `p2p.mode` of `auto`
103
- or `direct`, the relay defers its own offer by `p2pOfferGraceMs` (default **1200 ms**) and
103
+ or `direct`, the relay defers its own offer by `p2pOfferGraceMs` (default **3000 ms**) and
104
104
  cancels it if the lease has moved out of `queued` by then. No new message is needed for that
105
105
  test: the executor's first act on any offer it accepts is a lease heartbeat, which is exactly
106
106
  the fact that says "the direct copy arrived".
107
107
 
108
+ > **This contract first said 1200 ms, and 1200 ms was too tight.** Measured on a 1-vCPU shared
109
+ > host, two agent tests saw the relay's copy overtake the direct one, which under `p2pMode: direct`
110
+ > means refusing an offer that was about to arrive. The constant was widened to 3000 ms inside
111
+ > v0.4.0 (`DEFAULT_P2P_OFFER_GRACE_MS` in `src/relay/state.mjs`), which is why this file — written
112
+ > before that commit — is corrected here rather than frozen wrong. The cost stays bounded: one
113
+ > delayed copy per task, once.
114
+
108
115
  Consequences, stated because they are the cost of the feature:
109
116
 
110
117
  * a punch that fails costs one grace period of extra latency per task, and nothing else;
@@ -192,8 +199,9 @@ Every result carries the path it actually took:
192
199
  * **Neither field is comparable.** A path is not a result: two machines that ran the same
193
200
  bytes and disagree only about how the offer reached them are `consistent`, and a test says
194
201
  so explicitly. Adding a transport field to `COMPARABLE_FIELDS` would turn a network
195
- difference into a false bug report, which is the exact failure the six aggregation states
196
- exist to prevent.
202
+ difference into a false bug report, which is the exact failure the **nine** aggregation states
203
+ exist to prevent. (This line said "six" through v0.5.9; the verdict list has been nine since
204
+ v0.3.0 — see `AGGREGATE_STATUSES` in `src/relay/report.mjs`.)
197
205
 
198
206
  ## 7. The relay, extended
199
207