@twinsearth/w2m-dsh-plugin 0.8.0 → 0.8.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 CHANGED
@@ -4,6 +4,213 @@ 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.8.3] — 2026-10-14
8
+
9
+ **The last wire, and the reason it was never there.**
10
+
11
+ ### `autoStartAgent` was dead code
12
+
13
+ The option existed, was documented, and **threw on its first call**. `startLocalStack` built its
14
+ `createAgent` arguments as `projectDir` (the option is `project`), omitted `identity` (which is
15
+ required), and passed a `pairingCode` that `createAgent` does not accept.
16
+
17
+ Verified rather than inferred — calling `createAgent` with that exact shape:
18
+
19
+ ```
20
+ createAgent THREW: createAgent: project is required
21
+ ```
22
+
23
+ **So the code path that was supposed to bring an agent up had never run.** That is why the
24
+ fleet-join wire appeared to be missing: the place it was meant to live could not be reached at all.
25
+ An option that exists, is documented, and cannot execute is worse than one that is absent — it
26
+ reads as a feature.
27
+
28
+ ### Two more things wrong with the shape
29
+
30
+ * **It created a local relay unconditionally.** For a machine joining a shared fleet that is the
31
+ opposite of what is wanted: the machine must connect to the relay that already exists, not mint
32
+ another one — and with `rabbitUrl` pointing at a remote host it would have tried to bind a relay
33
+ on that hostname. It now stands up a local relay **only when the configured URL actually is
34
+ local**, and otherwise logs that it is joining the relay at the given address.
35
+ * **Nothing paired the agent.** Even a started agent could fetch no offers, which looks identical to
36
+ a fleet with no work.
37
+
38
+ ### Now
39
+
40
+ `startLocalStack` passes the option names `createAgent` actually documents, loads the identity from
41
+ the **same file the CLI uses** — so one machine has one identity whether the agent runs in-process
42
+ or as its own process, rather than each minting a keypair and overwriting the other's announcement
43
+ under a shared `machine_id` (the v0.6.2 defect, in a new place) — and **pairs by the account fleet
44
+ key**, falling back to a pairing code.
45
+
46
+ Pairing is attempted **before** `agent.start()`, and a failure is **named at warn level** rather than
47
+ swallowed: an agent that failed to pair runs and does nothing, which is indistinguishable from a
48
+ quiet fleet.
49
+
50
+ ### Config
51
+
52
+ `fleetKey` is read from the config layer or `W2M_FLEET_KEY`. The key must be **handed down** rather
53
+ than computed where it is used, because a standalone agent cannot read the signed-in account — which
54
+ is why it comes from the host.
55
+
56
+ ### Verified
57
+
58
+ **132 cases** across the tools, apply and fleet-join suites. `tools.mjs` loads. The 12 agent-suite
59
+ failures remain pre-existing and environmental, measured against an untouched clone of the same
60
+ commit.
61
+
62
+ ### ⚠️ Not yet proven on real hardware
63
+
64
+ The wiring is fixed and the mechanism was verified end to end in v0.8.2, but **no machine has yet
65
+ joined a fleet through this path in a live run**. That is the next check, and it needs a machine
66
+ that is not already paired.
67
+
68
+ ## [0.8.2] — 2026-10-14
69
+
70
+ **The client half of the auto-join.** v0.8.1 landed the credential and the relay's acceptance of
71
+ it; this is the wiring that makes a real machine use them.
72
+
73
+ ### What is now connected
74
+
75
+ | piece | what it does |
76
+ |---|---|
77
+ | `account.mjs` | derives the fleet key on **every** resolve, including the stored-SN path |
78
+ | `index.mjs` | exposes it as `fleetKey` — the key, never the account id |
79
+ | `agent.mjs` | `pair(code, userId, fleetKey)`, sending only the discriminator that was given |
80
+ | `w2m-localside` | `--fleet-key`, a peer of `--pair` |
81
+ | relay | derives the fleet from the key; never believes a claimed one |
82
+
83
+ **Verified end to end:** an agent given **only** `--fleet-key`, against a relay with **no fleet of
84
+ its own**, pairs and lands in the account-derived fleet (`d5bb1b9ddf3faad4`, `source=fleet_key`).
85
+
86
+ ### The bug that would have made it work exactly once
87
+
88
+ `resolve()` returned early when an SN was already stored and never read the account at all. That is
89
+ right for the SN — it must not be regenerated, or every restart looks like a new device — and
90
+ **wrong for the key**: a machine has a stored SN from its *second* start onward, so it would have
91
+ derived a fleet key once and never again.
92
+
93
+ **The auto-join would have worked on a machine's first boot and silently stopped afterwards** — the
94
+ hardest kind of bug to see, because it works when you test it. Found by asking what the stored-SN
95
+ path actually does, rather than by reading the happy path.
96
+
97
+ ### The bug in this release's own patch
98
+
99
+ The fleet-key handling was first inserted **inside `if (flags.pair)`**, so the key path could only
100
+ run when a pairing code was also supplied — precisely the case it exists to avoid. **It worked
101
+ whenever anyone tested it with a code, and did nothing in the case that mattered.** The end-to-end
102
+ test is what caught it, not review.
103
+
104
+ ### A provenance bug, also caught by testing
105
+
106
+ A device that paired by key was recorded as `fleet_source: 'pairing_code'`, because the relay builds
107
+ a synthetic code record so the binding reads from one place. That matters: `fleet_source` is what an
108
+ operator reads in `devices.json` to answer *"how did this machine get into my fleet?"*, and
109
+ answering "a pairing code" when there was none sends them looking for a code never issued.
110
+
111
+ ### Also
112
+
113
+ `HANDOFF.md` records the state, the single reachable path to the Mac, and the traps this codebase
114
+ has already fallen into.
115
+
116
+ ### Verified
117
+
118
+ **585 chatops cases pass.** The 12 agent-suite failures are **pre-existing and environmental** —
119
+ measured against an untouched clone of the same commit, both give **147 tests / 133 pass / 12 fail /
120
+ 2 skipped**, identical, so this change is not the cause.
121
+
122
+ ### ⚠️ Still not user-facing
123
+
124
+ **The plugin does not yet spawn the agent or hand it the key**, so with the default configuration a
125
+ new PC still will not auto-join. The mechanism is complete and verified; that last wire is what
126
+ makes it something a user experiences rather than something a test proves.
127
+
128
+ ## [0.8.1] — 2026-10-14
129
+
130
+ **A machine on the same account joins the same fleet with no human step** — the requirement that
131
+ had no implementation behind it, and the bug that made it visible.
132
+
133
+ ### The reported bug, and what it actually was
134
+
135
+ A new PC signed into the same DeepSeek account did **not** join the same fleet. Traced rather than
136
+ guessed, the cause was four gaps stacked:
137
+
138
+ * the plugin computed its SN, derived `fleet` from the account code, **logged it, and never used it
139
+ for anything**;
140
+ * nothing called `/v1/pair`;
141
+ * `autoStartAgent` defaults to **false**, so no agent started;
142
+ * and even with an agent, membership needed a **human-typed one-time pairing code**.
143
+
144
+ So "same account joins the same fleet automatically" was a stated requirement with no code path.
145
+
146
+ ### Why the obvious fix is the wrong one
147
+
148
+ The obvious fix is to use `accountCode` as the credential. `account.mjs` documents the trade
149
+ honestly — *"the cost of 32 bits, stated rather than hidden"* — and for **grouping** 32 bits is
150
+ fine. As a **secret** it is not: 2^32 is brute-forceable, so authorising a join with it would let
151
+ anyone enumerate their way into any fleet.
152
+
153
+ **The bug was a conflation: a grouping identifier was doing duty as a secret.**
154
+
155
+ ### The fix
156
+
157
+ The machines of one account share something an outsider cannot obtain — the ability to read
158
+ `userId` — so the fleet secret is derived from it:
159
+
160
+ ```
161
+ fleetKey = HMAC-SHA256(userId, "w2m-fleet-v1") 256 bits, same-account machines only
162
+ fleetId = SHA-256(fleetKey) what the relay groups by
163
+ ```
164
+
165
+ **Presenting the key is the proof.** The relay *computes* a fleet id from the key it is given
166
+ rather than believing a claimed one, so a device can only land in the fleet whose key it holds.
167
+ No trust-on-first-use, no operator configuration, no account lookup, nothing to forge — and a
168
+ device without the key forms a different, empty fleet instead of joining someone else's.
169
+
170
+ `deriveAccountFleetKey` **refuses anything shorter than 16 characters**, and the refusal is asserted
171
+ in a test rather than left to a comment: passing the 8-hex account code by mistake would otherwise
172
+ produce a key that *looks* strong and is not — the worst kind of credential failure, because it is
173
+ silent.
174
+
175
+ ### The relay side
176
+
177
+ `deviceFleetFields` gains a third source, ordered by authority:
178
+
179
+ | source | who decided |
180
+ |---|---|
181
+ | `pairing_code` | the operator, by issuing the code |
182
+ | **`fleet_key`** | **the account, by derivation** — the auto-join path |
183
+ | `request` | the relay operator, via `--fleet` |
184
+
185
+ A **malformed key does not fall through** to the relay's own fleet. Falling through would be the
186
+ silent wrong answer: a device that meant to join its account would be placed in an unrelated fleet
187
+ and told nothing. It is refused, with the reason recorded on the device record.
188
+
189
+ This also removes the deployment problem recorded in v0.7.9: `--fleet` no longer needs an operator
190
+ to compute and hand-carry a 32-bit code, because the relay needs to know **nothing** about any
191
+ account.
192
+
193
+ ### Verified
194
+
195
+ * **21 cases** for the derivation: same account agrees, different accounts differ, the 32-bit code
196
+ is refused, the wire form round-trips, a client and a relay independently derive the same id, and
197
+ comparison is constant-time.
198
+ * The derived bytes are **pinned**, not self-compared — a regression would silently split every
199
+ existing fleet into two halves that cannot see each other, with no error anywhere.
200
+ * **223 cases** across the relay, wiring, scoping and join suites, unchanged.
201
+
202
+ ### ⚠️ What is still not wired
203
+
204
+ The **derivation and the relay's acceptance** are done. The pieces that make a real machine use
205
+ them are not:
206
+
207
+ * `index.mjs` does not yet derive or expose the fleet key;
208
+ * the agent does not yet present it at pairing;
209
+ * `autoStartAgent` still defaults to false.
210
+
211
+ **Until those land, a new PC still will not auto-join.** The credential exists and is accepted; the
212
+ client does not yet offer it.
213
+
7
214
  ## [0.8.0] — 2026-10-14
