@twinsearth/w2m-dsh-plugin 0.5.1 → 0.5.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,39 @@ 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.5.2] — 2026-10-10
8
+
9
+ ### Fixed
10
+
11
+ * **The supervisor now says what it is doing, in a file.** A death that arrives with the console
12
+ cannot be logged from inside the process it kills — the log simply stops — so the supervisor
13
+ records `supervisor-heartbeat.json` (written atomically) with its state (`running` / `backoff` /
14
+ `degraded` / `stopped`), the child pid, restarts within ten minutes, the last exit code, and a note
15
+ that `3221225786` is `0xC000013A` rather than a crash of the agent logic. After three exits inside
16
+ ten minutes the state becomes `degraded` and the backoff grows to five minutes: not "give up", but
17
+ "stop hammering and say so", so the fleet sees a machine that is present and unwell instead of one
18
+ that vanished.
19
+ * **Orphan agents are reaped at start.** Detaching the agent (v0.5.1) is what stops a console
20
+ teardown from taking the machine off the fleet, and it leaked: three agents with one identity were
21
+ found running at once, the newest unable to bind the punch port (`P2P_BIND_FAILED: EADDRINUSE
22
+ 0.0.0.0:41235`) because an orphan held it, while `MultipleInstances IgnoreNew` kept a fresh
23
+ supervisor from starting at all. The heartbeat names the last child, so a new supervisor terminates
24
+ that pid before it spawns anything.
25
+
26
+ ### Added
27
+
28
+ * **`docs/AUDIT.md`** — what is implemented, what is running and how it works, with every claim
29
+ labelled `[M]` measured live, `[T]` asserted by the suites, or `[?]` **not verified**. It records
30
+ the uncomfortable half on purpose: A and B have no live proof yet, the macOS machine's state is
31
+ unconfirmed, the WireGuard hub has never seen a handshake, the session-0 service has never been
32
+ executed, and the link from the Windows machine to the relay is intermittently down. Its last
33
+ section is the commands to re-run each check, so the next reader need not trust the snapshot.
34
+
35
+ ### Notes
36
+
37
+ * This release exists because `deploy/` and `docs/` are inside the published package: after v0.5.1 the
38
+ tag and the tarball had drifted, and a release is the only honest way to close that.
39
+ * No runtime change to the eight tools.
7
40
  ## [0.5.1] — 2026-10-10
8
41
 
9
42
  ### Fixed
@@ -35,7 +35,7 @@
35
35
  //
36
36
  // Nothing here is Windows-specific except the defaults; the same supervisor works on POSIX hosts.
37
37
  import { spawn } from 'node:child_process';
38
- import { createWriteStream, existsSync, mkdirSync, openSync, readFileSync } from 'node:fs';
38
+ import { createWriteStream, existsSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
39
39
  import { dirname, join } from 'node:path';
40
40
 
41
41
  /** Minimal `--flag value` / `--flag` parser: no dependency, no surprises. */
@@ -176,7 +176,108 @@ for (const signal of ['SIGINT', 'SIGTERM', 'SIGBREAK']) {
176
176
  });
177
177
  }
178
178
 
179
+ // Declared before the heartbeat block, which reads it: a `let` referenced earlier in the same scope
180
+ // would be a temporal-dead-zone throw, not an undefined value.
179
181
  let runs = 0;
