@twinsearth/w2m-dsh-plugin 0.5.9 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/AUDIT.md CHANGED
@@ -10,6 +10,15 @@ whose evidence cannot be told apart from its assumptions is worse than no audit:
10
10
 
11
11
  Re-run the checks in §5 rather than trusting this file; it records a moment, not a guarantee.
12
12
 
13
+ > **SUPERSEDED — kept as the historical record.** This audit covers **v0.4.1 … v0.5.1**, and it is
14
+ > superseded by **v0.6.0 (2026-10-11)**. It is not being rewritten: what it recorded was true of the
15
+ > build it measured, and a reader who wants to know what that build did should still read it. Two
16
+ > figures in it were **already stale when it was written**, and are called out here rather than
17
+ > silently corrected below: §1's npm row says `@0.5.1, latest`, and §2 says `v0.5.0 installed` — the
18
+ > audit describes the session that built v0.4.1 … v0.5.1, and both rows name a version that had
19
+ > already been superseded by the time a reader could act on them. For the current state, use
20
+ > `CHANGELOG.md` and `README.md`.
21
+
13
22
  ## 1. Designed features: implemented?
14
23
 
15
24
  | Designed feature | State | Evidence |
@@ -29,7 +38,7 @@ Re-run the checks in §5 rather than trusting this file; it records a moment, no
29
38
  | 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
39
  | npm publication with Trusted Publishing | ✅ | **[M]** `@twinsearth/w2m-dsh-plugin@0.5.1`, latest, with a SLSA provenance attestation |
31
40
  | 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 |
41
+ | 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. That reasoning applies to adding a **TUN** adapter. It does not describe rung 4, which **is** deployed: a self-hosted WireGuard hub whose tunnelled traffic is named explicitly rather than passed off as server-free. See `deploy/networking/README.md`. |
33
42
 
34
43
  **Also not built, and known:** the ladder is not yet ordered *data* with per-rung budgets; the
35
44
  plugin has no `p2pPort` of its own (only the agent CLI needs one, since a dispatcher only dials out).
package/docs/DEPLOY.md CHANGED
@@ -141,14 +141,27 @@ real one):
141
141
  [w2m-rabbit] trust_proxy=false (X-Forwarded-* ignored)
142
142
  [w2m-rabbit] pair_rate_limit=5/min/IP
143
143
  [w2m-rabbit] persistence=on dir=/var/lib/w2m revived_devices=0 revived_tasks=0
