@twinsearth/w2m-dsh-plugin 0.9.1 → 0.9.3
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 +114 -0
- package/package.json +1 -1
- package/src/plugin/tools.mjs +1 -1
- package/src/relay/report.mjs +44 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,120 @@ 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.9.3] — 2026-10-14
|
|
8
|
+
|
|
9
|
+
**An undeclared anchor was compared against the literal `null`, so every dispatch from a non-git machine was `unverifiable` — including the ones that succeeded.**
|
|
10
|
+
|
|
11
|
+
### The report, and why it read as a lie
|
|
12
|
+
|
|
13
|
+
A relay was asked what happened to a task, and every answer came back the same way:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
status : unverifiable
|
|
17
|
+
reasons: ["base_commit \"a1e7dc042913b96da3ad68… != expected null"]
|
|
18
|
+
notes : ["aggregation refused: Step 1 anchor violation"]
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
while the machine's own record said `lease_state: done`, `status: ok`, `exit_code: 0`. A reader has no way
|
|
22
|
+
to prefer either half, so a task that ran perfectly was reported as one that could not be trusted — and
|
|
23
|
+
the same verdict came back for **every** dispatch.
|
|
24
|
+
|
|
25
|
+
### The cause
|
|
26
|
+
|
|
27
|
+
`report.mjs` Step 1 compared the anchor unconditionally:
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
if (canon(valueOf(env, 'base_commit')) !== canon(expected.base_commit)) {
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`expected.base_commit` comes from `task.base_commit ?? null`, and a dispatcher whose own tree is not a
|
|
34
|
+
git work tree — the ordinary case for the plugin running out of its profile directory — sends `null`. So
|
|
35
|
+
the comparison was *"the machine's real commit **equals null**"*, which no machine with an anchor can
|
|
36
|
+
satisfy. **A machine was marked untrustworthy for reporting its commit truthfully.**
|
|
37
|
+
|
|
38
|
+
That the two anchors disagree is the whole story. `base_tree` six lines below has always read:
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
if (expected.base_tree !== null && canon(valueOf(env, 'pre_tree_fingerprint')) !== canon(expected.base_tree)) {
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
— it tolerates absence. `base_commit` did not. Same kind of statement, opposite handling, and the
|
|
45
|
+
asymmetry is the bug. The protocol cannot even distinguish the two cases: `state.mjs` normalises both an
|
|
46
|
+
absent field and an explicit `null` with `input.base_commit ?? null`, so "no anchor" and "anchor is null"
|
|
47
|
+
arrive identically and must be treated identically.
|
|
48
|
+
|
|
49
|
+
### The fix
|
|
50
|
+
|
|
51
|
+
`base_commit` is enforced only when the dispatcher declared one, matching `base_tree`. The relaxation is
|
|
52
|
+
**reported, not silent**: an `unanchored: true` field on the aggregate plus a note saying
|
|
53
|
+
|
|
54
|
+
> no base_commit was declared, so the anchor check did not run: machine agreement here is agreement with
|
|
55
|
+
> each other, not with a tree the dispatcher named
|
|
56
|
+
|
|
57
|
+
because *"these machines agreed"* and *"nobody said what they should have agreed on"* must not read the
|
|
58
|
+
same to an operator. `consistent: true` from an unanchored run says the machines matched; it does not say
|
|
59
|
+
they ran the tree you meant.
|
|
60
|
+
|
|
61
|
+
### Verified
|
|
62
|
+
|
|
63
|
+
- `test/relay.test.mjs` — **152 tests, 0 failures**, including two new ones: the undeclared anchor is
|
|
64
|
+
skipped and reported, and a **declared** anchor mismatch still refuses aggregation. The second exists
|
|
65
|
+
because the first is a relaxation, and a relaxation without a falsifying test is how a check quietly
|
|
66
|
+
disappears.
|
|
67
|
+
- `eslint .` — 0 errors.
|
|
68
|
+
- The rest of the pre-flight — gate inventory, test wiring, workflow wiring, entry points, packed tarball,
|
|
69
|
+
claims, CHANGELOG shape — all green. The one red gate is `declared suite`, whose 38 failures on this host
|
|
70
|
+
are 38 tests that shell out to `git`, which is not installed here.
|
|
71
|
+
|
|
72
|
+
### Note on where task output lives
|
|
73
|
+
|
|
74
|
+
Investigating this also settled a question that had cost real time: the relay stores **no** task output. A
|
|
75
|
+
task record carries `machine_id`, `lease_state`, `outcome`, `status`, `exit_code`, `transport`, `p2p` and
|
|
76
|
+
`reasons` — and no `stdout`, no `stderr`, not even `stdout_sha256`, despite `COMPARABLE_FIELDS` naming
|
|
77
|
+
those fields. So "sync the output home" cannot be solved at the relay, by any port or proxy: the text was
|
|
78
|
+
never kept. Any durable record of what a command printed has to be written **on the machine that ran it** —
|
|
79
|
+
which is exactly where the fleet-memory work begins.
|
|
80
|
+
|
|
81
|
+
## [0.9.2] — 2026-10-14
|
|
82
|
+
|
|
83
|
+
**The gate added in v0.8.11 pointed at a file that had never been committed, so it failed on all four CI platforms.**
|
|
84
|
+
|
|
85
|
+
### What was broken
|
|
86
|
+
|
|
87
|
+
`verify:join` was wired in three places across two releases:
|
|
88
|
+
|
|
89
|
+
| where | when |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `package.json` → `"verify:join": "node scripts/verify-join.mjs"` | v0.8.11 |
|
|
92
|
+
| `.github/workflows/ci.yml` → `- run: npm run verify:join` | (pushed in v0.9.1) |
|
|
93
|
+
| `.github/workflows/release.yml` → `- run: npm run verify:join` | (pushed in v0.9.1) |
|
|
94
|
+
|
|
95
|
+
**`scripts/verify-join.mjs` itself was never in the repository.** It existed only in the working tree that built the tarballs, so every CI platform failed with the least interesting error available:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
Error: Cannot find module '/home/runner/work/dsh-windows2macos/dsh-windows2macos/scripts/verify-join.mjs'
|
|
99
|
+
code: 'MODULE_NOT_FOUND'
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
On `ubuntu-latest/22`, `windows-latest/22`, `windows-latest/20` and `macos-latest/22` alike. A file-existence check would have caught it; a review of the diff would not, because the diff was of files that existed.
|
|
103
|
+
|
|
104
|
+
### How it was found
|
|
105
|
+
|
|
106
|
+
By reading the CI failure logs rather than the CI summary. The summary said *"The fleet join reaches a token (stub agent, no relay)"* failed — which reads like the check *ran and disagreed*. It had not run at all. The summary named the step; only the log said `MODULE_NOT_FOUND`.
|
|
107
|
+
|
|
108
|
+
The same class of mistake as the four files an earlier push left behind (see v0.9.1), and it is worth naming the shared root cause: **the v0.8.x tarballs were packed from a working tree, while the repository was written to by listing files individually.** A pack packs everything; an explicit list omits what the author forgets. The durable fix is to stop enumerating — but until this release the enumeration is what the tree had.
|
|
109
|
+
|
|
110
|
+
So this release does the one thing v0.9.1 should have: it diffs the working tree against the repository in **both** directions. Exactly one file was present locally and absent remotely, and it was this one.
|
|
111
|
+
|
|
112
|
+
### Verified
|
|
113
|
+
|
|
114
|
+
- `node scripts/verify-join.mjs` — 16/16 locally, which it always was; the defect was never in the script.
|
|
115
|
+
- The script is now in the repository, which is the only place `verify:join` can run from: `claims.test.mjs` pins
|
|
116
|
+
`FORBIDDEN_IN_RELEASE = ['test', 'scripts', ...]`, so `scripts/` is deliberately **not** in `package.json#files` and
|
|
117
|
+
a published tarball carries no `scripts/` directory by design. `verify:join`, `pack` and `verify` are source-tree
|
|
118
|
+
and CI commands, not consumer commands. An earlier draft of this entry called the absent `scripts/` a packaging
|
|
119
|
+
defect; that was wrong, and the constraint above is why.
|
|
120
|
+
|
|
7
121
|
## [0.9.1] — 2026-10-14
|
|
8
122
|
|
|
9
123
|
**A powered-off machine stayed `online` and selectable in every fleet view, because `online` was never derived from contact.**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
4
4
|
"description": "DSH: Windows2MacOS — drive the same project on online Windows and macOS machines from one DeepSeek account, across networks and regions. P2P hole punching is the default path (relay + shared server as rendezvous, ledger and fallback), with a bundled STUN responder, Tailscale/public-VPS/tunnel shapes, Rabbit relay + per-machine Localside + eight DSH tools (replicate / split), nine aggregate verdicts and signed requests.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -180,7 +180,7 @@ const MAX_STAGES = 16;
|
|
|
180
180
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
181
181
|
* overrides it at runtime for tests and packagers.
|
|
182
182
|
*/
|
|
183
|
-
const PLUGIN_VERSION = '0.9.
|
|
183
|
+
const PLUGIN_VERSION = '0.9.3';
|
|
184
184
|
|
|
185
185
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
186
186
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
package/src/relay/report.mjs
CHANGED
|
@@ -216,7 +216,34 @@ export function aggregate(task, resultRecords = [], opts = {}) {
|
|
|
216
216
|
m.reasons.push('envelope status = unverifiable');
|
|
217
217
|
}
|
|
218
218
|
if (missing.length > 0) m.reasons.push(`missing required fields: ${missing.join(',')}`);
|
|
219
|
-
|
|
219
|
+
/**
|
|
220
|
+
* v0.9.3: `base_commit` is enforced only when the dispatcher declared one.
|
|
221
|
+
*
|
|
222
|
+
* The check used to be unconditional, so a dispatcher with **no** anchor -- one whose own tree is
|
|
223
|
+
* not a git work tree, which is the ordinary case for a machine that runs the plugin from its
|
|
224
|
+
* profile directory -- sent `base_commit: null`, and every result was then compared against the
|
|
225
|
+
* literal `null`. Any machine that *did* have an anchor reported its real commit and was marked
|
|
226
|
+
* `unverifiable` for telling the truth:
|
|
227
|
+
*
|
|
228
|
+
* expected.base_commit : null
|
|
229
|
+
* machine reported : a1e7dc042913b96da3ad68…
|
|
230
|
+
* reasons : ["base_commit \"a1e7dc04…\" != expected null"]
|
|
231
|
+
* notes : ["aggregation refused: Step 1 anchor violation"]
|
|
232
|
+
*
|
|
233
|
+
* which downgraded **every** dispatch from such a machine, including a wholly successful one, and
|
|
234
|
+
* made the verdict unreadable. The anchor exists to answer "did these machines run the same tree";
|
|
235
|
+
* with nothing declared there is no such question, and the honest verdict is that the run is
|
|
236
|
+
* unanchored -- not that it disagreed with `null`.
|
|
237
|
+
*
|
|
238
|
+
* `base_tree` three lines below has always read this way (`expected.base_tree !== null && …`), and
|
|
239
|
+
* the asymmetry was the bug: the two anchors are the same kind of statement and one of them
|
|
240
|
+
* tolerated absence while the other did not.
|
|
241
|
+
*
|
|
242
|
+
* An absent anchor is still *reported* rather than silently ignored -- `unanchored: true` on the
|
|
243
|
+
* aggregate and in the machine's own record -- because "these two machines agreed" and "nobody
|
|
244
|
+
* said what they should have agreed on" must not read the same to an operator.
|
|
245
|
+
*/
|
|
246
|
+
if (expected.base_commit !== null && canon(valueOf(env, 'base_commit')) !== canon(expected.base_commit)) {
|
|
220
247
|
m.reasons.push(`base_commit ${truncate(canon(env.base_commit), 24)} != expected ${truncate(canon(expected.base_commit), 24)}`);
|
|
221
248
|
}
|
|
222
249
|
if (expected.base_tree !== null && canon(valueOf(env, 'pre_tree_fingerprint')) !== canon(expected.base_tree)) {
|
|
@@ -429,6 +456,18 @@ export function aggregate(task, resultRecords = [], opts = {}) {
|
|
|
429
456
|
created_by: task.created_by ?? null,
|
|
430
457
|
cancelled: task.cancelled === true,
|
|
431
458
|
expected,
|
|
459
|
+
/**
|
|
460
|
+
* v0.9.3: whether this task ran against a declared tree at all.
|
|
461
|
+
*
|
|
462
|
+
* `expected.base_commit === null` means the dispatcher declared no anchor -- its own tree is not a
|
|
463
|
+
* git work tree, which is the ordinary case for the plugin running out of a profile directory. The
|
|
464
|
+
* anchor check is therefore skipped rather than compared against the literal `null` (see Step 1).
|
|
465
|
+
*
|
|
466
|
+
* Reported explicitly because the two situations must not look alike: `consistent: true` from an
|
|
467
|
+
* unanchored run says "these machines agreed with each other", **not** "these machines ran the
|
|
468
|
+
* tree you meant". An operator comparing results needs to know which of those they are holding.
|
|
469
|
+
*/
|
|
470
|
+
unanchored: expected.base_commit === null,
|
|
432
471
|
machines: machines.map((m) => ({
|
|
433
472
|
machine_id: m.machine_id,
|
|
434
473
|
machine_name: m.machine_name,
|
|
@@ -451,7 +490,10 @@ export function aggregate(task, resultRecords = [], opts = {}) {
|
|
|
451
490
|
})),
|
|
452
491
|
steps,
|
|
453
492
|
differences: step3.differences ?? [],
|
|
454
|
-
notes
|
|
493
|
+
notes: expected.base_commit === null
|
|
494
|
+
? [...notes, 'no base_commit was declared, so the anchor check did not run: machine agreement here is '
|
|
495
|
+
+ 'agreement with each other, not with a tree the dispatcher named']
|
|
496
|
+
: notes,
|
|
455
497
|
counts: {
|
|
456
498
|
refused: step0.refused.length,
|
|
457
499
|
unverifiable: step1.violations.length,
|