182
+
183
+ // ---------------------------------------------------------------------------------------------
184
+ // HEARTBEAT AND DEGRADED MARKER
185
+ //
186
+ // WHY FILES AND NOT LOG LINES: the failure this exists for is a console-teardown death, and a death
187
+ // that arrives with the console cannot be logged from inside the process it kills -- the log simply
188
+ // stops. A file that an outside observer reads has no such dependency, so a watchdog task, a human,
189
+ // or (later) the fleet can answer "is this machine working?" without parsing a log that may itself be
190
+ // truncated.
191
+ //
192
+ // The state is one of:
193
+ // running a worker is alive right now
194
+ // backoff between runs, within the healthy band
195
+ // degraded the crash-loop breaker has tripped: too many exits inside the window, so the fleet
196
+ // should see a machine that is present but unhealthy rather than one that is gone
197
+ //
198
+ // Written atomically (temp file + rename) so a reader never sees half a JSON document.
199
+ // ---------------------------------------------------------------------------------------------
200
+ const heartbeatPath = join(dirname(logPath), 'supervisor-heartbeat.json');
201
+ const degradedAfter = Number.isFinite(Number(pick('degraded-after', 3))) ? Number(pick('degraded-after', 3)) : 3;
202
+ const degradedWindowMs = 10 * 60_000;
203
+ const degradedBackoffMs = Number.isFinite(Number(pick('degraded-backoff-ms', 300_000)))
204
+ ? Number(pick('degraded-backoff-ms', 300_000))
205
+ : 300_000;
206
+ const exitTimes = [];
207
+
208
+ function writeHeartbeat(patch) {
209
+ const recent = exitTimes.filter((at) => Date.now() - at < degradedWindowMs).length;
210
+ const state = patch.state ?? (recent >= degradedAfter ? 'degraded' : 'backoff');
211
+ const record = {
212
+ updated_at: stamp(),
213
+ state,
214
+ supervisor_pid: process.pid,
215
+ run: patch.run ?? runs,
216
+ child_pid: patch.child_pid ?? null,
217
+ child_started_at: patch.child_started_at ?? null,
218
+ restarts_total: Math.max(0, runs - 1),
219
+ restarts_within_10min: recent,
220
+ last_exit_code: patch.last_exit_code ?? null,
221
+ last_exit_at: patch.last_exit_at ?? null,
222
+ // The known killer, recorded in decimal because that is how Node reports it: 3221225786 is
223
+ // 0xC000013A, STATUS_CONTROL_C_EXIT. Signed, it is -1073741510.
224
+ note: 'exit code 3221225786 means the console went away; it is not a crash of the agent logic',
225
+ project,
226
+ rabbit,
227
+ };
228
+ try {
229
+ const tmp = `${heartbeatPath}.tmp`;
230
+ writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
231
+ renameSync(tmp, heartbeatPath);
232
+ } catch (error) {
233
+ say(`[w2m-supervisor] could not write the heartbeat: ${error.message}`);
234
+ }
235
+ return record;
236
+ }
237
+
238
+ writeHeartbeat({ state: 'backoff' });
239
+ // ---------------------------------------------------------------------------------------------
240
+ // START-TIME REAPING -- the cost of detaching the agent, paid here
241
+ //
242
+ // Detaching the agent is what stops a console teardown from taking the machine off the fleet, and it
243
+ // has a price that was measured: every supervisor generation that dies without stopping its child
244
+ // leaves an orphan behind. Three agents with one identity were found running at once, the newest
245
+ // unable to bind the punch port (`P2P_BIND_FAILED: EADDRINUSE 0.0.0.0:41235`) because an orphan held
246
+ // it -- two streams on one device token, and a port that pins nothing.
247
+ //
248
+ // So the supervisor reaps what it did not start: the heartbeat names the last child, and if that pid
249
+ // is still alive when a new supervisor starts, it is a previous generation's orphan and is terminated
250
+ // before anything else runs. "Trust a graceful exit" is exactly the assumption that failed.
251
+ // ---------------------------------------------------------------------------------------------
252
+ try {
253
+ const previous = JSON.parse(readFileSync(heartbeatPath, 'utf8').replace(/^\uFEFF/, ''));
254
+ const orphan = Number(previous.child_pid);
255
+ if (Number.isInteger(orphan) && orphan > 0 && orphan !== process.pid) {
256
+ let alive = true;
257
+ try {
258
+ process.kill(orphan, 0);
259
+ } catch {
260
+ alive = false;
261
+ }
262
+ if (alive) {
263
+ try {
264
+ process.kill(orphan);
265
+ say(`[w2m-supervisor] reaped orphan agent pid=${orphan} left by an earlier supervisor`);
266
+ await new Promise((resolve) => {
267
+ setTimeout(resolve, 1500);
268
+ });
269
+ } catch (error) {
270
+ say(`[w2m-supervisor] could not reap orphan pid=${orphan}: ${error.message}`);
271
+ }
272
+ }
273
+ }
274
+ } catch {
275
+ // No heartbeat yet, or unreadable: nothing to reap, and that is not an error worth stopping for.
276
+ }
277
+ // A periodic beat, so "the supervisor itself is alive but a worker is wedged" is visible too.
278
+ const beat = setInterval(() => writeHeartbeat({}), 30_000);
279
+ beat.unref();
280
+
180
281
  while (!stopping) {
181
282
  runs += 1;
182
283
  const startedAt = Date.now();
@@ -208,6 +309,7 @@ while (!stopping) {
208
309
  stdio: ['ignore', logFd, logFd],
209
310
  windowsHide: true,
210
311
  });
312
+ writeHeartbeat({ state: 'running', child_pid: child.pid ?? null, child_started_at: stamp() });
211
313
 
