@twinsearth/w2m-dsh-plugin 0.5.9 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,369 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.6.2] — 2026-10-13
8
+
9
+ The release that stops a broken *relay* from being reported as an out-of-date *peer*, and stops a
10
+ relay that has quietly stopped recording from looking healthy.
11
+
12
+ The incident, measured on this fleet: every machine at v0.6.1 held a valid device key and announced
13
+ it, `peers_known` stayed `0` on all of them, no handshake was ever armed, and every direct channel
14
+ came back `P2P_CRYPTO_PLAINTEXT_PEER` — documented as *"the peer never learned a key (an older
15
+ build)"*. Both ends were the same build, on the same host. The component that was actually behind was
16
+ the relay: it started `2026-10-10T18:01:40Z`, and v0.6.0 and v0.6.1 shipped on `2026-10-11`. It had
17
+ never heard of `static_key`, so it accepted every announcement, dropped the field, and served entries
18
+ without it. The reason code blamed the one component that was up to date.
19
+
20
+ ### Added
21
+
22
+ * **`encryption.relay_serves_keys`** — whether the relay keeps the key this machine announces. `null`
23
+ until an announcement proves it either way; `false` when the relay dropped what was sent; `null` for
24
+ a node with no key of its own, because such a node never asked and must not conclude "no". Detected
25
+ from this node's **own** announcement — it POSTs a key and the relay echoes the entry it stored — so
26
+ it needs no relay cooperation, no version negotiation and no capability call. That is what makes it
27
+ work against the relay that is actually deployed, which is the whole point.
28
+ * **`P2P_CRYPTO_RELAY_NO_KEYS`** — a new reason, used when a peer announced, this machine holds a key
29
+ of its own, no key came back for that peer, and this node has already proved the relay drops keys.
30
+ * **`/healthz` reports durability**: top-level `degraded` and `degraded_reason`, plus `degraded`,
31
+ `degraded_since`, `last_append_error` and `append_failures` inside `persistence`. `ok` deliberately
32
+ stays `true`: the relay still serves the fleet, and flipping it would turn a recording problem into
33
+ an outage. Persistence disabled is not degraded — there is nothing that can fail.
34
+
35
+ ### Fixed
36
+
37
+ * **The reason a direct channel is plaintext now names the real cause.** `punch()` computed it as
38
+ `request === null ? PLAINTEXT_PEER : NO_KEY`, and `dial()` sends no crypto request in three
39
+ situations that are all *local* — this machine has no device key, it holds no key for that peer, or
40
+ it is dialling itself. Every one of them was reported as the peer being old. The code already
41
+ disagreed with itself about this: `dial()`'s own comment claims the reason "then says
42
+ `P2P_CRYPTO_NO_KEY`". The reason is now decided where it is known and travels with the call;
43
+ `PLAINTEXT_PEER` keeps its real meaning and keeps firing for the one case that is genuinely about
44
+ the peer — it publishes no key, so there is nothing to encrypt with.
45
+ * **`dial()` learns the peer key it was throwing away.** The peer's `static_key` has ridden in the
46
+ relay's `/v1/peer/{id}` record since v0.6.0, and `dial()` was fetching that very record for the
47
+ candidate list and discarding the key. A dispatcher that reported `PLAINTEXT_PEER` against a
48
+ same-version peer could have encrypted; it had an empty store and never asked. Reporting alone would
49
+ have been a better-worded lie.
50
+ * **`device.key` is loaded from beside `device.json`, not from `--state`.** The plugin followed the
51
+ documented rule; the agent did not, on the stated grounds that "`identity.mjs` puts `device.json`
52
+ when the agent is run with `--state`" — which is false. `device.json` is fixed at
53
+ `<DSH_HOME>/xclient/device.json` by the identity contract and `--state` does not move it. Measured
54
+ consequence on one machine running both halves: two different keypairs under one `machine_id`, both
55
+ announcing to the one `/v1/peer/{machine_id}` slot, the relay's record alternating between the two
56
+ nodes' ports every ~8s, each announcement overwriting the other. A dispatcher reading that record
57
+ learned one node's key or the other's, never reliably the key of the node it was about to dial. The
58
+ fix takes the path the caller actually resolved — `bin/w2m-localside.mjs` already had it in
59
+ `identityPath` and simply did not pass it down — and deliberately does **not** re-resolve `DSH_HOME`
60
+ inside the agent, which would make a test or an embedder read and *create* a key in the real user's
61
+ home.
62
+ * **A key already generated in `--state` is adopted, not abandoned.** `DeviceKey.load` warns that
63
+ "silently generating a replacement key would make every peer that knows the old one refuse this
64
+ machine", so the canonical location may create and a legacy file may only be adopted. An adopted key
65
+ is logged, because the public key is what a peer authenticates this machine by.
66
+ * **A relay that serves without recording now says so.** On a full disk the relay answered every
67
+ request normally and appended nothing; the only trace was one warning per failed append, and this
68
+ fleet's relay had reached **22 byte-identical `ENOSPC` entries**, so the signal was buried in its own
69
+ repetition. Identical warnings are now counted rather than repeated, and durability is a first-class
70
+ state: entered by the first failed append, cleared by the next success. `append_failures` is
71
+ cumulative and deliberately never reset — "how much was lost" is the first question after a disk
72
+ fills, and a counter that returns to zero cannot answer it.
73
+ * **A patch override that asserts the wrong `name:` now has a documented failure mode.** In a patch,
74
+ `name` is an *assertion* about the entry being targeted, not a rename, and the Loader drops the whole
75
+ patch on mismatch — leaving the plugin mounted on the bundle's empty defaults: eight tools present,
76
+ `w2m_status` reading `paired: false` and a null `rabbitUrl`, with the profile's settings sitting in
77
+ the file the whole time, unread. README now carries the hazard where the config block is copied,
78
+ `TROUBLESHOOTING.md` §2 names it as the third mechanism for "the patch lost my configuration" (and
79
+ the only one where the file looks intact), and §17 documents the reason codes this release adds and
80
+ corrects.
81
+ * **`test/agent.test.mjs`'s sub-path CLI test no longer depends on the host's `PATH`.** This is what
82
+ made v0.6.1's own pre-flight **RED** — "do not bump" — on a machine where node exists only as DSH's
83
+ bundled runtime. `runCli` points the child's `DSH_HOME` at a scratch directory, so
84
+ `bundledBinDirs()` correctly finds no bundled runtime there; the command under test is built from
85
+ `process.execPath` while the allow-list entry is the bare name `node -e`; and with no bundled runtime
86
+ and no `node` on `PATH`, that name does not resolve, so the absolute path is refused with
87
+ `COMMAND_EXECUTABLE_PATH_MISMATCH` — the allow-list behaving exactly as documented. CI runs
88
+ `actions/setup-node`, which is why it was green there. The child now gets the executing node's own
89
+ directory on its `PATH`, which restores the identity the check exists to confirm rather than
90
+ weakening it: a mismatched absolute path is still refused with the same code.
91
+
92
+ ### Changed
93
+
94
+ * `scripts/preflight.mjs` reports **11 pass, 0 fail, 1 unverifiable — GREEN, cleared to bump** on a
95
+ clean macOS checkout. Before this release the same gate reported "10 pass, 1 fail — RED".
96
+ * The local suite reads **557 tests, 555 pass, 0 fail**.
97
+
98
+ ## [0.6.1] — 2026-10-11
99
+
100
+ ### Fixed
101
+
102
+ * **v0.6.0 was tagged and never published, because its release pipeline ran a pre-flight that could not
103
+ complete on the platform that builds the artifact.** Nothing was wrong with the v0.6.0 *code*: the
104
+ tarball this release publishes is the v0.6.0 tree plus the repairs below, and the section underneath
105
+ describes what is in it. What was wrong was the pipeline's order and two of its checks, all three
106
+ invisible on the developer machine that wrote them. This is a fix-forward release rather than a re-tag:
107
+ v0.6.0 stays tagged and unpublished, and its content ships here.
108
+ * **The pre-flight ran before the pack, so on a fresh checkout it could never be green.** `dist/` is
109
+ gitignored, so a clean checkout has no tarball, and three assertions in `test/claims.test.mjs` about the
110
+ *packed* tree skip when there is none -- while `scripts/preflight.mjs` fails any skip whose reason is not
111
+ on `ACCEPTED_SKIPS`. Measured by moving `dist/` aside: "10 pass, 1 fail, 1 unverifiable -- RED", naming
112
+ `no dist/ in this checkout: the packed tree was not verified here`. It looked green on a developer
113
+ machine only because `dist/` still held a tarball from an earlier pack. `ci.yml`'s `preflight` job and
114
+ `release.yml` now pack first, and `release.yml`'s checksums moved below the pre-flight so `SHA256SUMS`
115
+ describes the bytes the pre-flight left in `dist/` -- the ones unpacked, verified and published. The skip
116
+ was deliberately **not** added to `ACCEPTED_SKIPS`: that would have traded three real assertions about
117
+ the published artifact for a green light.
118
+ * **The matrix job ran `npm test` without `npm install`, and the gate inventory names a `node_modules`
119
+ path.** `test/claims.test.mjs` asserts that every artifact `GATE_INVENTORY` names exists in the tree --
120
+ the check that catches a gate deleted from the pre-flight -- and the first row of that inventory is the
121
+ eslint entry point at `node_modules/eslint/bin/eslint.js`. The matrix job never installed dev tooling, so
122
+ all four legs failed that assertion with `the inventory names files that are not in the tree: eslint entry
123
+ point (node_modules/eslint/bin/eslint.js)`. It could not reproduce locally, where `node_modules/` exists,
124
+ and it was newly exposed by v0.6.0's own wiring change: the hand-listed 22-path subset it replaced had
125
+ never run `claims`. The matrix job now installs dev tooling before `npm test`, and was measured green on
126
+ all four legs afterwards.
127
+ * **The pre-flight read the claims suite's counts without pinning the reporter, so on node 22 it read
128
+ none.** `checkClaims()` parsed the child's stdout with the spec reporter's summary in mind, while
129
+ `checkTestSuite()` has always pinned `--test-reporter=spec --test-reporter-destination=stdout`. Node's
130
+ default reporter depends on the runtime: measured, the same command prints the spec summary under node 24
131
+ and TAP's `# tests 48` under node 22 when stdout is a pipe -- which is what a CI runner is. The check
132
+ therefore reported `the runner printed no test count at all` for a suite that had passed, on the version
133
+ every workflow runs, and **every local run on node 24 was green: only the runner's own output named it.**
134
+ The reporter is now pinned exactly as the declared-suite check pins it.
135
+ * **The pre-flight now names its own failing checks in the CI interface.** Its verdict is its exit status
136
+ and its output went only to the step log, so a red pre-flight read as one line -- "Process completed with
137
+ exit code 1" -- to anyone without log access, including the API-only tools this project audits a run
138
+ with. The `FAIL` and `UNVERIF` lines it printed are now `::error::` annotations, the convention
139
+ `compat.yml`'s verdict program already used. The exit status is unchanged: the wrapper reads the
140
+ pre-flight's own status out of `PIPESTATUS` and exits with it, because a pipeline ending in `tee` would
141
+ otherwise report success for a red gate.
142
+
143
+ ### Notes
144
+
145
+ * **Two skips are accepted because a CI runner has no DSH installation, and nothing else was
146
+ pre-accepted.** `update-install: real pnpm against a throwaway profile` needs the pnpm that ships
147
+ *inside* a DSH runtime (`$W2M_PNPM`, or `$DSH_HOME/dsh-runtimes/*/dependencies/pnpm/bin/pnpm.mjs`), so on
148
+ a runner both of its assertions skip with `runtime pnpm not found`; they are accepted as
149
+ `no-runtime-pnpm`, capped at two. The entry carries no platform qualifier, because this is not a platform
150
+ fact -- Windows runners lack the runtime exactly as Linux ones do -- and the cap is the bound: a third
151
+ skip in that describe fails the check, and this reason on a host that *does* have a runtime would be a
152
+ defect in resolution rather than an environment fact. The alternative, building a synthetic runtime in CI
153
+ the way `compat.yml` builds a host for the loader gate, was rejected on purpose: it would exercise the
154
+ installer against a pnpm fetched from npm instead of the one the Desktop ships, a weaker claim about the
155
+ artifact plus a network dependency inside the release gate. The platform-conditional pair in
156
+ `test/agent.test.mjs` is qualified the other way round and **by platform**, so accepting the POSIX side
157
+ cannot excuse the same reason on Windows, where that assertion is expected to run.
158
+ * **A hypothesis that was tested and did not survive, recorded because the next reader deserves it.** The
159
+ standing explanation for the four red matrix legs was "the runner has no DSH installation, so suites that
160
+ need one behave differently there". It was tested by rebuilding the runner's conditions -- a clone with no
161
+ `node_modules/`, no `dist/`, no DSH installation reachable, no `~/.dsh`, node 22, one `npm test` -- and it
162
+ did not produce a failure: the loader gate skips (accepted), and the bundled-python assertion is guarded
163
+ by `PATH` and had already passed in v0.5.9's CI on these same runners. The real mechanism was a path in
164
+ the gate inventory on a job that never ran `npm install`. A wrong hypothesis that was tested and
165
+ discarded is worth as much to the next reader as the right one.
166
+ * **A node-22 difference this release does not fix, stated with its bounds.** With the pre-flight's shaped
167
+ `DSH_HOME`, `test/agent.test.mjs` is 147 tests / **0 fail under node 24** and **13 fail under node 22**
168
+ (`'refused' !== 'ok'`, around `test/agent.test.mjs:3870` and `:3915`). `engines` declares `>=20.19.0`, so
169
+ node 22 is a supported runtime and this is a real difference rather than a curiosity. Two facts bound it:
170
+ it does not fire in CI today, because a runner cannot shape a home -- `prepareSuiteEnv()` finds no runtime
171
+ source there and the suite runs bare, which is why no agent failure appears on ubuntu -- and it will
172
+ surface the moment the pre-flight job is given a runtime layout. It is a behaviour question rather than
173
+ wiring, so it is recorded here and left to a separate change.
174
+ * `PROTOCOL.md` §9's correction note said there is no such release as v0.6.0; that was true when it was
175
+ written and the tag made it false. It now still says what it was written to say -- the P2P data-plane
176
+ crypto shipped in **v0.5.3**, not v0.6.0 -- without contradicting the tag.
177
+
178
+ ## [0.6.0] — 2026-10-11
179
+
180
+ ### Added
181
+
182
+ * **The pre-flight became a ledger, with a self-test of its own.** `scripts/preflight.mjs` grew from 886
183
+ lines to 2,077 and the claims suite it reuses from 775 to 1,581. Every gate it delegates to is now an
184
+ **artifact bound to the registered check that measures it, in both directions**: a gate with no check is
185
+ a finding, a check with no gate is a finding, and a registered check that produces no result at all is
186
+ a `FAIL` rather than a silence. The ledger that enforces that is itself exercised on every run — a
187
+ silent check, a throwing check and an honest one must classify `FAIL`/`FAIL`/`PASS`, and an
188
+ `UNVERIFIABLE` whose reason is not on the accepted list is refused rather than reported. Measured on
189
+ this tree: 12 artifacts inventoried, 11 registered checks, 0 unbound.
190
+ * **The packed tarball is checked against an expectation derived from the tree, not a hand-written list.**
191
+ Every document in the tree is either shipped or named in `DOCUMENT_EXCLUSIONS` **with a reason** (18 in
192
+ the tree, 17 shipped, 1 excluded, 0 neither), the packed `package.json` is compared with the tree's
193
+ **field for field**, and every manifest-declared path must ship. Both directions are asserted, so "the
194
+ packer stopped shipping a document" and "a document was added and the packer was not told" fail the
195
+ same check instead of one of them passing quietly.
196
+ * **Workflow wiring is a check rather than a convention.** `ci.yml` and `release.yml` must each run every
197
+ suite `package.json#scripts.test` names and must not hand-list a suite set, and the pre-flight asserts
198
+ that the loader gate keeps its triggers on the two occasions that matter — every pull request and every
199
+ release. That gate was the one a hand-edited suite list quietly dropped, which is how six suites that
200
+ exist and pass came to run in no workflow at all.
201
+ * **Accepted reasons are named per claim, and every skip is printed with its reason.** `UNVERIFIABLE` is
202
+ not a pass: each accepted entry carries the exact text it accepts and the one claim it may be used for.
203
+ On this host the declared suite produces 4 skips, each on the accepted list, each printed in the
204
+ pre-flight's own output rather than summarised as a count.
205
+ * **The compatibility gate classifies every result line, and reads the assertions out of the test file.**
206
+ `.github/workflows/compat.yml`'s verdict program used to check nine hand-listed titles plus the page
207
+ half, so a tenth assertion that skipped could leave the job green; and it decided the accepted reason
208
+ from the **runner** (`BROWSER === ''`), so a page assertion skipped for a reason that is a property of
209
+ our own composition printed ``Accepted reason `no-browser` `` and exited 0. The accepted list is now
210
+ matched against the **skip directive's own text**, only the page half may use it, the assertions the
211
+ file declares are read from the file, and an assertion that produced no result line at all is a `FAIL`.
212
+ * **The operator token is no longer printed on every start.** The relay prints it in its banner **only on
213
+ the start that generated it**; every other start prints `OPERATOR TOKEN — not printed (this start did
214
+ not generate it)` with the path and the mode to read it from. That banner lands in journald, a
215
+ container log or a CI transcript, and this token authorises dispatching to the whole fleet, so a
216
+ restored token in a log is a credential in a log. `--print-operator-token`
217
+ (`W2M_PRINT_OPERATOR_TOKEN=1`, or `printOperatorToken: true` for an embedder) asks for the old
218
+ behaviour explicitly, and `docs/DEPLOY.md` states that on the generating start the value still appears
219
+ **twice on purpose** — once on stderr as the log copy, once in the CLI's own box on stdout as the
220
+ handoff a human reads.
221
+
222
+ ### Fixed
223
+
224
+ * **A punch could be refused by a datagram that was not a refusal at all — about one handshake in 256.**
225
+ `decodeRefuse` accepted exactly one body length and validated nothing else, and a `REFUSE` rides inside
226
+ the same `HELLO_ACK` frame as a handshake answer. A genuine `HELLO_ACK`'s byte 6 is the first byte of
227
+ the responder's random ephemeral, so when that byte happened to equal the length the parser expected,
228
+ an honest encrypted answer was read as a refusal: reported with a refusal reason the peer never sent,
229
+ and — in the codes that are terminal — handing back no channel at all. It was found while adding the
230
+ `k_ref` seal to `REFUSE`. The parse now checks the version byte, both legal lengths and that the
231
+ machine id carries no control bytes, and a test bends a real acknowledgement's length byte into that
232
+ exact collision.
233
+ * **`REFUSE` is sealed, and a refusal that cannot be corroborated is dropped.** A `REFUSE` may be
234
+ unauthenticated by design — it is sent precisely when the two ends could not agree a key — which made
235
+ it a downgrade oracle: anything able to forge one could turn "the peer never answered" into "the peer
236
+ refused". `k_ref = HKDF(ECDH(e_i, s_r), REFUSE_MAC_LABEL)` is a key the initiator derives as easily as
237
+ the responder, over a label distinct from every handshake MAC and over a **different input**
238
+ (`ECDH(e_i, s_r)`, not `ECDH(s_i, s_r)`); a refusal must now come from the address and the session the
239
+ punch agreed and carry a `k_ref` MAC. Four codes name the **ignored datagram** rather than a failed
240
+ punch (`P2P_CRYPTO_REFUSE_SESSION_MISMATCH`, `..._UNPUNCHED_SOURCE`, `..._BAD_MAC`,
241
+ `..._UNCORROBORATED`), so a refusal that was thrown away is never reported as a refusal that happened.
242
+ * **A started node held the process open, and the comment claimed an `unref()` that existed nowhere.**
243
+ With no `ctx.effect` — an older Cordis, or a bare test context — the plugin composed a node and never
244
+ released the handles that keep a Node.js process alive: the bound UDP socket, the announce refresh
245
+ timer, and the accept loop's pending wait timeout. `src/agent/p2p-node.mjs` now has a real `unref()`,
246
+ **remembered rather than applied once** (a `close()` followed by a `start()` binds a second socket and
247
+ arms a second wait, so a call that took effect only on the first would not be a promise), the accept
248
+ loop reads the flag on every round, and the plugin calls it when no disposer will ever be called.
249
+ Found by an independent verifier against the `w2m_status` test's bare context.
250
+ * **A failed state publish left its temp file behind: one live state directory had accumulated 266
251
+ `agent-state.json.tmp-*` files.** `publishState()` renames a temp file over `agent-state.json` while
252
+ another process may be reading it, and on Windows that contention comes back as `EPERM`/`EACCES` from
253
+ the rename although both paths are fine and the condition clears within milliseconds. The rename is now
254
+ retried (5 attempts, 3 ms × attempt, **retryable errors only** — a permanent failure is rethrown
255
+ exactly as a bare `renameSync` would be), and a publish that fails outright unlinks its own temp file.
256
+ * **`test:agent` masked a hang for more than 283 seconds.** The suite had finished asserting and could
257
+ not exit; without `--test-force-exit` the verdict was a timeout rather than a result, and the same was
258
+ true of `test:relay` and `test:tools`. All three carry the flag now, as `test`, `test:e2e` and
259
+ `test:all` already did.
260
+ * **`test/p2p-crypto.test.mjs` was still not in `test:all`.** 33 tests covering the data-plane
261
+ encryption rode `npm test` and not the suite a release runs, so the crypto suite did not ride the full
262
+ gate. The one path the v0.5.9 entry asked its owner for is appended after
263
+ `test/mdns-discover.test.mjs`.
264
+ * **The allow-list is one rule at both ends, and it is a grant rather than a sandbox.** The plugin's
265
+ pre-flight reduced `argv[0]` to a basename before matching, so it would wave `E:\Temp\evil\node.exe`
266
+ through while the agent refused it; the argv is handed over verbatim now, to the same matcher the agent
267
+ uses. Three narrower rules sit on top of the prefix match, each with its own code because each has its
268
+ own remedy: `COMMAND_REPARSING_ARGUMENT` refuses code-loading **trailing** arguments
269
+ (`--test-reporter`, `--import`, `--require`, `-r`, `--experimental-loader`, `-e`/`--eval`,
270
+ `-p`/`--print`, `python -c`/`-m`, `bash -c`, `git -c`/`--exec-path`, `make -f`, `tar --to-command`, and
271
+ their equivalents in the interpreters in common use) unless the entry names them;
272
+ `COMMAND_EXECUTABLE_PATH_MISMATCH` admits an absolute `argv[0]` only when it resolves to the same file
273
+ the entry's executable does; and `COMMAND_EXECUTABLE_RELATIVE_PATH` refuses a relative `argv[0]`
274
+ outright, because the working directory comes from the offer. **The limit is stated rather than
275
+ implied: an entry is a code-execution grant, not a sandbox.** `node --test` allows
276
+ `node --test <any file>`, and the test runner loads and runs whatever file it is handed. The list says
277
+ which program may run, not what that program can be told to do.
278
+ * **The Windows supervisor: one supervisor, one agent, and the punch port held by the agent that is
279
+ actually running.** Measured on this machine: **fourteen `w2m` processes, nine of them `w2m-localside`
280
+ agents sharing one identity, with port 41235 held by an agent from thirteen hours earlier** — so every
281
+ dispatch fell back to the relay (`P2P_UNREACHABLE`) while the heartbeat said nothing. A second
282
+ generation could start while a healthy one ran, and the start-time reap could only ever see the single
283
+ pid the heartbeat remembered. There is now a **single-instance lock** (`open(…, 'wx')`, i.e.
284
+ `O_CREAT|O_EXCL`, so of two generations racing exactly one creates the file): a generation that finds
285
+ it held logs a no-op line and exits without spawning, and a lock whose holder is gone or whose file is
286
+ unreadable is **taken over loudly** rather than blocking the machine forever. The reap matches **by
287
+ identity** — process table plus command line naming the same state directory, with the supervisor
288
+ excluded — because a supervisor's own command line carries the agent stub's path and an identity-only
289
+ reap killed its own program (measured: it did). `orphans_reaped`, `orphans_reaped_pids` and
290
+ `lock.takeovers_total` are in the heartbeat, so the machine's own account of what it reaped is readable
291
+ without a debugger.
292
+ * **The scheduled task was registered `MultipleInstances IgnoreNew`, which cannot recover from a
293
+ supervisor death.** IgnoreNew refuses the second start — the right-looking answer and the wrong one,
294
+ because the watchdog tick that should have replaced a dead generation was itself the start being
295
+ ignored, and the machine stayed down until a human noticed. `install-service.ps1` now registers
296
+ **`MultipleInstances Parallel`**, which is safe precisely because the supervisor's own lock is what
297
+ prevents a second *agent*, and it prints the setting it wrote so the correction is visible in the
298
+ install transcript rather than inferred. `deploy/windows/verify-supervisor.mjs` is the reproducible,
299
+ no-elevation check of the whole property: it builds the broken state on purpose — orphan agents already
300
+ holding a free port, a lock left behind by a killed supervisor — and then asserts that exactly one agent
301
+ is left, that it owns the port, and that the heartbeat names the pids it reaped.
302
+ * **Documentation corrections, each of which was a false statement in a shipped document.**
303
+ * **`SECURITY.md` had been telling users to install a six-release-old build.** Its supported-version
304
+ table read "v0.5.3 and everything before it" and listed v0.5.6-and-later as "not yet released" —
305
+ three releases after v0.5.6 had shipped. It names **v0.5.9** as the supported line now and says why
306
+ v0.5.4 and v0.5.5 are withdrawn (they fail during DSH's web boot and take the whole application with
307
+ them, all eight tools included), and the install commands in `SECURITY.md` and `TROUBLESHOOTING.md`
308
+ point at a release that exists.
309
+ * **`TROUBLESHOOTING.md` named the wrong cause for a vanished `cordis.patch.yml`.** `dsh plugin add`
310
+ does **not** rewrite it — the CLI shares the plugin manager's package operations, which write
311
+ `package.json` and `pnpm-lock.yaml`. What removes the file is Desktop's fatal-recovery
312
+ `sanitizeProfile`, which renames it to `cordis.patch.yml.bak-<unix-ms>`; the `.bak-<timestamp>`
313
+ sibling is the receipt to look for, and the backup habit matters more before an upgrade, not less.
314
+ The same section now shows the `insert:` form and states what a bare `- id:` actually does — replace
315
+ fields on a row that already exists, so against a row nothing created it is a silent no-op and the
316
+ loader says `patch: entry "w2m" not found` while the install reports success.
317
+ * **`docs/P2P-CRYPTO.md` dated the data plane's encryption to a release that does not exist.** Stage 1
318
+ shipped in **v0.5.3**, and the file said v0.6.0. This is the first v0.6.0, and the reference now says
319
+ v0.5.3.
320
+ * **The `p2pOfferGraceMs` line in the v0.4.0 entry said 1200 ms and the shipped constant is 3000 ms.**
321
+ The widening rode *inside* v0.4.0 (commit `eea80c7`, the commit the tag points at) without its own
322
+ CHANGELOG line, which `CONTRIBUTING.md` §5 requires for a behaviour change. The line matches the
323
+ shipped constant now and the omission is recorded rather than left to be rediscovered from a diff;
324
+ `README.md` and `PROTOCOL-v0.4.0.md` carry the same correction.
325
+ * **`docs/PIPELINE-DESIGN.md` opened with "unresolved, and `pipeline` is deliberately not
326
+ implemented"**, which had been false since v0.3.4, and it recommended Option A while **Option C** is
327
+ what shipped. The analysis is kept as written and the divergence is stated: editing the argument to
328
+ match its conclusion would destroy the only thing that file is good for.
329
+ * **`deploy/networking/LESSONS.md` stated the tunnel invariant in a form that contradicts the
330
+ ledger.** It said "tunnelled traffic must never be reported as `p2p`"; `transport` is the **socket
331
+ path**, rung 4 *is* a socket between the two machines, and calling that a lie would make the field
332
+ useless. The rule that actually holds is that the **tunnel must be named explicitly** wherever it
333
+ carried the traffic, and that "direct" must never be read as "the bytes avoided a server".
334
+ `deploy/networking/README.md` states it in the same words, and the two files now agree with each
335
+ other and with `docs/AUDIT.md`.
336
+
337
+ ### Notes
338
+
339
+ * **What is new behaviour here, and what is a document catching up to behaviour that already shipped.**
340
+ The REFUSE parse and its `k_ref` seal, the node `unref()`, the state-publish retry and cleanup, the
341
+ shared allow-list matcher and its three narrower rules, the operator-token printing gate, the
342
+ supervisor's lock / reap-by-identity / `MultipleInstances` correction, the pre-flight's ledger, the
343
+ compat gate's structural classification, and the `test:all` + `--test-force-exit` wiring are **code
344
+ changes in this release**. The client-entry requirement (`id: w2m`, the **bare** package name, and the
345
+ `insert:` form) and the loader driver's **registry-wide failure sweep** shipped in v0.5.4–v0.5.6; what
346
+ is new here is that both are documented as requirements a reader can act on, and that the compatibility
347
+ gate requires the sweep's own result line (`sweeps every fiber in the process and finds no failure`) by
348
+ name instead of trusting nine hand-listed titles.
349
+ * **LAN discovery remains opt-in, and it is unverified on a real network.** Every socket in
350
+ `test/mdns-wire.test.mjs` and `test/mdns-discover.test.mjs` is loopback, and no mDNS packet has crossed
351
+ a real LAN in this project's testing; `README.md` names both suites in its table now rather than
352
+ leaving them to be found. Discovery is a **hint, never an authorisation**: a machine the LAN announced
353
+ but the relay does not list is not a target.
354
+ * **One check is `UNVERIFIABLE` on this machine and is printed as such rather than passed quietly.** This
355
+ host's `%DSH_HOME%` holds no `dsh-runtimes\`, while `src/agent/resolve.mjs` looks for the bundled
356
+ runtime at `dsh-runtimes/dsh-primary-runtime/dependencies` and the application bundle carries it at
357
+ `E:\DS\resources\runtime\primary-runtime\dependencies`. The suite is run against a throwaway home with
358
+ the documented layout, and the host mismatch is printed next to the verdict.
359
+ * **Unverified from this machine, stated rather than implied.** The GitHub Release assets behind the
360
+ documented tarball URLs (`test/claims.test.mjs` skips that claim without `W2M_NETWORK_TESTS=1`), and
361
+ **the first CI run of the compatibility gate in its new form**: its verdict program is extracted out of
362
+ the workflow and exercised by `test/claims.test.mjs` against synthetic TAP captures — an honest
363
+ no-browser skip, an injected extra assertion, an unaccepted reason — but the gate itself is judged by a
364
+ runner that is not this machine.
365
+ * The numbers in `README.md`'s suite table are the **v0.5.9** measurements and stay labelled that way.
366
+ Adding `test/p2p-crypto.test.mjs` to `test:all` changes that row's suite and test counts, and neither
367
+ was re-measured for v0.6.0 on this host — a full `test:all` on this machine takes long enough that
368
+ running it to refresh a table would be the wrong trade.
369
+
7
370
  ## [0.5.9] — 2026-10-11