8
215
 
9
216
  **The first release on npm since 0.6.3.** Eight versions of work — account identity, fleet
package/HANDOFF.md ADDED
@@ -0,0 +1,173 @@
1
+ # Handoff — where this stands, and what to do next
2
+
3
+ Written at the point of switching development from the Windows machine to the Mac. Everything here
4
+ is **committed and pushed**; nothing depends on the machine that wrote it.
5
+
6
+ ---
7
+
8
+ ## The Mac is reachable by exactly one path
9
+
10
+ Discovered the hard way — four blind attempts failed first, each diagnosed by encoding state into
11
+ an **exit code**, because `unverifiable` tasks do not return their stdout and that is the only
12
+ readable channel from that machine:
13
+
14
+ | target | from the Mac |
15
+ |---|---|
16
+ | `raw.githubusercontent.com` | ❌ |
17
+ | `github.com` release tarball | ❌ |
18
+ | `http://202.182.123.154:8899/pub` | ❌ |
19
+ | `http://202.182.123.154:8787` | ❌ |
20
+ | **`http://geefish.cn/pub/...`** | ✅ **the only one** |
21
+
22
+ Port 80, behind the nginx that already fronts the relay. A `location ^~ /pub/` was added to that
23
+ same server block, so releases travel the one path already proven to work. The nginx change is
24
+ tested with `nginx -t` **before** loading and restores its backup on any failure, because a broken
25
+ config there takes the whole fleet down, not just a download.
26
+
27
+ ## Updating the Mac
28
+
29
+ ```bash
30
+ curl -fsSL http://geefish.cn/pub/mac-self-update.mjs -o ~/w2m-self-update.mjs
31
+ curl -fsSL http://geefish.cn/pub/w2m-0.8.2.tgz -o ~/w2m-0.8.2.tgz
32
+ node ~/w2m-self-update.mjs --tarball ~/w2m-0.8.2.tgz \
33
+ --sha256 8ff593845612a4699c11c03ab22bbd81afa02f93b865d2e444badc8b62cfb60a
34
+ ```
35
+
36
+ `--sha256` is not optional in spirit: the supervisor **refuses** a tarball whose checksum differs,
37
+ before it touches anything.
38
+
39
+ The supervisor is **detached** on purpose — it has to survive the DSH it restarts. Seven steps,
40
+ ordered so the previous state is restorable **without the network**: verify the checksum, back up in
41
+ place, check the staged tree for its entry point, swap, restart, self-check, and **roll back
42
+ automatically** on failure. Every step is appended to `~/w2m-self-update-report.json` as it happens,
43
+ so a process killed mid-way still says where it stopped.
44
+
45
+ ---
46
+
47
+ ## Current version, everywhere
48
+
49
+ | machine | version |
50
+ |---|---|
51
+ | Windows | 0.8.1 |
52
+ | **Linux relay host** | **0.8.2** |
53
+ | **Mac** | **0.8.0 — needs the update above** |
54
+ | npm | 0.8.0 |
55
+ | GitHub | v0.8.0 released; 0.8.1 and 0.8.2 committed, **no release tag yet** |
56
+
57
+ ---
58
+
59
+ ## What was fixed, and the bug that is now closed
60
+
61
+ **The reported bug:** a new PC signed into the same DeepSeek account did not join the same fleet.
62
+
63
+ **The cause, traced rather than guessed** — four gaps stacked:
64
+
65
+ 1. the plugin computed its SN, derived `fleet` from the account code, **logged it, and never used
66
+ it for anything**;
67
+ 2. nothing called `/v1/pair`;
68
+ 3. `autoStartAgent` defaults to **false**;
69
+ 4. and even with an agent, membership needed a **human-typed one-time pairing code**.
70
+
71
+ So *"same account joins the same fleet automatically"* was a requirement with **no code path**.
72
+
73
+ **Why the obvious fix would have been wrong:** using `accountCode` as the credential. It is
74
+ `sha256(userId)` truncated to **32 bits** — documented as fine for *grouping*, brute-forceable as a
75
+ *secret*. The bug was a **conflation: a grouping identifier doing duty as a secret.**
76
+
77
+ **The fix** derives the secret from what same-account machines share and outsiders cannot obtain —
78
+ the ability to read `userId`:
79
+
80
+ ```
81
+ fleetKey = HMAC-SHA256(userId, "w2m-fleet-v1") 256 bits
82
+ fleetId = SHA-256(fleetKey) what the relay groups by
83
+ ```
84
+
85
+ **Presenting the key is the proof.** The relay *computes* a fleet id from the key rather than
86
+ believing a claimed one, so a device can only land in the fleet whose key it holds. No
87
+ trust-on-first-use, no operator configuration, no account lookup, nothing to forge — and a device
88
+ without the key forms a different, empty fleet rather than joining someone else's.
89
+
90
+ This also removes the deployment problem recorded in v0.7.9: **`--fleet` no longer needs an operator
91
+ to compute and hand-carry a 32-bit code**, because the relay needs to know *nothing* about any
92
+ account.
93
+
94
+ **Verified end to end:** an agent given **only** `--fleet-key`, against a relay with **no fleet of
95
+ its own**, pairs and lands in the account-derived fleet (`d5bb1b9ddf3faad4`, `source=fleet_key`).
96
+
97
+ ### The bug that would have made it work exactly once
98
+
99
+ `resolve()` returned early when an SN was already stored and never read the account at all. Right
100
+ for the SN — it must not be regenerated, or every restart looks like a new device — and **wrong for
101
+ the key**: a machine has a stored SN from its *second* start onward, so it would have derived a key
102
+ once and never again.
103
+
104
+ **The auto-join would have worked on a machine's first boot and silently stopped afterwards** — the
105
+ hardest kind of bug to see, because it works when you test it. Found by asking what the stored-SN
106
+ path actually does, not by reading the happy path.
107
+
108
+ ---
109
+
110
+ ## What is wired, and the one thing that is not
111
+
112
+ | piece | state |
113
+ |---|---|
114
+ | `account.mjs` derives the key on **every** resolve, both paths | ✅ |
115
+ | `index.mjs` exposes it as `fleetKey` (the key, never the account id) | ✅ |
116
+ | `agent.mjs` `pair(code, userId, fleetKey)` | ✅ |
117
+ | `w2m-localside --fleet-key` | ✅ |
118
+ | relay derives the fleet from the key | ✅ |
119
+ | **the plugin spawns the agent and passes its key** | ❌ **not wired** |
120
+
121
+ **So with the default configuration a new PC still will not auto-join.** The mechanism is complete
122
+ and verified; the plugin does not yet start the agent or hand it the key. Until that lands, either:
123
+
124
+ ```bash
125
+ w2m-localside --rabbit http://geefish.cn --fleet-key <KEY> --project <dir> --name <name> --state <dir>
126
+ ```
127
+
128
+ or set `autoStartAgent` and wire the key through.
129
+
130
+ **This is the next task.** It is also the last one: after it, installing the plugin and signing into
131
+ the same account is the whole of the user-facing procedure.
132
+
133
+ ---
134
+
135
+ ## Traps this codebase has already fallen into — worth knowing before editing
136
+
137
+ Each of these cost a debugging round, and most were **silent**:
138
+
139
+ * **A presence check reads `undefined` as "provided".** `createILinkChannel` passed
140
+ `{ fetch: undefined }` and threw on mount (v0.7.0). The same shape appears in the pairing body:
141
+ an absent code must be an **absent key**, not a `null` one.
142
+ * **An anchor in a `patch` file is an assertion, not a rename.** A mismatched `name:` makes the
143
+ Loader skip the whole patch *silently*.
144
+ * **`statSync` on an injected path defeats the injection.** The secrets guard called it
145
+ unconditionally, so an injected reader was never reached and the scan reported **zero findings
146
+ across zero files** — a guard that is silently off.
147
+ * **`createHmac('sha256', Buffer.alloc(0))` is not `createHash('sha256')`.** A signature over the
148
+ wrong bytes verifies nowhere and says nothing about why.
149
+ * **A test that hard-codes a rule name measures the name, not the behaviour** — renaming broke it.
150
+ * **`login` confirms as `confirmed`, not `scaned`.** Waiting on `scaned` misses the token entirely.
151
+ * **The relay needs two credentials.** The operator token dispatches; the **device token** reads the
152
+ verdict. Supplying one gives a *confident wrong answer* — `判定:unknown` — rather than an error.
153
+
154
+ ## Reading the Mac without stdout
155
+
156
+ Exit codes are the only channel. Encode state into them and probe:
157
+
158
+ ```js
159
+ // on the Mac, via node -e
160
+ process.exit(tgzSize < 700000 ? 10 : sup.done ? (sup.ok ? 0 : 13) : 12);
161
+ ```
162
+
163
+ ## Ground rules that have held
164
+
165
+ * `deploy/server/` holds the units **as the live host runs them**; the deploy script's step order
166
+ *is* the safety argument.
167
+ * `/var/lib/w2m/rabbit/` — `operator-token.txt` and the ledger — **must never be committed**.
168
+ `.gitignore` refuses the paths; `scripts/scan-for-secrets.mjs` reads the **content** of every
169
+ tracked file, because a `.gitignore` refuses a path while the ways a credential escapes are
170
+ pasting one into a source file, a fixture, or an example.
171
+ * **A deploy cannot be an outage.** The relay runs **unscoped**, so its four devices keep working.
172
+ Scoping it requires an account code and a re-pair of every device.
173
+ * **Never publish to npm without the token**, and rotate it: it has been pasted into a chat log.