212
314
  const result = await new Promise((resolve) => {
213
315
  child.on('exit', (exitCode, signal) => resolve({ exitCode, signal }));
@@ -216,6 +318,7 @@ while (!stopping) {
216
318
  resolve({ exitCode: null, signal: null });
217
319
  });
218
320
  });
321
+ exitTimes.push(Date.now());
219
322
 
220
323
  const lived = Math.round((Date.now() - startedAt) / 1000);
221
324
  say(
@@ -223,10 +326,22 @@ while (!stopping) {
223
326
  `${stopping ? ' (stopping)' : ' -- restarting'}`,
224
327
  );
225
328
  if (stopping) break;
329
+ const record = writeHeartbeat({ last_exit_code: result.exitCode, last_exit_at: stamp() });
330
+ // Degraded is not "give up": it is "stop hammering and say so". The marker file is what makes the
331
+ // difference visible from outside -- the fleet can then see a machine that is present and unwell
332
+ // instead of one that silently vanished, which is the failure this whole file exists for.
333
+ const wait = record.state === 'degraded' ? degradedBackoffMs : backoffMs;
334
+ if (record.state === 'degraded') {
335
+ say(
336
+ `[w2m-supervisor] degraded: ${record.restarts_within_10min} exits within 10 minutes -- ` +
337
+ `backing off ${Math.round(wait / 1000)}s and marking ${heartbeatPath}`,
338
+ );
339
+ }
226
340
  // Deliberately NOT unref'ed: an unref'ed timer with nothing else pending lets Node exit during the
227
341
  // backoff, which is the silent death this loop exists to prevent.
228
342
  await new Promise((resolve) => {
229
- setTimeout(resolve, backoffMs);
343
+ setTimeout(resolve, wait);
230
344
  });
231
345
  }
346
+ writeHeartbeat({ state: 'stopped' });
232
347
  log.end();