8
371
 
9
372
  ### Added
@@ -71,8 +434,13 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
71
434
  was not being run at all between that edit and this release.
72
435
  * **`test/p2p-crypto.test.mjs` was named by no script at all.** 33 tests, green, and wired into
73
436
  nothing — the exact defect v0.5.7's release note identified in someone else's repository. A suite
74
- that never runs protects nothing, and that is now asserted rather than assumed. It is in `test` and
75
- `test:all`.
437
+ that never runs protects nothing, and that is now asserted rather than assumed. **It is in `test`,
438
+ and as of this entry it is still *not* in `test:all`** — that is a wiring gap, not a rounding
439
+ detail, because `test:all` is the suite a release runs: 35 paths are named there and
440
+ `test/p2p-crypto.test.mjs` is not one of them, so the crypto suite still does not ride the full
441
+ gate. This entry previously claimed both; the claim is now the measured one. The fix is one path in
442
+ `package.json#scripts["test:all"]`, appended after `test/mdns-discover.test.mjs` — the file is owned
443
+ by the integrator, so the line is reported rather than edited here.
76
444
 
77
445
  ### Notes
78
446
 
@@ -789,12 +1157,20 @@ written down as not measured.
789
1157
  * **The direct path is the default connection method between machines.**
790
1158
  `p2pMode: auto` is now real behaviour: the dispatching machine announces,
