@twinsearth/w2m-dsh-plugin 0.5.9 → 0.6.1

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.1",
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",
@@ -23,7 +23,7 @@
23
23
 
24
24
  import { Readable } from 'node:stream';
25
25
  import { access, constants as fsConstants } from 'node:fs/promises';
26
- import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
26
+ import { mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
27
27
  import { join } from 'node:path';
28
28
  import { randomUUID } from 'node:crypto';
29
29
 
@@ -141,6 +141,24 @@ export const AGENT_STATE_SCHEMA_VERSION = 1;
141
141
  */
142
142
  export const AGENT_STATE_PUBLISH_INTERVAL_MS = 30_000;
143
143
 
144
+ /**
145
+ * How many times `publishState()` tries the final `renameSync` before giving up, and the base of
146
+ * the synchronous backoff between tries (attempt N waits N * base).
147
+ *
148
+ * The publish renames a temp file *over* `agent-state.json` while another process may be reading
149
+ * it -- the plugin in the DSH host, an editor, a backup agent, or an antivirus scanner. On Windows
150
+ * that contention comes back as `EPERM`/`EACCES` from the rename even though both paths are fine,
151
+ * and the condition clears within milliseconds; without a retry the publish is lost, and (before
152
+ * v0.5.10) its temp file was left behind, which is how one live state directory accumulated 266
153
+ * `agent-state.json.tmp-*` files.
154
+ *
155
+ * Bounded on purpose: 5 attempts, and only a retryable error is retried, so a permanent failure
156
+ * (the destination is a directory, the volume is gone, the paths are on different devices) costs at
157
+ * most 30ms of synchronous backoff on a diagnostics-only path.
158
+ */
159
+ export const STATE_RENAME_ATTEMPTS = 5;
160
+ export const STATE_RENAME_BACKOFF_MS = 3;
161
+
144
162
  /**
145
163
  * How many times a relay identity change may trigger an *immediate* cursor-less
146
164
  * re-attach before the agent falls back to the normal backoff ladder. A healthy
@@ -857,6 +875,56 @@ export class Heartbeat {
857
875
  }
858
876
  }
859
877
 
878
+ /**
879
+ * Block the current thread for a few milliseconds.
880
+ *
881
+ * Only for {@link renameStateFile}'s backoff, and only on the failing path: `publishState()` is
882
+ * synchronous by design (an async publish would need a queue to keep two publishes from
883
+ * interleaving), and `Atomics.wait` on a throwaway shared buffer is the one way to wait without
884
+ * either a busy loop or an async boundary. There is no `await` here to yield at, and each wait is
885
+ * a handful of milliseconds.
886
+ *
887
+ * @param {number} ms
888
+ */
889
+ function sleepSync(ms) {
890
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
891
+ }
892
+
893
+ /** Is this rename failure the transient Windows-contention kind, rather than a real one? */
894
+ function isRetryableRenameError(error) {
895
+ const code = error?.code;
896
+ return code === 'EPERM' || code === 'EACCES' || code === 'EBUSY';
897
+ }
898
+
899
+ /**
900
+ * `renameSync` over the state file, retried briefly while the failure is transient.
901
+ *
902
+ * See {@link STATE_RENAME_ATTEMPTS} for why this exists at all. Only a retryable error is retried
903
+ * and only a bounded number of times, so the caller's "never throws" contract is preserved: the
904
+ * last error is rethrown for a permanent failure, exactly as a bare `renameSync` would have.
905
+ *
906
+ * @param {string} from The temp file this call created.
907
+ * @param {string} to The published path.
908
+ * @returns {number} The 1-based attempt that succeeded (a caller-visible fact for tests).
909
+ */
910
+ function renameStateFile(from, to) {
911
+ /** @type {unknown} */
912
+ let lastError = null;
913
+ for (let attempt = 1; attempt <= STATE_RENAME_ATTEMPTS; attempt += 1) {
914
+ try {
915
+ renameSync(from, to);
916
+ return attempt;
917
+ } catch (error) {
918
+ lastError = error;
919
+ if (attempt === STATE_RENAME_ATTEMPTS || !isRetryableRenameError(error)) throw error;
920
+ sleepSync(STATE_RENAME_BACKOFF_MS * attempt);
921
+ }
922
+ }
923
+ // Unreachable: the loop either returns or throws. Kept so a future edit cannot make this
924
+ // function fall off the end and return `undefined` to a caller that expects a count.
925
+ throw lastError;
926
+ }
927
+
860
928
  // ---------------------------------------------------------------------------
861
929
  // The agent
862
930
  // ---------------------------------------------------------------------------
@@ -1929,19 +1997,47 @@ export function createAgent(options) {
1929
1997
  * the agent (or a running task) down -- the file is a diagnostic, not a
1930
1998
  * delivery guarantee -- so failure is logged once and otherwise ignored.
1931
1999
  *
2000
+ * v0.5.10 adds the two halves of "the temp file is ours to clean up": the rename is retried
2001
+ * briefly (see {@link renameStateFile}), and a publish that fails outright unlinks its own temp
2002
+ * file. Before that, every failed rename left a `agent-state.json.tmp-*` behind, and a live state
2003
+ * directory that could not rename for a few hours had accumulated 266 of them / 187 KB with the
2004
+ * agent still warning once and carrying on.
2005
+ *
1932
2006
  * @returns {object|null} The snapshot written, or null when the write failed.
1933
2007
  */
1934
2008
  function publishState() {
1935
2009
  const snapshot = agentStateSnapshot();
2010
+ /** @type {string|null} The temp file this call created; null once it has been renamed away. */
2011
+ let tmp = null;
1936
2012
  try {
1937
2013
  mkdirSync(stateDir, { recursive: true, mode: 0o700 });
1938
- const tmp = `${stateFilePath}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`;
2014
+ tmp = `${stateFilePath}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`;
1939
2015
  writeFileSync(tmp, `${JSON.stringify(snapshot, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
1940
- renameSync(tmp, stateFilePath);
2016
+ const attempts = renameStateFile(tmp, stateFilePath);
2017
+ // Renamed: the temp name is gone, so the catch below must not be handed it.
2018
+ tmp = null;
2019
+ if (attempts > 1) {
2020
+ // Named, not silent: contention on this path was invisible before v0.5.10, and the only
2021
+ // trace of it was a growing pile of temp files. One line per publish that needed a retry
2022
+ // is the evidence an operator needs to recognise "something is holding the state file".
2023
+ log('info', `published ${AGENT_STATE_FILE} after ${attempts} rename attempts`, {
2024
+ path: stateFilePath,
2025
+ });
2026
+ }
1941
2027
  state.statePublishedAt = snapshot.updated_at;
1942
2028
  statePublishWarned = false;
1943
2029
  return snapshot;
1944
2030
  } catch (error) {
2031
+ // A publish that failed is a publish whose temp file must not survive it. The unlink is
2032
+ // itself best-effort and never rethrows: this function's contract is "never throws", and the
2033
+ // file it is trying to remove is by definition one nobody wants.
2034
+ if (tmp !== null) {
2035
+ try {
2036
+ unlinkSync(tmp);
2037
+ } catch {
2038
+ /* already gone, or the directory is unwritable -- nothing left to do here */
2039
+ }
2040
+ }
1945
2041
  if (!statePublishWarned) {
1946
2042
  statePublishWarned = true;
1947
2043
  log('warn', `could not publish ${AGENT_STATE_FILE} (diagnostics only; continuing)`, {
@@ -2692,9 +2788,14 @@ export function createAgent(options) {
2692
2788
  }
2693
2789
 
2694
2790
  // ---- 5. allow-list (default-deny) -----------------------------------
2791
+ //
2792
+ // `commandArgv` is the offer's own command line, which for a `pipeline` the relay sets to
2793
+ // `stages[0].command_argv` -- so this check covers the first stage and nothing else. Every
2794
+ // stage is checked below, before any of them runs; see the note there for why the second check
2795
+ // has to exist even though the plugin's pre-flight also checks every stage.
2695
2796
  const allowed = matchAllowedCommand(commandArgv, allowedCommands);
2696
2797
  if (!allowed.allowed) {
2697
- log('warn', `refused ${offer.task_id}: COMMAND_NOT_ALLOWED`, { argv: commandArgv });
2798
+ log('warn', `refused ${offer.task_id}: ${allowed.reason_code ?? REFUSAL.COMMAND_NOT_ALLOWED}`, { argv: commandArgv });
2698
2799
  await finish({
2699
2800
  offer,
2700
2801
  cwdRel,
@@ -2703,7 +2804,11 @@ export function createAgent(options) {
2703
2804
  anchorsAfter: null,
2704
2805
  execution: null,
2705
2806
  status: 'refused',
2706
- refusalReason: REFUSAL.COMMAND_NOT_ALLOWED,
2807
+ // The matcher's own code, not a second spelling of it: the plugin's dispatch pre-flight
2808
+ // reports the same literal for the same argv, and `COMMAND_REPARSING_ARGUMENT` and the two
2809
+ // executable-identity codes have to travel the same way or the two ends would disagree
2810
+ // about *why* one command was refused.
2811
+ refusalReason: allowed.reason_code ?? REFUSAL.COMMAND_NOT_ALLOWED,
2707
2812
  warnings,
2708
2813
  startedAt,
2709
2814
  endedAt: new Date().toISOString(),
@@ -2744,6 +2849,48 @@ export function createAgent(options) {
2744
2849
  }))
2745
2850
  : [{ index: 0, command_argv: [...offer.command_argv], cwd_rel: offer.cwd_rel ?? '.', continue_on_failure: false }];
2746
2851
 
2852
+ // ---- 6a. allow-list, again, for **every** stage ----------------------
2853
+ //
2854
+ // Step 5 checked the offer's `command_argv`, which the relay sets to `stages[0].command_argv`
2855
+ // (src/relay/state.mjs) -- so on a chain it vouched for the first stage alone. The plugin's
2856
+ // dispatch-time pre-flight does check every stage (src/plugin/tools.mjs), and its comment says
2857
+ // why: "the allow-list is a default-deny control, and a chain is not an exemption from it".
2858
+ // But that pre-flight runs on the *dispatcher*, and a dispatcher that posts straight to
2859
+ // `/v1/task` with the operator token never calls it. Enforcement on the machine that executes
2860
+ // is the only control that survives a hostile dispatcher, so it has to be here as well.
2861
+ //
2862
+ // The whole chain is refused before anything runs, not at the offending stage: running stages
2863
+ // 1..n-1 of a chain that will not finish would leave side effects from work the caller was
2864
+ // never allowed to ask for, and the plugin's pre-flight refuses the dispatch as a whole for the
2865
+ // same reason. The refusal names the stage index, because "this chain is not allowed" is not
2866
+ // actionable without knowing which stage.
2867
+ for (const stage of plannedStages) {
2868
+ const stageAllowed = matchAllowedCommand(stage.command_argv, allowedCommands);
2869
+ if (stageAllowed.allowed) continue;
2870
+ const code = stageAllowed.reason_code ?? REFUSAL.COMMAND_NOT_ALLOWED;
2871
+ log('warn', `refused ${offer.task_id}: ${code} at stage ${stage.index}`, {
2872
+ argv: stage.command_argv,
2873
+ });
2874
+ await finish({
2875
+ offer,
2876
+ cwdRel,
2877
+ commandArgv,
2878
+ anchorsBefore,
2879
+ anchorsAfter: null,
2880
+ execution: null,
2881
+ status: 'refused',
2882
+ refusalReason: code,
2883
+ warnings,
2884
+ startedAt,
2885
+ endedAt: new Date().toISOString(),
2886
+ // `protocol_violations` is the field a reader already finds a refused stage in (see the
2887
+ // cwd refusal below), and the stage index is the fact that makes it actionable. The
2888
+ // top-level `refusal_reason` carries the code the plugin's pre-flight would have reported.
2889
+ protocolViolations: [`stage ${stage.index}: ${code} - ${stageAllowed.detail}`],
2890
+ });
2891
+ return;
2892
+ }
2893
+
2747
2894
  // The task's timeout is a budget for the whole chain, not per stage. Giving each stage the full
2748
2895
  // timeout would let a 16-stage pipeline run 16x longer than the caller asked for, and the relay
2749
2896
  // would have given up long before.