package/docs/AUDIT.md ADDED
@@ -0,0 +1,130 @@
1
+ # Audit — what is implemented, what is running, and how it works
2
+
3
+ Written 2026-10-10 (UTC) against `main` at `460c437`, plus the live behaviour observed during the
4
+ session that built v0.4.1 … v0.5.1. Every claim is labelled with how it is known, because an audit
5
+ whose evidence cannot be told apart from its assumptions is worse than no audit:
6
+
7
+ * **[M]** measured live on the three-machine fleet during the session,
8
+ * **[T]** asserted by the repository's own test suites or CI,
9
+ * **[?]** **not verified** — stated as an open item rather than assumed.
10
+
11
+ Re-run the checks in §5 rather than trusting this file; it records a moment, not a guarantee.
12
+
13
+ ## 1. Designed features: implemented?
14
+
15
+ | Designed feature | State | Evidence |
16
+ |---|---|---|
17
+ | Eight DSH tools (`w2m_devices`, `run`, `wait`, `report`, `status`, `update`, `history`, `stats`) | ✅ | **[T]** `scripts/verify-installed.mjs` on the packed artifact (`PASS: 8 tools registered`), plus a test that every tool returns a **string** (the host refuses anything else) |
18
+ | Four modes: replicate / split / broadcast / pipeline | ✅ | **[M]** replicate across three machines; **[T]** dedicated suites for the others |
19
+ | Nine aggregate verdicts | ✅ | **[M]** `consistent`, `divergent-platform`, `partial` observed live; **[T]** the rest covered by `test/relay`, `broadcast`, `recovery` |
20
+ | Anchor comparability (`base_commit` + `git-temp-index-tree/v1`) | ✅ | **[M]** Windows and Linux produced the **same** fingerprint `d6d39082…` for commit `a1e7dc0` — cross-platform comparison is real, not assumed |
21
+ | Default-deny allow-list, token-wise prefix match | ✅ | **[M]** allowed and refused commands observed on two machines; v0.4.1 unified the plugin's pre-flight with the agent's matcher |
22
+ | P2P direct path (punch / accept / dial, reliable fragmented channel) | ✅ | **[M]** `transport: p2p` between Windows and the Tokyo server, repeatedly |
23
+ | **A** simultaneous two-way punch, **B** reverse dial for results | ✅ code | **[T]** +17 tests, all passing (loopback). **[?]** **no live proof yet** — the fleet has not run this build on all three machines at once |
24
+ | Pinned punch port (`--p2p-port`) | ✅ | **[M]** `0.0.0.0:41235` bound on Windows after pinning |
25
+ | Windows deployment without elevation: supervisor + self-healing trigger | ✅ | **[M]** killing the supervisor left the agent alive with its socket bound; `every=PT5M` trigger registered |
26
+ | Supervisor heartbeat file + `degraded` marker + orphan reaping | ✅ | **[M]** marker reaches `degraded` after three exits; a clean start leaves exactly one supervisor and one agent, heartbeat `running` |
27
+ | Session-0 service mode (fully immune to console teardown) | ✅ script | **[?]** requires one elevation; **not executed** |
28
+ | **C** self-hosted WireGuard hub | ✅ server side | **[M]** `wg0` active on `:51820`, two peers configured. **[?]** no `latest handshake` has ever appeared: **no client has connected**, so rung 4 is not end-to-end |
29
+ | Delivery-ladder documentation + acceptance script | ✅ | **[M]** `deploy/networking/acceptance.mjs` ran against the live fleet; baseline recorded (all three machines on rung 5 for a relay-dispatched task) |
30
+ | npm publication with Trusted Publishing | ✅ | **[M]** `@twinsearth/w2m-dsh-plugin@0.5.1`, latest, with a SLSA provenance attestation |
31
+ | dsh-plugin.org listing | ✅ | **[M]** listed and marked *Verified*; the page's own snapshot is stale (submission issue #141 asks for a re-scan) |
32
+ | TUN/VPN mode | ❌ **deliberately not built** | Decision and reasoning in `deploy/networking/LESSONS.md`: a tunnel is hub-and-spoke, so it would make "the data path is peer-to-peer" false for exactly the traffic a tunnel exists to carry |
33
+
34
+ **Also not built, and known:** the ladder is not yet ordered *data* with per-rung budgets; the
35
+ plugin has no `p2pPort` of its own (only the agent CLI needs one, since a dispatcher only dials out).
36
+
37
+ ## 2. Modules: running, and correct?
38
+
39
+ | Module | State | Notes |
40
+ |---|---|---|
41
+ | Relay `w2m-rabbit` (`:8787`) | ✅ active | **[M]** three devices tracked, ledger persisted across a restart (`revived_devices`) |
42
+ | STUN (`:3478/udp`) | ✅ active | |
43
+ | Localside `jp-shared` (Tokyo) | ✅ v0.5.x, streaming | Re-deployed from the GitHub release asset; sha256 matched the locally built tarball byte for byte |
44
+ | Localside `win-desktop` (this machine) | ✅ supervisor + agent, socket `41235`, heartbeat `running` | **[M]** after reaping three orphans that a previous generation had left behind |
45
+ | Localside `macmini` | **[?]** version and liveness unconfirmed | Four-line self-check for its owner is in `deploy/networking/README.md`'s section on rungs; it previously showed *online but not accepting work* (`expired` leases) |
46
+ | DSH plugin in this profile | ✅ v0.5.0 installed, eight tools live | Configuration is picked up by hot reload, verified via `w2m_status` twice |
47
+ | CI / Release / npm | ✅ | CI green on four platforms; Release #31 green; npm publish automatic via OIDC |
48
+ | v2ray-agent (same server, unrelated to this project) | ✅ xray active | TLS entry on **556** with live `accepted` log lines; nothing listens on 443, which that configuration does not need |
49
+ | WireGuard `wg0` | ✅ server, ❌ clients | No handshake yet (§1) |
50
+ | **Known environmental blocker** | ⚠️ | The link from this machine (an iPhone hotspot behind carrier-grade NAT) to Tokyo is intermittently unreachable — HTTP and SSH together. It takes the Windows machine off the fleet while it lasts, and it is the reason several checks above are marked **[?]** |
51
+
52
+ **Known defect classes found and fixed during the session, recorded because they are the kind that
53
+ recur:** the plugin's dispatch pre-flight could never match a multi-token allow-list entry; the punch
54
+ port could not be pinned; `w2m_update` returned an object where the host requires a string; the
55
+ Windows supervisor silently dropped every `-AgentArgs` value; a test read the punch port out of a
56
+ timestamp; and detaching the agent (which fixed the console-teardown death) leaked orphan agents.
57
+ Each is fixed with a test or a verification, and each is described in `CHANGELOG.md` under its
58
+ version.
59
+
60
+ ## 3. How it works
61
+
62
+ ```
63
+ DSH plugin (any machine) Relay (the shared server) Localside agent (each machine)
64
+ eight tools ──HTTPS────▶ /v1/task · leases · ledger · SSE ──▶ executes argv within the allow-list
65
+ │ ▲ │
66
+ └──── UDP hole punch: task offer + result ──────────────────────────────┘
67
+ └─ when no direct path opens, the relay carries it
68
+ ```
69
+
70
+ **Control plane is centralised, data plane tries to be direct, and every task records which path it
71
+ took.** That sentence is the whole design; everything below is mechanism.
72
+
73
+ * **Two credentials, deliberately split.** The operator token authorises *dispatching* work; a
74
+ per-machine device token authorises *taking* and *reporting* it. Measured: reading a task back with
75
+ the operator token answers `401 missing or invalid device_token`. The credential that can order work
76
+ around cannot also read every machine's results.
77
+ * **Anchors make "same revision" provable.** The dispatcher fingerprints its work tree
78
+ (`git-temp-index-tree/v1`); each machine must reproduce it or the comparison is refused as
79
+ `unverifiable`. A peer that is merely on a different commit cannot produce a misleading "the results
80
+ differ".
81
+ * **The delivery ladder, recorded rung by rung.** A live channel, then a simultaneous two-way punch
82
+ (**A**), then a reverse dial for the result (**B**), then a tunnel (**C**), then the relay — with
83
+ `offer_path`, `result_path`, `p2p.opened_by` and per-rung reason codes saying which one served the
84
+ command and why the ones above it did not.
85
+ * **Verdicts are computed in a fixed order** (§6.3 of the protocol): refusal, then anchors, then
86
+ execution, then comparison. `divergent-platform` exists so that "the toolchains differ" is never
87
+ reported as "the results disagree".
88
+ * **Identity and state** live in files, not in a process: `device.json` per machine, an agent state
89
+ directory (spool and cursor), and the relay's `devices.json` + `ledger.jsonl`, which is why a relay
90
+ restart does not lose the fleet.
91
+ * **The eight tools are the interface.** A model calls `w2m_devices` → `w2m_run` → `w2m_wait` →
92
+ `w2m_report`; nothing about the transport is required knowledge to use it, and nothing about the
93
+ transport is hidden once you ask (`w2m_status`, `w2m_history`, the aggregate's `transport`).
94
+
95
+ ## 4. Open items, stated plainly
96
+
97
+ 1. **A/B have no live proof yet.** They are implemented and unit-tested (loopback crosses no NAT), but
98
+ the three-machine measurement needs all machines on this build at once.
99
+ 2. **The MAC's state is unverified**, and one earlier episode had it online but not accepting work.
100
+ 3. **WireGuard rung 4 is not end-to-end**: hub yes, clients no. When it is, the honest label is
101
+ "direct socket over a tunnel the VPS forwards" — not "the bytes avoided a server".
102
+ 4. **The session-0 service is unexecuted**; the interactive task remains vulnerable to sign-out, and
103
+ its self-healing window is up to five minutes.
104
+ 5. **The relay link from this machine is unreliable**, which is an environmental fact about a hotspot
105
+ behind carrier-grade NAT, not a property of this project — and it is precisely why A/B and C exist.
106
+ 6. `deploy/` and `docs/` are inside the published package, so any change to them owes a release for
107
+ tag/artifact consistency; 0.5.2 is owed at the time of writing (this file included).
108
+
109
+ ## 5. How to re-run this audit
110
+
111
+ ```bash
112
+ # the eight tools exist in the packed artifact
113
+ node scripts/verify-installed.mjs "$(mktemp -d)" # after extracting dist/*.tgz into it
114
+
115
+ # the suites this project gates on (see release.yml for the authoritative list)
116
+ node --test --test-force-exit test/p2p-*.test.mjs test/agent.test.mjs test/tools.test.mjs \
117
+ test/e2e.test.mjs test/crossnetwork.test.mjs test/recovery.test.mjs test/outbound-only.test.mjs
118
+
119
+ # what path did a real dispatch take, per machine
120
+ node deploy/networking/acceptance.mjs --rabbit http://202.182.123.154:8787 \
121
+ --token <operator token> --project <fleet checkout> --command 'git rev-parse HEAD'
122
+
123
+ # the Windows side, locally
124
+ Get-ScheduledTask -TaskName 'W2M Localside agent' | Get-ScheduledTaskInfo
125
+ Get-NetUDPEndpoint -LocalPort 41235
126
+ Get-Content "$env:USERPROFILE\.dsh\w2m\supervisor-heartbeat.json"
127
+
128
+ # the tunnel
129
+ wg show # on the server: a `latest handshake` line per peer is the only proof
130
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
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",
@@ -115,7 +115,7 @@ const MAX_STAGES = 16;
115
115
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
116
116
  * overrides it at runtime for tests and packagers.
117
117
  */
118
- const PLUGIN_VERSION = '0.5.1';
118
+ const PLUGIN_VERSION = '0.5.2';
119
119
 
120
120
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
121
121
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;