791
1159
  punches to each target, and pushes the offer over the punched channel. The relay
792
- **holds its own copy of that offer back for `p2pOfferGraceMs` (1200 ms)** and
1160
+ **holds its own copy of that offer back for `p2pOfferGraceMs` (3000 ms)** and
793
1161
  offers it anyway if the punch does not land — without that hold the direct path
794
1162
  could never win, because an SSE push over an already-open connection beats a
795
1163
  punch that costs a round trip. Measured on loopback while writing it: the relay
796
1164
  copy won every time. The hold is cancelled by the executor's own lease
797
1165
  heartbeat, so no new message was needed to say "the direct copy arrived".
1166
+ The first value was **1200 ms and it was too tight** — measured on a 1-vCPU
1167
+ shared host, two agent tests saw the relay's copy overtake the direct one, which
1168
+ under `p2pMode: direct` refuses an offer that was about to arrive. This entry said
1169
+ 1200 ms for the whole of v0.4.0; the widening to 3000 ms rode **inside** v0.4.0
1170
+ (commit `eea80c7`, the commit the tag points at) **without its own CHANGELOG
1171
+ line**, which `CONTRIBUTING.md` §5 requires for a behaviour change. The line now
1172
+ matches the shipped constant, and the omission is recorded here rather than left
1173
+ for a reader to rediscover from a diff.
798
1174
  * **The result notice travels back over the same channel, and the payload does
