@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/CHANGELOG.md +288 -3
- package/PROTOCOL-v0.4.0.md +11 -3
- package/PROTOCOL.md +69 -15
- package/README.md +180 -27
- package/SECURITY.md +69 -19
- package/TROUBLESHOOTING.md +100 -53
- package/bin/w2m-rabbit.mjs +32 -5
- package/deploy/networking/LESSONS.md +18 -1
- package/deploy/networking/README.md +32 -7
- package/deploy/windows/README.md +20 -1
- package/deploy/windows/install-service.ps1 +98 -19
- package/deploy/windows/verify-supervisor.mjs +794 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +867 -40
- package/docs/AUDIT.md +10 -1
- package/docs/DEPLOY.md +16 -3
- package/docs/P2P-CRYPTO.md +105 -4
- package/docs/PIPELINE-DESIGN.md +35 -10
- package/docs/TROUBLESHOOTING.md +22 -3
- package/package.json +5 -5
- package/src/agent/agent.mjs +152 -5
- package/src/agent/allowed-commands.mjs +480 -18
- package/src/agent/crypto.mjs +238 -40
- package/src/agent/p2p-node.mjs +40 -0
- package/src/agent/p2p.mjs +323 -30
- package/src/agent/resolve.mjs +68 -3
- package/src/plugin/fleet-target.mjs +160 -15
- package/src/plugin/tools.mjs +27 -29
- package/src/relay/server.mjs +74 -11
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
|
|
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
|
package/docs/P2P-CRYPTO.md
CHANGED
|
@@ -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
|
|
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,
|
|
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.
|
package/docs/PIPELINE-DESIGN.md
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
# Pipeline mode: the design question that has to be answered first
|
|
2
2
|
|
|
3
|
-
Status:
|
|
4
|
-
`
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
- `
|
|
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.
|
package/docs/TROUBLESHOOTING.md
CHANGED
|
@@ -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
|
|
554
|
-
a path without spaces there —
|
|
555
|
-
|
|
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.
|
|
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",
|
package/src/agent/agent.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|