144
- ... plus a banner repeating the OPERATOR TOKEN, which is also written to
145
- <state>/operator-token.txt with mode 0600
144
+ ... plus the OPERATOR TOKEN **on the start that generated it**, and otherwise
145
+ only where it is stored: <state>/operator-token.txt with mode 0600.
146
+ Later starts print "operator token (restored) ... NOT printed" instead,
147
+ because this banner lands in journald / a container log / CI output and
148
+ that token authorises dispatching to the whole fleet. Print it anyway with
149
+ --print-operator-token (or W2M_PRINT_OPERATOR_TOKEN=1).
146
150
  ```
147
151
 
152
+ On that one generating start the value appears **twice, deliberately**: once in
153
+ the `[w2m-rabbit]` line above (stderr — the log copy) and once in the CLI's
154
+ `OPERATOR TOKEN` box (stdout — the handoff a human reads). Two sinks on the start
155
+ that minted the token is not the defect this behaviour fixes; printing it on
156
+ *every* start, restored ones included, was. Every other start prints it in
157
+ neither place unless the opt-in above asks for it.
158
+
148
159
  With `--json`, all of the above goes to **stderr** and stdout carries exactly
149
160
  one JSON line; the operator token is reported only as
150
161
  `"operatorTokenPresent": true`. Read the token from
151
- `<state>/operator-token.txt`, never from stdout.
162
+ `<state>/operator-token.txt`, never from stdout — the one exception is the start
163
+ that generated it, which prints it once so a first-time operator is not left
164
+ without it.
152
165
 
153
166
  - the **pairing code** (`PAIR-XXXXXXXX`) — hand this to each machine, once. It
154
167
  **rotates after every successful pairing**, and the new one is printed, so you
@@ -30,7 +30,9 @@ that is actually valuable here and needs no privileges: **its cryptography and i
30
30
  * A passive observer anywhere on the path between two machines: task offers, results and file
31
31
  fingerprints on the direct UDP channel become unreadable.
32
32
  * An off-path attacker who knows a peer's address: without the peer's static private key a forged
33
- `HELLO` cannot complete the handshake.
33
+ `HELLO` cannot complete the handshake, and a forged `REFUSE` cannot be sealed with `k_ref` either.
34
+ What such an attacker *can* still do is send an unsealed refusal if they can also spoof the punched
35
+ peer's source address and port — see the does-not-protect list below, which is where that belongs.
34
36
  * Replay and reordering of data frames: counters plus a replay window, not hope.
35
37
  * A *curious relay operator* reading task data **that travels over the direct channel**. Note the
36
38
  relay still sees the control plane — see below.
@@ -42,6 +44,17 @@ that is actually valuable here and needs no privileges: **its cryptography and i
42
44
  when, and the aggregate verdict. Metadata is not hidden by this work, and pretending otherwise would
43
45
  be worse than saying so.
44
46
  * Traffic analysis, or an attacker who can drop packets (availability is the relay's job).
47
+ * **An active attacker who can inject a datagram at a punch address.** `REFUSE` is what tells a
48
+ dialler "the handshake is impossible, here is why", and the unknown-peer form of it (`NO_PEER_KEY`,
49
+ `NO_KEY`) is sent precisely when the sender holds no key for the dialler — so it can carry no `k0`
50
+ proof, and an attacker who can put one such datagram on the wire *from the punched peer's own
51
+ address and port* can force the recorded outcome: a plaintext channel with `P2P_CRYPTO_NO_KEY`, or —
52
+ with a terminal refusal — the relay fallback. The v0.6.0 guard (`REFUSE_GUARD` in `src/agent/p2p.mjs`)
53
+ narrows this to exactly that attacker: a refusal that names another session, arrives from any other
54
+ address, or carries a `k_ref` seal that does not verify changes nothing, and a sealed refusal cannot
55
+ be forged without the peer's static private key. A peer that *can* seal its refusal is therefore not
56
+ downgradable at all; a peer running an older build cannot seal one, and its plaintext fallback has to
57
+ stay reachable.
45
58
  * Any traffic that falls back to the relay, **unless** the relay leg is separately protected. Stage 1
46
59
  leaves the relay leg as it is and says so in the ledger (`encrypted: false` for that machine).
47
60
 
@@ -96,7 +109,28 @@ session key k = HKDF( ECDH(e_i, e_r) ‖ ECDH(s_i, e_r) ‖ ECDH(e_
96
109
  * A replayed `HELLO` cannot produce a session key, because the responder's fresh `e_r` never existed
97
110
  at the time the original was captured.
98
111
  * A machine that does not know the initiator's static key cannot verify `mac` and refuses, naming the
99
- reason — never silently downgrading to plaintext.
112
+ reason. It does **not** silently downgrade to plaintext, and that sentence is narrower than it reads
113
+ — see the guard below and the residual in §2. What it means precisely is that a *failed handshake*
114
+ never falls back into the clear by itself: a plaintext channel is only handed back when the peer
115
+ named `NO_KEY`/`NO_PEER_KEY` **and** that refusal either came with proof or came from the address
116
+ that answered the punch. The v0.5.9 review broke the earlier, absolute wording with one 13-byte
117
+ datagram from a third socket, so the absolute wording is gone.
118
+
119
+ **Refusals, and what makes one evidence rather than a claim.** A `REFUSE` may carry a trailing 48-byte
120
+ MAC, sealed with `k_ref = HKDF(ECDH(e_i, s_r))` — the initiator's ephemeral (public on the wire) and
121
+ the responder's static key (which the responder always holds). Both ends derive it, neither needs the
122
+ other's *static* public key on file, and nobody else can. `NO_PEER_KEY`, `UNKNOWN_PEER`, `BAD_MAC` and
123
+ `REPLAY` are sealed when the sender can; a `VERSION` mismatch is not, because the peer that needs it
124
+ speaks a body layout this build cannot vouch for. A dialler drops a refusal that does not name the
125
+ agreed session, or that arrives from an address the punch never dialled, and it drops a **sealed**
126
+ refusal that would choose a plaintext channel while its seal does not verify. A plaintext-choosing
127
+ refusal with *no* seal is honoured only when it comes from the endpoint that acknowledged the punch —
128
+ that is the form an older build sends, and the reason it is still reachable. A terminal refusal
129
+ (`BAD_MAC`, `REPLAY`, a version mismatch) is honoured from a dialled address whether or not it is
130
+ sealed, and the outcome records which it was (`refusal_authenticated`: `true` proof, `null` no seal,
131
+ `false` a seal that did not verify) so a claim is never mistaken for proof. A dropped refusal is
132
+ recorded (`crypto.ignored_refusals`) and never fails the punch, because a datagram an attacker can
133
+ forge must not be able to deny service either.
100
134
 
101
135
  **Transport.** ChaCha20-Poly1305 per frame; nonce = four zero bytes followed by a 64-bit
102
136
  little-endian counter, per direction (WireGuard's layout). A 64-message sliding replay window. Rekey
@@ -110,7 +144,12 @@ untouched.
110
144
  **Fallback, and it is not optional.** If the handshake fails — an older peer, an unknown key, a
111
145
  tampered packet — the task proceeds **exactly as today**, over the relay, and the ledger records
112
146
  `p2p.encrypted: false` together with the reason. A fleet must never lose a machine because a security
113
- upgrade could not complete; it must only *say* that it did not.
147
+ upgrade could not complete; it must only *say* that it did not. The one outcome that is not a failure
148
+ is the peer saying it cannot take part at all (`NO_KEY`/`NO_PEER_KEY`): that is a plaintext channel,
149
+ recorded with its reason and with whether the refusal was sealed, and it is the documented outcome
150
+ rather than a downgrade. What §4 does **not** promise is that no datagram can produce it: an
151
+ unsealed refusal from the punched peer's own address can, which is the §2 residual and the reason the
152
+ guard exists.
114
153
 
115
154
  **Compatibility.** Additive: an older peer ignores the unknown offer fields (that property is already
116
155
  pinned by tests) and its frames are plaintext, which the newer peer accepts while recording
@@ -128,7 +167,11 @@ window carry over unchanged.
128
167
 
129
168
  Unchanged from `deploy/networking/`: the hub makes machines reachable regardless of NAT, and it does
130
169
  **not** make them peers — traffic between two clients is forwarded by the hub. It stays a fallback for
131
- reachability, and the ledger must never label tunnelled traffic as direct.
170
+ reachability. When it carries a task, the ledger's `transport` may describe the socket path — rung 4
171
+ really is a socket between the two machines — but the tunnel **must be named explicitly** wherever it
172
+ carried the traffic (the `p2p` block's `opened_by` / `offer_path`, or an equivalent field), and
173
+ **"direct" must never be read as "the bytes avoided a server"**: here it means "a socket between the two
174
+ machines, forwarded by the hub". Naming the tunnel is the requirement, not falsifying the socket.
132
175
 
133
176
  ## 7. How any of this is believed
134
177
 
@@ -223,3 +266,61 @@ decision nobody has needed yet.
223
266
  rediscovered.
224
267
  * A v0.4.x/v0.5.x peer is unaffected: it gets the plain channel it always had, and our side records
225
268
  `encrypted: false` with a reason. Never silently downgraded, never dropped from the fleet.
269
+
270
+ ### Added by the v0.5.9 review (MEDIUM-4)
271
+
272
+ The review reproduced a downgrade with one datagram: a third socket on loopback sending a 13-byte
273
+ `HELLO_ACK` whose body is `REFUSE(NO_PEER_KEY)`. Without it the punch refused with
274
+ `P2P_CRYPTO_HANDSHAKE_TIMEOUT` and returned no channel; with it the same punch returned `ok: true` with
275
+ a plaintext channel and recorded `P2P_CRYPTO_NO_KEY` — a live channel where the text of §4 and the
276
+ `cryptoOutcome` comment said none could exist, and a ledger entry blaming the peer. The fix is
277
+ `REFUSE_GUARD` (`src/agent/p2p.mjs`) plus the `k_ref` seal (`src/agent/crypto.mjs`), and it is stated
278
+ here rather than folded into §4 because §4 is the contract as it was agreed:
279
+
280
+ * a refusal must name the session the punch agreed, and arrive from an address that punch dialled;
281
+ * a **sealed** refusal that would choose a plaintext channel and whose `k_ref` seal does not verify is
282
+ dropped — an honest peer either seals correctly or sends no seal at all;
283
+ * an *unsealed* refusal that would choose plaintext must come from the endpoint that acknowledged the
284
+ punch — which is how an older peer, one that cannot seal, keeps its fallback;
285
+ * a *terminal* refusal is honoured from a dialled address sealed or not, and `refusal_authenticated`
286
+ records whether its seal was proof;
287
+ * a dropped refusal is recorded with a `reason_code` (`P2P_CRYPTO_REFUSE_SESSION_MISMATCH`,
288
+ `P2P_CRYPTO_REFUSE_UNPUNCHED_SOURCE`, `P2P_CRYPTO_REFUSE_BAD_MAC`,
289
+ `P2P_CRYPTO_REFUSE_UNCORROBORATED`) and never fails the punch, so forging one is not a cheap denial
290
+ of service;
291
+ * the outcome now says whether a refusal was proof or a claim (`refusal_authenticated`), because a
292
+ ledger that cannot tell those apart is the part of the defect that outlives the packet.
293
+
294
+ **Residual, and it is the honest version of §2's new bullet**: an attacker who can send an unsealed
295
+ refusal *from the punched peer's own address and port* can still force the recorded plaintext outcome,
296
+ or the relay fallback with a terminal refusal, against a peer old enough not to seal. Against a peer
297
+ that seals, it cannot. What no longer works at all is a datagram from anywhere else, one naming
298
+ another session, or a sealed downgrade that does not verify.
299
+
300
+ **Why a sealed downgrade that does not verify is dropped, and what that costs.** Sealing is not optional
301
+ for a peer that can do it — `k_ref` needs only the ephemeral it already received and the static key it
302
+ already holds — so a *plaintext-choosing* refusal that arrives sealed and fails verification is not a
303
+ peer whose records disagree with ours; it is a datagram somebody assembled. Honouring it as a claim
304
+ would let anyone who can spoof the punched address force plaintext, which is the defect this section
305
+ exists to close. The cost is stated rather than hidden: when **our** stored key for the peer is stale,
306
+ the peer seals its honest refusal with a `k_ref` we cannot reproduce, and what happens next depends on
307
+ which refusal it is. If it is a *downgrade* — the peer also holds no key for us, so it answers
308
+ `NO_PEER_KEY` — the seal fails and the refusal is dropped, so that misconfigured pair falls back to the
309
+ **relay** instead of a plaintext direct channel. If it is *terminal* — the peer does hold a good key for
310
+ us, so the mismatch shows up as `BAD_MAC` — the refusal is still honoured and still named, with
311
+ `refusal_authenticated: false` recording that its seal proved nothing. Either way the task completes,
312
+ the ledger records a fact that is true (`P2P_CRYPTO_HANDSHAKE_TIMEOUT`, or the peer's own named
313
+ refusal) rather than a plaintext channel an attacker could have chosen, and the staleness is the
314
+ operator's to fix. `refusal_authenticated` is what keeps the rest honest, and it is deliberately
315
+ three-valued on the outcome: `true` (a `k_ref` seal verified — proof), `null` (no seal at all: the form
316
+ an older build sends, which proves nothing), `false` (a seal was present and did not verify — the
317
+ stale-key `BAD_MAC` just described, which fails the punch honestly and is simply never counted as
318
+ proof). A reader of the ledger can therefore never mistake "somebody said the peer refused" for "the
319
+ peer proved it refused".
320
+
321
+ One collision was found and closed while adding the seal: `decodeRefuse` accepted one length and
322
+ validated nothing else, so a genuine `HELLO_ACK` could be read as a refusal — `body[6]` (the first byte
323
+ of a random ephemeral) as the code and `body[7]` as an id length — roughly once in 256 handshakes,
324
+ which would have refused an honest punch. The parse now checks the version byte, both legal lengths and
325
+ that the machine id holds no control bytes; a test bends a real acknowledgement's length byte into the
326
+ collision and asserts it is still not a refusal.
@@ -1,11 +1,19 @@
1
1
  # Pipeline mode: the design question that has to be answered first
2
2
 
3
- Status: **unresolved, and `pipeline` is deliberately not implemented.** The relay rejects
4
- `mode: "pipeline"` with `BAD_REQUEST` rather than guessing.
5
-
6
- This file exists because the question is not obvious until you try to answer it, and writing the
7
- analysis down is cheaper than re-deriving it — or than shipping a guess that half the system then
8
- depends on.
3
+ > **Status: resolved and implemented — keep reading for the reasoning, not for a to-do list.**
4
+ > `pipeline` shipped in **v0.3.4** as **Option C** below (`replicate` applied to a sequence): every
5
+ > machine runs the whole `stages` chain in order, stopping at the first stage that fails, with no
6
+ > cross-machine data flow. `src/relay/state.mjs`'s `createTask` accepts `mode: 'pipeline'`, requires a
7
+ > non-empty `stages` array of at most `MAX_PIPELINE_STAGES` (16) entries, and refuses `index_total > 1`.
8
+ > `CHANGELOG.md`'s `[0.3.4]` entry is the record, and `test/pipeline-e2e.test.mjs` drives a real relay
9
+ > and a real agent over it.
10
+ >
11
+ > **This header used to say "unresolved, and `pipeline` is deliberately not implemented", and that had
12
+ > been false since v0.3.4.** The sections below are left as they were written — the analysis is the
13
+ > reason the answer is Option C and not Option B, and editing the argument to match its conclusion would
14
+ > destroy the only thing this file is good for. Two things in it are now historical rather than
15
+ > current: the recommendation was **Option A**, and Option A is **not** what shipped; and "what
16
+ > implementing Option A requires" describes a plan, not the implementation.
9
17
 
10
18
  ## What is already settled
11
19
 
@@ -71,6 +79,12 @@ it is `replicate` applied to a sequence.
71
79
 
72
80
  **Option A**, with Option C as a cheap follow-on if useful.
73
81
 
82
+ > **What shipped was Option C, not Option A.** v0.3.4 implemented the chain as `replicate` applied to a
83
+ > sequence: no `executor_machine_id`, every machine runs every stage. Option A — "one machine runs the
84
+ > whole chain, composed with `broadcast`" — remains **unbuilt**. The recommendation below is kept as
85
+ > written, because the reasons it gives are still the reasons Option B was refused; the divergence
86
+ > between this recommendation and the shipped behaviour is recorded rather than smoothed over.
87
+
74
88
  Reasons, in order of weight:
75
89
 
76
90
  1. Option B is the only one that needs cross-machine data flow, and it is the only one whose
@@ -82,14 +96,25 @@ Reasons, in order of weight:
82
96
 
83
97
  ## What implementing Option A requires
84
98
 
99
+ **None of this is a to-do list: Option C was implemented instead (v0.3.4), and it met four of these
100
+ five points on the way.** It is kept because it is still the checklist for Option A if that is ever
101
+ built, and because it records which parts of the system a chain touches.
102
+
85
103
  - `state.mjs`: accept `mode: 'pipeline'` with `stages: [{command_argv, cwd_rel, ...}]`; keep
86
104
  `executor_machine_id` meaningful; reject `index_total > 1` for the same reason `broadcast` does.
105
+ — **Done in v0.3.4 for Option C.** One difference: `executor_machine_id` is *not* used by a
106
+ pipeline, so a chain cannot be pinned to one machine this way.
87
107
  - `agent.mjs`: execute stages in order, stop on the first non-`ok` stage, and report per-stage
88
108
  outcomes. This is the substantial part — the envelope currently describes exactly one command.
89
- - `report.mjs`: a chain failure must be distinguishable from a chain that never started.
90
- - `tools.mjs`: `w2m_run` gains `stages`.
91
- - `PROTOCOL`: the new field, and a compatibility note.
109
+ — **Done in v0.3.4.** The per-stage record travels in the envelope, and `command_hash` binds
110
+ **every** stage rather than stage 0 alone.
111
+ - `report.mjs`: a chain failure must be distinguishable from a chain that never started. — **Done.**
112
+ - `tools.mjs`: `w2m_run` gains `stages`. — **Done**, and `stages` is refused rather than ignored
113
+ under any other mode.
114
+ - `PROTOCOL`: the new field, and a compatibility note. — **Done**; the mode enum is in §5.1 and the
115
+ chain semantics are in §4.1.
92
116
 
93
117
  **Not verified, and it cannot be verified here:** whether Option A is what was actually wanted. That
94
118
  is a product question, not a technical one, and guessing it is how a fleet ends up with a chained
95
- mode nobody asked for.
119
+ mode nobody asked for. As shipped, the question is answered for Option C by the fact that it exists
120
+ and is tested; whether Option A is *also* wanted is still open.
@@ -550,9 +550,28 @@ Two consequences that are easy to walk into:
550
550
  [["C:\\Program Files\\nodejs\\node.exe", "-e"]]
551
551
  ```