799
1175
  not.** `{type:'task.result', task_id, machine_id}` is acknowledged with
800
1176
  `result.ack`; the envelope is not on that frame, because `result_path` is one of
@@ -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
 
package/PROTOCOL.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # W2M 线协议 v1(冻结)
2
2
 
3
- > 项目:**DSH: Windows2MacOS** · 版本 **0.0.1** · 本文件是 Rabbit / Localside / DSH 插件三方共同遵循的**唯一契约**。
3
+ > 项目:**DSH: Windows2MacOS** · **线协议版本 `1`**(`protocol_version = 1`,未变)· 本文件最后校对的软件版本 **`@twinsearth/w2m-dsh-plugin@0.5.9`** · 本文件是 Rabbit / Localside / DSH 插件三方共同遵循的**唯一契约**。
4
4
  > 任何一方改动本文件即为**破坏性变更**,必须同时升 `protocol_version`。
5
+ > 两个版本号不是一回事:**线协议版本**只在破坏性变更时上升,**软件版本**是写这份文件时校对过的发行版;软件版本比协议版本变得频繁得多,不要把它们混为一谈。
5
6
 
6
7
  ---
7
8
 
@@ -33,7 +34,7 @@
33
34
  | `POST` | `/v1/result` | Bearer | 回传结果信封 | 是(`dedupe_key` 去重) |
