@twinsearth/w2m-dsh-plugin 0.5.0 → 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 +67 -0
- package/deploy/networking/LESSONS.md +90 -0
- package/deploy/windows/install-service.ps1 +25 -1
- package/deploy/windows/w2m-agent-supervisor.mjs +147 -10
- package/docs/AUDIT.md +130 -0
- package/package.json +1 -1
- package/src/plugin/tools.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,73 @@ 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.
|
|
40
|
+
## [0.5.1] — 2026-10-10
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
* **The agent no longer dies with its console.** Measured twice on the fleet: the whole process
|
|
45
|
+
tree — supervisor and agent together — was killed with code `0xC000013A` (`STATUS_CONTROL_C_EXIT`,
|
|
46
|
+
its console went away), the log ended mid-sentence, and Task Scheduler did not restart it, so the
|
|
47
|
+
fleet silently lost the machine. A supervisor that shares the console dies with the thing it
|
|
48
|
+
supervises, which was its one job. The supervisor now spawns the agent with `detached: true` and
|
|
49
|
+
**file-descriptor stdio** — never `inherit`, never pipes, because a detached child writing into a
|
|
50
|
+
pipe whose reader has died blocks instead of failing loudly.
|
|
51
|
+
* **The scheduled task heals itself, without elevation.** The interactive mode now also registers a
|
|
52
|
+
repeating trigger (default every 5 minutes) which, with `MultipleInstances IgnoreNew`, means "be
|
|
53
|
+
running, or start": a death from any cause is repaired at the next tick. It exists because
|
|
54
|
+
`RestartCount 999` was measured not to fire at all.
|
|
55
|
+
|
|
56
|
+
### Added
|
|
57
|
+
|
|
58
|
+
* **`deploy/networking/LESSONS.md`** — a read-only study of 2dust/v2rayN (GPL-3.0, so description
|
|
59
|
+
with citations only and nothing copied) recording the transport disciplines this project was
|
|
60
|
+
missing, and the decision it changed: **no TUN/VPN mode.** v2rayN never creates an adapter itself
|
|
61
|
+
and contains no peer-to-peer anywhere; a tunnel is hub-and-spoke by construction, so it would make
|
|
62
|
+
our "the data path is peer-to-peer" claim false for exactly the traffic a tunnel exists to carry.
|
|
63
|
+
|
|
64
|
+
### Notes
|
|
65
|
+
|
|
66
|
+
* A regression introduced and caught during the fix is recorded in the code: the first version also
|
|
67
|
+
called `child.unref()`, which leaves the supervisor with no handle at all, so its event loop
|
|
68
|
+
drains and it exits immediately after spawning the agent. The evidence was that no supervisor
|
|
69
|
+
process existed — found while looking for a different property.
|
|
70
|
+
* Verified on the machine that had the defect: supervisor and agent both present, and killing the
|
|
71
|
+
supervisor leaves the agent alive with its punch socket still bound (`0.0.0.0:41235`).
|
|
72
|
+
* Also included: the test fix for `test/outbound-only.test.mjs`, whose port was being read from the
|
|
73
|
+
timestamp rather than from the field that states it (commit `57dbc5f`).
|
|
7
74
|
## [0.5.0] — 2026-10-10
|
|
8
75
|
|
|
9
76
|
The release that stops treating "the relay carried it" as the only answer for a machine behind a
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# What a mature proxy client taught us about transport — and what it did not
|
|
2
|
+
|
|
3
|
+
This file records a **read-only study of [2dust/v2rayN](https://github.com/2dust/v2rayN)**, done to
|
|
4
|
+
find out which transport disciplines this project is missing. It exists because the study's
|
|
5
|
+
conclusions are only worth anything if they can be checked, and because one of them is a decision we
|
|
6
|
+
would otherwise have made wrongly.
|
|
7
|
+
|
|
8
|
+
## Provenance and licence
|
|
9
|
+
|
|
10
|
+
* Read at branch **`master`**, commit **`b501efbc45bca3c465df99af7d4ec092c2f4c40f`**. Paths below are
|
|
11
|
+
relative to that clone, so every claim can be checked at that revision.
|
|
12
|
+
* **v2rayN is GPL-3.0** (`LICENSE`, the verbatim GNU GPL v3 text; no other licence statement in the
|
|
13
|
+
repository). **This project is MIT.** No code, text or file was copied from it — not a fragment,
|
|
14
|
+
not a paraphrase of code. Everything below is *description with citations*, and any implementation
|
|
15
|
+
we write from it is our own.
|
|
16
|
+
* We did not modify v2rayN, and we did not open issues or pull requests against it.
|
|
17
|
+
|
|
18
|
+
## The decision this study changed: do not add a TUN/VPN mode
|
|
19
|
+
|
|
20
|
+
The tempting idea was "add a VPN so two machines behind hard NAT can reach each other". The study
|
|
21
|
+
says plainly that this does not do what it sounds like:
|
|
22
|
+
|
|
23
|
+
* v2rayN never creates an adapter or touches the routing table itself; it writes a declarative config
|
|
24
|
+
and the external core (Xray/sing-box/mihomo) does the privileged work
|
|
25
|
+
(`v2rayN/ServiceLib/Services/CoreConfig/V2ray/V2rayInboundService.cs:75-77`;
|
|
26
|
+
`Sample/tun_singbox_inbound:10-13`).
|
|
27
|
+
* Its local inbound is loopback-only (`Sample/SampleInbound:5`), and every path is client → remote
|
|
28
|
+
server. There is **no peer-to-peer anywhere in the codebase**: no hole punching, no candidate
|
|
29
|
+
exchange, no relay of our kind — a grep for the concepts returns nothing, and the only STUN code is
|
|
30
|
+
a UDP reachability *diagnostic* (`ServiceLib.UdpTest/Tester/StunService.cs:8-48`), not a data path.
|
|
31
|
+
* So a TUN interface is **hub-and-spoke by construction**: traffic between two of our machines would
|
|
32
|
+
transit the hub. Our promise — "the data path is peer-to-peer" — would become **false for exactly
|
|
33
|
+
the traffic the tunnel exists to carry**.
|
|
34
|
+
|
|
35
|
+
**Therefore: no TUN in this project for now.** It would cost administrator rights, a kernel driver,
|
|
36
|
+
and the truth of our central claim, while solving only part of what the delivery ladder already
|
|
37
|
+
addresses. If it is ever added, two invariants have to hold, and both belong in the ladder's
|
|
38
|
+
documentation rather than in a commit message: the control plane and the punch sockets must be
|
|
39
|
+
exempted from the tunnel, and tunnelled traffic must never be reported as `p2p`.
|
|
40
|
+
|
|
41
|
+
## Disciplines worth adopting, in the order we should adopt them
|
|
42
|
+
|
|
43
|
+
1. **Declare policy; let one worker enforce it.** v2rayN's central shape is an ordered, declarative
|
|
44
|
+
policy consumed by a single enforcer (`Services/CoreConfig/…`), with validation that aborts a start
|
|
45
|
+
rather than launching something broken (`Manager/CoreManager.cs:75-80`), mutating a deep copy
|
|
46
|
+
rather than live config. Our ladder should be *data* — ordered rungs, each with its own bound and
|
|
47
|
+
its own reason code — executed in one place.
|
|
48
|
+
2. **One named constant per network bound, and a bigger budget for the relayed leg.** `LocalFetch`
|
|
49
|
+
5 s, `DirectDownloadConnect` 5 s, **`ProxyDownloadConnect` 10 s**, selected per hop
|
|
50
|
+
(`Global.cs:100,103-104`; `Helper/DownloaderHelper.cs:336-339`). We cross NAT *and* a relay; the
|
|
51
|
+
relay leg deserves its own larger bound instead of sharing one timeout with a LAN attempt.
|
|
52
|
+
3. **A distinguished failure value, never a silent zero.** `-1` as a failure sentinel
|
|
53
|
+
(`Handler/ConnectionHandler.cs:34,72`), with dependent work explicitly skipped when a probe fails
|
|
54
|
+
(`Services/SpeedtestService.cs:499-509`). Our `HELLO`/`HELLO_ACK` path should separate *no answer
|
|
55
|
+
yet* / *refused* / *timed out* / *answered wrong*, and the ledger should name **which rung** failed
|
|
56
|
+
rather than reporting a single boolean.
|
|
57
|
+
4. **Bounded attempts, then fall down the ladder.** `MaxTryAgainOnFailure = 2`, twice with a 500 ms
|
|
58
|
+
gap (`DownloaderHelper.cs:37,71`; `ConnectionHandler.cs:39-47`). Give the simultaneous punch and
|
|
59
|
+
the reverse dial two or three attempts each, and record the reason each one lost.
|
|
60
|
+
5. **Exempt the transport's own traffic from the transport.** v2rayN forces its cores and its own
|
|
61
|
+
directory to go direct (`Services/CoreConfig/V2ray/V2rayRoutingService.cs:239-278`, emitted at
|
|
62
|
+
`:16-30`). This is the most load-bearing lesson in the whole study: a control plane routed through
|
|
63
|
+
the thing it controls is the most likely way to brick a machine.
|
|
64
|
+
6. **Teardown that is enforced by the OS, plus reaping at start.** Cores are assigned to a
|
|
65
|
+
kill-on-close job object (`Services/WindowsJobService.cs:15-18`; `Manager/CoreManager.cs:360,365-376`)
|
|
66
|
+
so a GUI crash still kills them, and stale devices are swept at the *next* launch
|
|
67
|
+
(`Common/WindowsUtils.cs:55-75`). Our analogue: reap stale state and half-open punches on start
|
|
68
|
+
instead of trusting a graceful exit.
|
|
69
|
+
7. **Validate, warn, fall back to a safe value.** Errors and warnings are separate lists; an
|
|
70
|
+
unresolvable tag falls back with a warning; invalid values are blanked and reported
|
|
71
|
+
(`Handler/Builder/CoreConfigContextBuilder.cs:3-26,70-136`).
|
|
72
|
+
8. **Ordered rules with a documented catch-all.** Tunnel/DNS/loop rules first, user rules next,
|
|
73
|
+
catch-all last (`V2rayRoutingService.cs:9-64,215-237`). Only relevant to us if routing ever enters
|
|
74
|
+
the picture — which, per the decision above, it has not.
|
|
75
|
+
|
|
76
|
+
## What we deliberately are not copying
|
|
77
|
+
|
|
78
|
+
Everything requiring an external core or administrator rights: Wintun/WFP plumbing, WinINet/RAS proxy
|
|
79
|
+
configuration with a registry fallback, PAC serving, route supernet arithmetic, TUN address pools,
|
|
80
|
+
per-process/per-domain routing, and core-level observatories/balancers. None of it is our shape, and
|
|
81
|
+
most of it is licensed work we must not take even if it were.
|
|
82
|
+
|
|
83
|
+
## One thing v2rayN does *not* do, which we should not admire
|
|
84
|
+
|
|
85
|
+
There is **no core auto-restart and no core-death notification**: the `Exited` handler only
|
|
86
|
+
unsubscribes log handlers (`Services/ProcessService.cs:132-142`). Its safety net is the job object
|
|
87
|
+
killing things reliably — not noticing and repairing. We already learned this the hard way: this
|
|
88
|
+
project's agent was twice found dead with a `0xC000013A` and nothing restarting it. Reliability by
|
|
89
|
+
construction is better than reliability by hope, and where we cannot have the former we now at least
|
|
90
|
+
have a watchdog that notices.
|
|
@@ -56,6 +56,7 @@ param(
|
|
|
56
56
|
[string]$Bin = '',
|
|
57
57
|
[string]$AgentArgs = '',
|
|
58
58
|
[int]$RestartBackoffMs = 60000,
|
|
59
|
+
[int]$WatchdogMinutes = 5,
|
|
59
60
|
[switch]$Interactive,
|
|
60
61
|
[switch]$Uninstall,
|
|
61
62
|
[switch]$WhatIfOnly
|
|
@@ -133,11 +134,34 @@ $json = $config | ConvertTo-Json -Depth 4
|
|
|
133
134
|
Write-Host "wrote $configPath"
|
|
134
135
|
|
|
135
136
|
$action = New-ScheduledTaskAction -Execute $node -Argument ($arguments -join ' ') -WorkingDirectory $PSScriptRoot
|
|
137
|
+
# ---------------------------------------------------------------------------------------------
|
|
138
|
+
# SELF-HEALING WITHOUT ELEVATION
|
|
139
|
+
#
|
|
140
|
+
# Measured twice on this machine: the whole process tree (supervisor + agent) is killed with
|
|
141
|
+
# 0xC000013A (STATUS_CONTROL_C_EXIT -- its console went away), the log ends mid-sentence, and Task
|
|
142
|
+
# Scheduler does NOT bring it back even with RestartCount 999: the task simply ends. The fleet then
|
|
143
|
+
# silently loses the machine, which is exactly the failure a supervisor was supposed to prevent --
|
|
144
|
+
# a supervisor that shares the console dies with it.
|
|
145
|
+
#
|
|
146
|
+
# A repeating trigger fixes that without any administrator right: combined with
|
|
147
|
+
# `MultipleInstances IgnoreNew` the task means "be running, or start", so a death from any cause is
|
|
148
|
+
# repaired at the next tick. The window is the repetition interval, and that is the honest trade:
|
|
149
|
+
# up to N minutes offline after a console-close death, versus needing elevation for a session-0
|
|
150
|
+
# service (the real fix, and the default mode above).
|
|
151
|
+
# ---------------------------------------------------------------------------------------------
|
|
152
|
+
$repetition = if ($Interactive) {
|
|
153
|
+
# `[TimeSpan]::MaxValue` is NOT a valid duration here -- Task Scheduler rejects it and the whole
|
|
154
|
+
# registration fails (measured: the script had already stopped the old task, so the machine was
|
|
155
|
+
# left with no agent at all). Ten years is the idiomatic stand-in for "repeat indefinitely".
|
|
156
|
+
New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
|
|
157
|
+
-RepetitionInterval (New-TimeSpan -Minutes $WatchdogMinutes) `
|
|
158
|
+
-RepetitionDuration ([TimeSpan]::FromDays(3650))
|
|
159
|
+
} else { $null }
|
|
136
160
|
# AtStartup only makes sense for the session-0 (S4U) shape, which also runs whether or not anyone is
|
|
137
161
|
# signed in. Registering an AtStartup trigger on an interactive task is refused without elevation --
|
|
138
162
|
# measured, and the reason this list is conditional rather than always two entries.
|
|
139
163
|
$triggers = if ($Interactive) {
|
|
140
|
-
@(New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME")
|
|
164
|
+
@((New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME"), $repetition)
|
|
141
165
|
} else {
|
|
142
166
|
@(
|
|
143
167
|
(New-ScheduledTaskTrigger -AtStartup),
|
|
@@ -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, 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. */
|
|
@@ -149,6 +149,9 @@ if (!existsSync(bin)) {
|
|
|
149
149
|
|
|
150
150
|
mkdirSync(dirname(logPath), { recursive: true });
|
|
151
151
|
const log = createWriteStream(logPath, { flags: 'a' });
|
|
152
|
+
// A second handle on the same file, as a descriptor: this is what the detached agent writes into,
|
|
153
|
+
// because a descriptor is not connected to this process's console or to any pipe of ours.
|
|
154
|
+
const logFd = openSync(logPath, 'a');
|
|
152
155
|
const stamp = () => new Date().toISOString();
|
|
153
156
|
const say = (line) => {
|
|
154
157
|
console.log(line);
|
|
@@ -173,19 +176,140 @@ for (const signal of ['SIGINT', 'SIGTERM', 'SIGBREAK']) {
|
|
|
173
176
|
});
|
|
174
177
|
}
|
|
175
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.
|
|
176
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
|
+
|
|
177
281
|
while (!stopping) {
|
|
178
282
|
runs += 1;
|
|
179
283
|
const startedAt = Date.now();
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
284
|
+
// ---------------------------------------------------------------------------------------------
|
|
285
|
+
// THE AGENT MUST NOT SHARE THIS PROCESS'S CONSOLE.
|
|
286
|
+
//
|
|
287
|
+
// Measured twice on the fleet: the whole tree -- supervisor and agent together -- is killed with
|
|
288
|
+
// 0xC000013A (STATUS_CONTROL_C_EXIT, "the console went away"), the log ends mid-sentence, and
|
|
289
|
+
// nothing restarts it. A supervisor that shares the console dies with the thing it supervises,
|
|
290
|
+
// which was its one job. A study of v2rayN (GPL-3.0, read-only; see deploy/networking/LESSONS.md)
|
|
291
|
+
// turned up why its core cannot die this way: it is started with no console window and redirected
|
|
292
|
+
// stdio at all.
|
|
293
|
+
//
|
|
294
|
+
// `detached: true` plus stdio that is NOT connected to this process is the documented way to make
|
|
295
|
+
// a long-running child independent of its parent's console: with `detached`, a long-running child
|
|
296
|
+
// "will not stay running in the background after the parent exits unless it is provided with a
|
|
297
|
+
// `stdio` configuration that is not connected to the parent". So the agent gets file descriptors --
|
|
298
|
+
// never `inherit`, never pipes -- and that is the whole of the console-severing change.
|
|
299
|
+
//
|
|
300
|
+
// NOT `child.unref()`. It was the obvious-looking addition and it is wrong here: unref'ing the
|
|
301
|
+
// child leaves this process with no handle at all, so its event loop drains and **the supervisor
|
|
302
|
+
// exits immediately after spawning the agent** -- measured, and caught by noticing that no
|
|
303
|
+
// supervisor process existed at all. `unref()` is for a parent that intends to leave; this one
|
|
304
|
+
// exists to stay. Pipes would be the other trap: a detached child writing into a pipe whose reader
|
|
305
|
+
// has died blocks instead of failing loudly, which is why the log is a descriptor.
|
|
306
|
+
// ---------------------------------------------------------------------------------------------
|
|
307
|
+
const child = spawn(process.execPath, argv, {
|
|
308
|
+
detached: true,
|
|
309
|
+
stdio: ['ignore', logFd, logFd],
|
|
310
|
+
windowsHide: true,
|
|
188
311
|
});
|
|
312
|
+
writeHeartbeat({ state: 'running', child_pid: child.pid ?? null, child_started_at: stamp() });
|
|
189
313
|
|
|
190
314
|
const result = await new Promise((resolve) => {
|
|
191
315
|
child.on('exit', (exitCode, signal) => resolve({ exitCode, signal }));
|
|
@@ -194,6 +318,7 @@ while (!stopping) {
|
|
|
194
318
|
resolve({ exitCode: null, signal: null });
|
|
195
319
|
});
|
|
196
320
|
});
|
|
321
|
+
exitTimes.push(Date.now());
|
|
197
322
|
|
|
198
323
|
const lived = Math.round((Date.now() - startedAt) / 1000);
|
|
199
324
|
say(
|
|
@@ -201,10 +326,22 @@ while (!stopping) {
|
|
|
201
326
|
`${stopping ? ' (stopping)' : ' -- restarting'}`,
|
|
202
327
|
);
|
|
203
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
|
+
}
|
|
204
340
|
// Deliberately NOT unref'ed: an unref'ed timer with nothing else pending lets Node exit during the
|
|
205
341
|
// backoff, which is the silent death this loop exists to prevent.
|
|
206
342
|
await new Promise((resolve) => {
|
|
207
|
-
setTimeout(resolve,
|
|
343
|
+
setTimeout(resolve, wait);
|
|
208
344
|
});
|
|
209
345
|
}
|
|
346
|
+
writeHeartbeat({ state: 'stopped' });
|
|
210
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.
|
|
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",
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -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.
|
|
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;
|