552
552
 
553
- (This is why the pipeline end-to-end suite passes on CI runners — Node lives at
554
- a path without spaces there — and can fail locally on a Windows box where Node
555
- is installed under `C:\Program Files`.)
553
+ (This is why the pipeline end-to-end suite used to fail locally and pass on CI
554
+ runners — Node lives at a path without spaces there — until the entry in that
555
+ suite was written as an array entry, the form above.)
556
+
557
+ Three more refusals look like this section's symptom, and each has its own code
558
+ because each has its own remedy:
559
+
560
+ * `COMMAND_REPARSING_ARGUMENT` — the entry matched, but a **trailing** argument
561
+ would make the tool load code the entry never named: `node --test
562
+ --test-reporter=file:///tmp/evil.mjs`, `--import`, `--require`, `-e`, `python -c`,
563
+ `bash -c`, `git --exec-path`, `make -f`, `tar --to-command`, … Name the argument
564
+ in the entry if this machine is meant to run it (`["node --test
565
+ --test-reporter=tap"]`); an entry that merely names the tool is not read as
566
+ permission to eval.
567
+ * `COMMAND_EXECUTABLE_PATH_MISMATCH` — `argv[0]` is a path, and it is not the
568
+ file the entry's executable resolves to. `C:\Program Files\nodejs\node.exe` is
569
+ admitted for the entry `node` only when that is what `node` resolves to; a copy
570
+ of another program at `E:\Temp\evil\node.exe` is refused, because a copy under an
571
+ allowed name is a different program.
572
+ * `COMMAND_EXECUTABLE_RELATIVE_PATH` — `argv[0]` is a relative path
573
+ (`./node.exe`). The working directory comes from the offer, so the path names a
574
+ different file in every directory; use the bare name or an absolute path.
556
575
 
557
576
  To see what the machine actually received, ask the relay for the last task and
558
577
  read the machine's `refusal_reason`, or run the agent in the foreground: the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.5.9",
3
+ "version": "0.6.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",
@@ -101,11 +101,11 @@
101
101
  "scripts": {
102
102
  "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs",
103
103
  "test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs",
104
- "test:relay": "node --test test/relay.test.mjs",
105
- "test:agent": "node --test test/agent.test.mjs",
106
- "test:tools": "node --test test/tools.test.mjs",
104
+ "test:relay": "node --test --test-force-exit test/relay.test.mjs",
105
+ "test:agent": "node --test --test-force-exit test/agent.test.mjs",
106
+ "test:tools": "node --test --test-force-exit test/tools.test.mjs",
107
107
  "test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
108
- "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs",
108
+ "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs",
109
109
  "pack": "node scripts/pack.mjs",
110
110
  "lint": "eslint .",
111
111
  "lint:fix": "eslint . --fix",