34
35
  | `POST` | `/v1/task` | Bearer | 提交任务(插件调用) | 否(每次新 `task_id`) |
35
36
  | `GET` | `/v1/devices` | Bearer | 设备表 | 是 |
36
- | `GET` | `/v1/tasks/{task_id}` | Bearer | 任务状态 + 六态聚合 | 是 |
37
+ | `GET` | `/v1/tasks/{task_id}` | Bearer | 任务状态 + 九态聚合 | 是 |
37
38
  | `GET` | `/v1/tasks/{task_id}/report?format=md\|json` | Bearer | 汇总报告 | 是 |
38
39
  | `GET` | `/v1/tasks?limit=N` | Bearer | 任务列表 | 是 |
39
40
 
@@ -139,7 +140,12 @@
139
140
  - 给了 → 只发给列出的机器;
140
141
  - 没给 → 发给**全部已配对设备**。
141
142
 
142
- **index 如何分配**:`replicate` → 所有机器 `index = 0`、`index_total = 1`;`split` → 按设备顺序 `index = i % index_total`。
143
+ **index 如何分配**(`src/relay/state.mjs` 的 `createTask`):`replicate` → 所有机器 `index = 0`、`index_total = 1`;`split` → 按设备顺序 `index = i % index_total`。
144
+
145
+ **四种 mode**:`replicate`(每台机器跑同一条命令)、`split`(按 index 切片)、`broadcast`(**只有一台**机器执行,由 `executor_machine_id` 指定;其余机器拿到 `state: "observing"` 的 lease,只被告知结果而**不执行**)、`pipeline`(每台机器按顺序跑完 `stages` 整条链,遇首个失败阶段即停;`stages` 仅在此 mode 下合法,最多 16 个阶段,且 `index_total` 必须为 1)。
146
+
147
+ > `observing` 是 **lease 状态**,不是 `outcome`:它持有该任务的一个位置并会被告知结果,但从未拿到命令,因此**不经过能力门**(拒绝一件没让你做的事不算拒绝),并且**被排除在每一个聚合步骤之外** —— 没跑过的机器永远不会被记成"结果一致"。
148
+
143
149
  能力门失败的机器仍会拿到一条 `state: "refused"` 的 lease,但**不会收到 offer**。
144
150
 
145
151
  ### 4.1.1 `GET /v1/tasks/{task_id}` 响应形状(初稿未定义,v0.0.1 补充)
@@ -167,7 +173,8 @@
167
173
  - **终态判定读 `aggregate.status`**(不是顶层 `status`,也没有顶层 `aggregate` 字符串)。
168
174
  - `machines[].outcome` 是**每台机器**的判定,`machines[].reasons[]` 是它被扣分的具体理由(例如 `"pre_tree_fingerprint != base_tree"`)。
169
175
  - `aggregate` 在结果到达前是 `pending`。**`pending` 与"某台机器仍 pending"都不算终态**:初稿的六态里 `partial` 会在第一台结果落地时立刻出现,此时其余机器仍在跑 —— 消费者必须同时检查 `machines[].outcome === "pending"`。
170
- - 六态之外还有两个仅作展示的值:`pending`(首条结果到达前)、`refused`(所有 lease 都被能力门拒绝)。
176
+ - **九态**(`AGGREGATE_STATUSES`,`src/relay/report.mjs`):`consistent`、`divergent`、`divergent-platform`、`degraded`、`failed`、`partial`、`timeout`、`cancelled`、`unverifiable`。其中 `timeout` / `cancelled` / `degraded` 是 v0.3.0 追加的三个**判定**,各自取代了一个说得不准确的说法:`timeout` 取代"永远 `pending`",`cancelled` 取代与 `failed` 混同,`degraded` 取代借用的 `partial`(后者同时意味着"还有机器没回")。
177
+ - 九态之外还有两个**仅作展示、不是判定**的值:`pending`(首条结果到达前,**唯一非终态**)、`refused`(所有 lease 都被能力门拒绝 —— 每台机器都已作答,等待不会改变结论,故按终态处理)。
171
178
 
172
179
  ### 4.2 `task.offer` 事件
173
180
  ```json
@@ -236,7 +243,7 @@ dedupe_key = sha256(task_id + "|" + index + "|" + command_hash + "|" + base_tree
236
243
  | `platform` | object | `{os, os_version, arch, shell, shell_version}` |
237
244
  | `caps` | object | 能力声明 |
238
245
  | `index` / `index_total` | int | replicate 固定 0/1 |
239
- | `mode` | `replicate`\|`split` | |
246
+ | `mode` | `replicate`\|`split`\|`broadcast`\|`pipeline` | |
240
247
  | `cwd_rel` | string | 相对项目根,正斜杠 |
241
248
  | `base_commit` | string | 中继广播 |
242
249
  | `base_tree` | string\|null | 期望工作区指纹 |
@@ -284,21 +291,32 @@ dedupe_key = sha256(task_id + "|" + index + "|" + command_hash + "|" + base_tree
284
291
  `base_commit` + `pre_tree_fingerprint` + `command_hash`。
285
292
  > `toolchain` / `platform` **不是锚**,它们走能力门。
286
293
 
287
- ### 6.3 六态聚合(Rabbit 侧)
294
+ ### 6.3 九态聚合(Rabbit 侧)
295
+
296
+ 步序是契约的一部分:Step 0 → 1 → 2 → 3,**永不合并**,因为顺序会改变结论。
297
+
288
298
  ```
289
299
  Step 0 任一机器 refused → 该机记 refused(不阻断其他机器)
300
+ 全部被拒 → 聚合 status = refused,Step 2/3 跳过
290
301
  Step 1 任一机器缺必需字段 或 base_commit≠期望 或
291
302
  pre_tree_fingerprint≠base_tree 或 command_hash 不一致 → unverifiable(拒绝汇总)
292
303
  Step 2 其余机器中:
293
304
  全部 ok → 进 Step 3
294
- 部分 ok、部分 {nonzero_exit,timeout,crashed} → partial
305
+ 部分 ok、部分 {nonzero_exit,timeout,crashed} → degraded (所有机器都已作答)
306
+ 部分 ok、部分失败,且仍有机器 pending → partial ("还有机器没回")
295
307
  全部 {nonzero_exit,timeout,crashed} → failed
308
+ 无人作答且已过 deadline → timeout (等够了,不会再来)
309
+ 操作者主动停止 → cancelled (不是故障,不得报成 failed)
296
310
  Step 3 比对全部"可比字段":
297
311
  全等 → consistent
298
312
  仅 toolchain/platform 不同且差异集中在 stdout 文本 → divergent-platform(期望内,不告警)
299
313
  其他差异 → divergent(列出字段与逐机取值)
300
314
  ```
301
315
 
316
+ **两个非判定值**:`pending`(首条结果到达前,唯一非终态)、`refused`(所有 lease 都被能力门拒绝)。
317
+
318
+ `degraded` 与 `partial` 的分工是 v0.3.0 修正的一处失真:`partial` 同时意味着"还有机器没回",于是一个**已结束但部分失败**的任务和**仍在进行**的任务在 `status` 这一个字段上无法区分。现在两者各有其名。
319
+
302
320
  ---
303
321
 
304
322
  ## 7. 错误响应统一形状
@@ -311,6 +329,9 @@ Step 3 比对全部"可比字段":
311
329
  | 码 | 含义 |
312
330
  |---|---|
313
331
  | `COMMAND_NOT_ALLOWED` | argv 不在该机的 default-deny 白名单内 |
332
+ | `COMMAND_REPARSING_ARGUMENT` | 白名单条目匹配,但**尾随实参**会让该工具加载条目从未指明的代码(`--test-reporter`、`--import`、`--require`、`-e`/`--eval`、`-p`/`--print`、`python -c`、`bash -c`、`git --exec-path` …)。要跑就必须把该实参写进条目本身 |
333
+ | `COMMAND_EXECUTABLE_PATH_MISMATCH` | `argv[0]` 是路径,但不是该条目可执行文件解析到的同一个文件(同名副本不是同一个程序) |
334
+ | `COMMAND_EXECUTABLE_RELATIVE_PATH` | `argv[0]` 是相对路径:工作目录由 offer 决定,因此它指向的文件随目录而变,白名单无法判定 |
314
335
  | `MISSING_<TOOL>` | 例如 `MISSING_PYTHON`、`MISSING_NPM`:能力门要求该工具,但该机探不到 |
315
336
  | `PLATFORM_MISMATCH` | 任务 `requirements.platform` 不含该机 OS |
316
337
  | `READ_ONLY_MACHINE` | 任务 `write: true`,但该机项目目录不可写(`fs.access(W_OK)` 失败) |
@@ -340,7 +361,13 @@ Step 3 比对全部"可比字段":
340
361
 
341
362
  ---
342
363
 
343
- ## 9. P2P 数据面加密(v0.6.0 新增,**附加式扩展,不升版本**)
364
+ ## 9. P2P 数据面加密(v0.5.3 新增,**附加式扩展,不升版本**)
365
+
366
+ > **版本号更正。** 本节此前标为 "v0.6.0 新增",这个标注是错的:**P2P 数据面加密的 stage 1 随
367
+ > v0.5.3(2026-10-11)发布**,`CHANGELOG.md` 的 `[0.5.3]` 条目就是它。`v0.6.0` 这个 tag 确实
368
+ > 存在,但它是其后的一次发行,内容是 REFUSE 的冲突处理、允许清单作为授权、以及 Windows 监督器的
369
+ > 锁 —— 加密不是它引入的。本节下文凡写 "v0.6.0" 处,指的是 **v0.5.3 起的这批加固**,而不是一个
370
+ > 版本号;不要因为本节挨着 v0.6.0 就把它算到 v0.6.0 的账上。
344
371
 
345
372
  > **不破坏冻结。** `protocol_version` 仍为 `1`,`envelope_version` 仍为 `1.0`。本节全部内容是
346
373
  > **可选对象 `p2p` 内部的新键**,以及对**已存在的可选字段** `p2p.encrypted` 的赋值规则;不知道
@@ -387,9 +414,14 @@ Step 3 比对全部"可比字段":
387
414
 
388
415
  | 帧 | body 布局(大端) | 长度 |
389
416
  |---|---|---|
390
- | `HELLO` | `A5 \| ver u8 \| session u32 \| e_i[32] \| idLen u8 \| machineId \| mac[48]` | 88 + idLen |
391
- | `HELLO_ACK` | 同上,`e_r` 替代 `e_i` | 88 + idLen |
392
- | `REFUSE` | `A5 \| ver u8 \| session u32 \| code u8 \| idLen u8 \| machineId` | 8 + idLen |
417
+ | `HELLO` | `A5 \| ver u8 \| session u32 \| e_i[32] \| idLen u8 \| machineId \| mac[48]` | 87 + idLen |
418
+ | `HELLO_ACK` | 同上,`e_r` 替代 `e_i` | 87 + idLen |
419
+ | `REFUSE` | `A5 \| ver u8 \| session u32 \| code u8 \| idLen u8 \| machineId[ \| mac 48]` | 8 + idLen,或 56 + idLen |
420
+
421
+ > 长度是 **87 + idLen**,不是 88:前缀 39 字节(magic 1 + ver 1 + session 4 + `e[32]` 32 + idLen 1),
422
+ > 加 `mac[48]` 共 87,再加 `idLen` 字节的 `machineId`。代码里就是
423
+ > `HANDSHAKE_PREFIX_BYTES = 1 + 1 + 4 + KEY_BYTES + 1`(39)加 `MAC_BYTES`(48),
424
+ > `test/p2p-crypto.test.mjs` 也按 `1+1+4+32+1+byteLength(machineId)+48` 断言。
393
425
 
394
426
  `mac` = `AEAD(k0, nonce=0, BLAKE2s-256(prologue ‖ ver u8 ‖ session u32 ‖ s_i.pub ‖ s_r.pub))`,48 字节
395
427
  (32 密文 + 16 tag)。`mac'` = 同 `k0`,消息为 `BLAKE2s-256(HELLO ‖ e_r)`。
@@ -402,7 +434,19 @@ Step 3 比对全部"可比字段":
402
434
  > 真实性无关。该省略以常量 `HANDSHAKE_MAC_FIELDS_OMITTED` 落在代码里,不是沉默。
403
435
 
404
436
  `REFUSE.code`:`1`=UNKNOWN_PEER、`2`=BAD_MAC、`3`=REPLAY、`4`=NO_KEY、`5`=VERSION、`6`=NO_PEER_KEY。
405
- `REFUSE` 本身**不认证**(它正是在无法认证对端时发出的),因此不携带任何证明。
437
+
438
+ `REFUSE` 的**可选尾部 48 字节 MAC**(v0.5.3 起的加固,MEDIUM-4 修复)用
439
+ `k_ref = HKDF-SHA256(ECDH(e_i, s_r))` 封装,消息为 `BLAKE2s-256(label ‖ 不含 MAC 的 REFUSE)`,其中
440
+ `label = "w2m-p2p-crypto v1 refuse"`。两端都能推导 `k_ref`:发起方持有 `e_i` 私钥与 `s_r` 公钥,
441
+ 响应方持有自己的 `s_r` 与 `HELLO` 里公开的 `e_i`——因此**即使响应方完全没有本机公钥**(`NO_PEER_KEY`
442
+ 的情形)也仍能给出证明;而 `k0` 在那种情形下根本不存在,这正是这个 MAC 必须用 `k_ref` 的原因。
443
+ `NO_PEER_KEY`/`UNKNOWN_PEER`/`BAD_MAC` 在可封装时一律封装;`VERSION` 保持裸体(需要对端的 body 布局
444
+ 本端无法担保)。因此「`REFUSE` 不认证」这句话只在**旧版本对端**意义上成立:可以没有 MAC,但规则是——
445
+ **要求明文的降级类拒绝**(`NO_KEY`/`NO_PEER_KEY`)必须**要么带通过验证的 `k_ref` 证明,要么不带 MAC
446
+ 且来自「已确认本次打洞的那个地址」**;**终结类拒绝**(`BAD_MAC`/`REPLAY`/版本不符)只要来自打过的地址
447
+ 就被采纳(无论是否封装),结果里用 `refusal_authenticated` 记下 `true`(证明通过)/`null`(无 MAC)/
448
+ `false`(有 MAC 但没通过),账本因此不会把「有人说」当成「对端证明」。丢弃的拒绝数据报只被记录、
449
+ 绝不使打洞失败——否则伪造一个数据报就成了比压制真实应答更便宜的拒绝服务。
406
450
 
407
451
  ### 9.4 数据面帧加密
408
452
 
@@ -432,10 +476,19 @@ frame = kind u8 | session u32 | A5 | epoch u8 | counter u64le | ciphertext | tag
432
476
  | `P2P_CRYPTO_HANDSHAKE_TIMEOUT` | 路径已通但握手未完成 → 不建通道 |
433
477
  | `P2P_CRYPTO_VERSION_MISMATCH` / `MALFORMED` | 版本或报文形状不可用 |
434
478
  | `P2P_CRYPTO_PLAINTEXT_PEER` | 对端未学得密钥(v0.5.x 情形)→ 明文通道 |
479
+ | `P2P_CRYPTO_REFUSE_SESSION_MISMATCH` | 收到的 `REFUSE` 未指名本次打洞的 session → **丢弃并记录**,不改变结果 |
480
+ | `P2P_CRYPTO_REFUSE_UNPUNCHED_SOURCE` | `REFUSE` 来自本次打洞从未打过的地址 → 丢弃并记录 |
481
+ | `P2P_CRYPTO_REFUSE_BAD_MAC` | 降级类 `REFUSE` 带 MAC 但验证失败 → 丢弃并记录(终结类拒绝不因 MAC 失败被丢) |
482
+ | `P2P_CRYPTO_REFUSE_UNCORROBORATED` | 未封装的降级类 `REFUSE` 并非来自「已确认本次打洞的地址」→ 丢弃并记录 |
435
483
 
436
484
  **回退规则**:握手失败 → 任务**照旧走中继**,信封记录 `p2p.encrypted: false` 与原因;机器**绝不**
437
485
  因为安全升级而丢失。唯一不允许的降级是"已持有对端密钥、握手失败后仍建明文通道"——那是一次攻击者
438
- 可触发的降级。
486
+ 可触发的降级。**这句在 v0.5.3 被收紧为更准确的表述**:握手*失败*本身绝不回落明文;唯一会给出明文
487
+ 通道的是对端明确声明"无法参与"(`NO_KEY`/`NO_PEER_KEY`),而这样的拒绝必须**要么带 `k_ref` 证明、
488
+ 要么来自已确认本次打洞的那个地址**。结果里同时记录 `refusal_authenticated`,让账本能区分
489
+ 「对端证明了自己」与「有人这么说」。仍然存在的残余风险(攻击者能从被打洞对端自己的地址与端口发出
490
+ **未封装**的拒绝)写在 `docs/P2P-CRYPTO.md` §2 与 §9 的最后,不再用绝对句掩盖。
491
+ 四个 `P2P_CRYPTO_REFUSE_*` 码都只记录、**绝不使打洞失败**:否则伪造一个数据报就比压制真实应答更便宜。
439
492
 
440
493
  ### 9.6 信封字段
441
494
 
@@ -455,7 +508,8 @@ frame = kind u8 | session u32 | A5 | epoch u8 | counter u64le | ciphertext | tag
455
508
  | 对端 | 结果 |
456
509
  |---|---|
457
510
  | v0.4.x / v0.5.x(无 `static_keys`) | 本端发**普通** `HELLO`,明文通道,任务照常完成,`p2p.encrypted: false` + 具名原因 |
458
- | v0.6.0(`static_keys` 齐备) | 握手完成,通道密封,`p2p.encrypted: true` |
459
- | v0.6.0,但本端无对端公钥 | 对端回 `REFUSE(6)`+普通 `HELLO_ACK` → 明文通道,原因为 `P2P_CRYPTO_NO_KEY` |
511
+ | v0.5.3 及以后,`static_keys` 齐备 | 握手完成,通道密封,`p2p.encrypted: true` |
512
+ | v0.5.3 及以后,但本端无对端公钥 | 对端回 `REFUSE(6)`+普通 `HELLO_ACK` → 明文通道,原因为 `P2P_CRYPTO_NO_KEY` |
460
513
  | 公钥错误 / 有人在冒充 | `REFUSE(2)` → **不建通道**,任务走中继,账本记 `encrypted: false` + `P2P_CRYPTO_BAD_MAC` |
514
+ | 第三方伪造 `REFUSE`(v0.5.9 MEDIUM-4) | 会话不符、地址未打过、MAC 不通过,或降级类拒绝未被同一地址确认 → 丢弃并记 `P2P_CRYPTO_REFUSE_*`,打洞结果不变 |
461
515
  | 自打洞(relay 把任务租给下发机自己) | 明文通道(自连接无路径可保护,且不得失败)