@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.6

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +1288 -0
  2. package/LICENSE +21 -0
  3. package/PROTOCOL-v0.1.2.md +165 -0
  4. package/PROTOCOL-v0.3.0.md +454 -0
  5. package/PROTOCOL-v0.3.9.md +207 -0
  6. package/PROTOCOL-v0.4.0.md +255 -0
  7. package/PROTOCOL.md +339 -0
  8. package/README.md +665 -2
  9. package/bin/w2m-localside.mjs +303 -0
  10. package/bin/w2m-rabbit.mjs +396 -0
  11. package/bin/w2m-stun.mjs +231 -0
  12. package/cordis.patch.yml +94 -0
  13. package/deploy/caddy/Caddyfile.example +118 -0
  14. package/deploy/docker/Dockerfile +144 -0
  15. package/deploy/docker/README.md +338 -0
  16. package/deploy/docker/docker-compose.yml +179 -0
  17. package/deploy/nginx/w2m.conf.example +196 -0
  18. package/deploy/shared-server/README.md +118 -0
  19. package/deploy/shared-server/install.sh +255 -0
  20. package/deploy/systemd/w2m-rabbit.service +150 -0
  21. package/deploy/systemd/w2m-stun.service +69 -0
  22. package/deploy/windows/README.md +115 -0
  23. package/deploy/windows/install-service.ps1 +195 -0
  24. package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
  25. package/deploy/windows/winsw/w2m-localside.xml +50 -0
  26. package/docs/DEPLOY.md +902 -0
  27. package/docs/LINT-AUDIT.md +492 -0
  28. package/docs/PIPELINE-DESIGN.md +95 -0
  29. package/docs/TROUBLESHOOTING.md +560 -0
  30. package/docs/UI-CARDS.md +81 -0
  31. package/lib/index.js +13 -0
  32. package/lib/tools.js +11 -0
  33. package/package.json +133 -4
  34. package/src/agent/agent.mjs +3317 -0
  35. package/src/agent/allowed-commands.mjs +116 -0
  36. package/src/agent/caps.mjs +437 -0
  37. package/src/agent/exec.mjs +356 -0
  38. package/src/agent/git.mjs +616 -0
  39. package/src/agent/identity.mjs +253 -0
  40. package/src/agent/p2p-node.mjs +1243 -0
  41. package/src/agent/p2p.mjs +605 -0
  42. package/src/agent/resolve.mjs +148 -0
  43. package/src/agent/spool.mjs +252 -0
  44. package/src/agent/stun.mjs +397 -0
  45. package/src/agent/url.mjs +127 -0
  46. package/src/plugin/auto-update.mjs +574 -0
  47. package/src/plugin/schedule.mjs +452 -0
  48. package/src/plugin/shared-config.mjs +264 -0
  49. package/src/plugin/tools.mjs +3891 -0
  50. package/src/plugin/update-install.mjs +764 -0
  51. package/src/plugin/update-source.mjs +686 -0
  52. package/src/relay/metrics.mjs +284 -0
  53. package/src/relay/peers.mjs +180 -0
  54. package/src/relay/persistence.mjs +207 -0
  55. package/src/relay/report.mjs +591 -0
  56. package/src/relay/server.mjs +1187 -0
  57. package/src/relay/state.mjs +2004 -0
  58. package/src/relay/stun-server.mjs +733 -0
  59. package/src/signing.mjs +359 -0
  60. package/src/util/cli.mjs +70 -0
@@ -0,0 +1,560 @@
1
+ # W2M troubleshooting — cross-region and cross-network deployments
2
+
3
+ > Companion to `DEPLOY.md`. Every entry follows the same shape:
4
+ > **symptom → cause → fix**, with the exact command that confirms the
5
+ > diagnosis.
6
+ >
7
+ > First move for any problem: ask the relay what it thinks.
8
+
9
+ ```bash
10
+ curl <rabbitUrl>/healthz
11
+ ```
12
+
13
+ A healthy relay answers with `ok: true` plus the fields this document refers to
14
+ repeatedly:
15
+
16
+ | Field | What it tells you |
17
+ |---|---|
18
+ | `relay_id` | a fresh id per process start — **changed means the relay restarted** |
19
+ | `started_at` | when this process started (RFC3339) |
20
+ | `effective_scheme` | `http` or `https` as the relay believes it is reached |
21
+ | `base_path` | the prefix the relay expects (`/` unless started with `--base-path`) |
22
+ | `operator_token_required` | whether sending work needs the operator token |
23
+ | `pair_rate_limit` | pairing attempts allowed per IP per minute |
24
+ | `persistence` | `{enabled, dir, revived_devices, revived_tasks, …}` |
25
+ | `devices` / `tasks` | how many machines are registered, how many tasks exist |
26
+
27
+ ---
28
+
29
+ ## 1. "It connects, but no events ever arrive"
30
+
31
+ **Symptom.** A machine pairs successfully, `w2m_devices` lists it, but task
32
+ offers never reach it. The local-side log shows the stream opening and then
33
+ nothing. The UI/agent sits idle. A `curl` against `/v1/stream` returns HTTP 200
34
+ and then hangs without a body.
35
+
36
+ **Cause.** A reverse proxy in the path is **buffering the event stream**.
37
+ Proxies buffer responses by default; a buffered stream delivers frames in
38
+ batches (or not at all, for a stream that stays open), so the connection looks
39
+ established while nothing flows. The relay sends `X-Accel-Buffering: no`, which
40
+ nginx honours automatically, but nginx still needs `proxy_buffering off` for
41
+ correctness, and **Caddy does not honour that header at all**.
42
+
43
+ **Fix.**
44
+
45
+ nginx — the stream location needs all three:
46
+
47
+ ```nginx
48
+ location = /v1/stream {
49
+ proxy_pass http://w2m_relay/v1/stream;
50
+ proxy_buffering off; # the fix
51
+ proxy_read_timeout 86400s;
52
+ proxy_http_version 1.1;
53
+ proxy_set_header Connection "";
54
+ }
55
+ ```
56
+
57
+ Caddy — buffering is disabled with `flush_interval -1`:
58
+
59
+ ```caddy
60
+ handle /v1/stream {
61
+ reverse_proxy 127.0.0.1:8787 {
62
+ flush_interval -1 # the fix
63
+ transport http { read_timeout 0 write_timeout 0 }
64
+ }
65
+ }
66
+ ```
67
+
68
+ Also set `gzip off` for the stream: compressing an event stream re-enables
69
+ buffering.
70
+
71
+ **Confirm the diagnosis.** Read the response headers through the proxy and
72
+ check they are still the relay's, not the proxy's:
73
+
74
+ ```bash
75
+ curl -sS -D - -o /dev/null -N \
76
+ -H "Authorization: Bearer <device_token>" \
77
+ -H "Accept: text/event-stream" \
78
+ https://w2m.<your-domain>/v1/stream
79
+ ```
80
+
81
+ You should see `content-type: text/event-stream` and
82
+ `x-accel-buffering: no`. Then watch the stream and time the first frame — a
83
+ buffered proxy typically shows nothing for many seconds:
84
+
85
+ ```bash
86
+ time curl -sS -N -H "Authorization: Bearer <device_token>" \
87
+ https://w2m.<your-domain>/v1/stream | head -c 200
88
+ ```
89
+
90
+ A correct proxy delivers a frame in well under a second. If the same command
91
+ against `http://127.0.0.1:8787/v1/stream` on the relay host is instant but the
92
+ proxied one is not, the proxy is the problem — not the relay, not the client.
93
+
94
+ ---
95
+
96
+ ## 2. Every `/v1/*` call returns 404 (`NOT_FOUND`)
97
+
98
+ **Symptom.** `/healthz` may work, but `/v1/pair`, `/v1/stream`, `/v1/devices`
99
+ and friends all return `404`. The body reads something like:
100
+
101
+ ```json
102
+ {"error":{"code":"NOT_FOUND","message":"path is outside the configured base path /w2m","detail":{"path":"/v1/devices"}}}
103
+ ```
104
+
105
+ **Cause.** A **base-path mismatch**. Either the relay is mounted under a prefix
106
+ but was started without `--base-path`, or the client's `rabbitUrl` is missing
107
+ the prefix the relay expects. There are four ways to get this wrong, and they
108
+ look identical from the client:
109
+
110
+ | Relay | Client `rabbitUrl` | Result |
111
+ |---|---|---|
112
+ | `--base-path /w2m` | `https://host/w2m` | correct |
113
+ | `--base-path /w2m` | `https://host` | 404 — client asks for `/v1/...`, relay only answers under `/w2m` |
114
+ | no `--base-path` | `https://host/w2m` | 404 — client asks for `/w2m/v1/...`, relay only answers at the root |
115
+ | `--base-path /w2m` **and** the proxy strips the prefix | `https://host/w2m` | 404 — the relay receives `/v1/...` and rejects it |
116
+
117
+ The fourth case is the one that bites hardest, because both the relay and the
118
+ client look correctly configured: the **proxy** is silently rewriting the path.
119
+ nginx strips a prefix when `proxy_pass` has a URI (`proxy_pass
120
+ http://relay/;`); Caddy strips it with `handle_path` instead of `handle`.
121
+
122
+ **Fix.** Decide which side owns the prefix and make all three agree.
123
+
124
+ 1. Ask the relay what it expects:
125
+
126
+ ```bash
127
+ curl https://w2m.<your-domain>/healthz | grep -o '"base_path":"[^"]*"'
128
+ # or, at the root:
129
+ curl https://w2m.<your-domain>/w2m/healthz | grep -o '"base_path":"[^"]*"'
130
+ ```
131
+
132
+ `"base_path":"/"` means the relay is at the root and the tunnel/proxy must
133
+ forward paths unchanged. `"base_path":"/w2m"` means the relay expects the
134
+ prefix and the client must include it.
135
+
136
+ 2. Make the client match:
137
+
138
+ ```bash
139
+ w2m-localside --rabbit https://<host>/w2m --pair ... # prefix included
140
+ w2m-localside --rabbit https://<host> --pair ... # root
141
+ ```
142
+
143
+ 3. Make the proxy pass the prefix through unchanged (nginx `proxy_pass
144
+ http://w2m_relay;` with no URI; Caddy `handle`, not `handle_path`).
145
+
146
+ **Note.** `--base-path /w2m` keeps `/healthz` answering at **both**
147
+ `/healthz` and `/w2m/healthz` on purpose, because monitoring probes are often
148
+ pointed at the bare path. So a working `/healthz` does **not** prove the base
149
+ path is right — check `/v1/*`, or read `base_path` from `/healthz`.
150
+
151
+ **Related, easily confused:** `/v1/*` returning **401** is not this problem.
152
+ 401 means the path was found and authentication was required — the base path is
153
+ correct and the credential is not.
154
+
155
+ ---
156
+
157
+ ## 3. Sending a task returns `401 OPERATOR_REQUIRED`
158
+
159
+ **Symptom.** `w2m_run` (or a direct `POST /v1/task`) fails with
160
+ `OPERATOR_REQUIRED`, while `w2m_devices`, `w2m_status` and taking work all
161
+ succeed on the same machine.
162
+
163
+ **Cause.** Two different credentials are being confused. In 0.1.2:
164
+
165
+ | Credential | Held by | Can do |
166
+ |---|---|---|
167
+ | `device_token` | every paired machine | take work, return results, read device/task state |
168
+ | `operator_token` | **only you** | **send work** (`POST /v1/task`) |
169
+
170
+ A `device_token` cannot dispatch tasks — deliberately, so that one compromised
171
+ executor cannot command the whole group. The relay's error message says exactly
172
+ this ("this is a device_token … dispatching tasks requires the operator_token"),
173
+ but the distinction is easy to miss if you only read the status code.
174
+
175
+ **Fix.** Put the operator token where the dispatching side can read it:
176
+
177
+ ```bash
178
+ # 1. Find it. It is written at first start, mode 0600:
179
+ cat /var/lib/w2m/operator-token.txt
180
+
181
+ # If the relay was started with --no-persist or the file is gone, read the
182
+ # startup log instead (it prints the token once, on stderr):
183
+ journalctl -u w2m-rabbit | grep -A2 'operator token'
184
+ # or, for a container:
185
+ docker compose -f deploy/docker/docker-compose.yml logs relay | grep -A2 'operator token'
186
+
187
+ # Do NOT expect it in `--json` output: stdout carries only
188
+ # "operatorTokenPresent": true|false, deliberately (a token in a log is a
189
+ # leak). See docs/DEPLOY.md §8.
190
+
191
+ # 2. Give it to the dispatching side (environment or plugin config):
192
+ export W2M_OPERATOR_TOKEN=<token>
193
+
194
+ # 3. Confirm the relay requires it:
195
+ curl https://w2m.<your-domain>/healthz | grep -o '"operator_token_required":[a-z]*'
196
+ ```
197
+
198
+ **What "correct" looks like** (measured against a live relay):
199
+
200
+ | Authorization header on `POST /v1/task` | Result |
201
+ |---|---|
202
+ | `Bearer <operator_token>` | `200` with a `task_id` |
203
+ | `Bearer <device_token>` | `401` with error code `OPERATOR_REQUIRED` |
204
+ | no header | `401` with error code `OPERATOR_REQUIRED` |
205
+ | `Bearer <anything else>` | `401` with error code `OPERATOR_REQUIRED` |
206
+
207
+ So a bare `401` on `POST /v1/task` while every other call works is **this**
208
+ problem, not a broken device token. The `error.code` in the body says
209
+ `OPERATOR_REQUIRED` and the message names the credential confusion.
210
+
211
+ **Only the dispatching machine needs this token.** Adding it to every executor
212
+ would let any one of them dispatch work, which is the exact scenario the split
213
+ exists to prevent. On the client side the flag that carries it is
214
+ `w2m-localside --operator-token <t>` (or `$W2M_OPERATOR_TOKEN`), and its own
215
+ `--help` says it is "only needed when this host also submits tasks; the
216
+ Localside agent itself never sends it". See `DEPLOY.md` §8.1.
217
+
218
+ **Escape hatch, for isolated debugging only:** `--operator-token ''` turns the
219
+ requirement off and prints a loud warning; `/healthz` then reports
220
+ `"operator_token_required": false`. Never do this on a reachable deployment.
221
+
222
+ ---
223
+
224
+ ## 4. `429 RATE_LIMITED` — or the limit that never triggers
225
+
226
+ This entry has two opposite symptoms and one shared cause: whether
227
+ `--trust-proxy` matches reality.
228
+
229
+ ### 4a. Everyone gets rate-limited at once
230
+
231
+ **Symptom.** Pairing a new machine fails with `RATE_LIMITED`, even though you
232
+ have made fewer than `pair_rate_limit` attempts. A second machine pairing right
233
+ after the first fails immediately.
234
+
235
+ **Cause.** `--trust-proxy` is **off**, so the relay reads each client's IP from
236
+ the socket — which is the reverse proxy's or tunnel's loopback address for
237
+ every client. All machines therefore share one budget.
238
+
239
+ **Fix.** Start the relay with `--trust-proxy` when it sits behind a proxy or
240
+ tunnel you control, and make sure the proxy actually sets the header:
241
+
242
+ ```bash
243
+ w2m-rabbit --host 127.0.0.1 --port 8787 --state /var/lib/w2m --trust-proxy
244
+ ```
245
+
246
+ nginx must forward the client address (`deploy/nginx/w2m.conf.example` does):
247
+
248
+ ```nginx
249
+ proxy_set_header X-Real-IP $remote_addr;
250
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
251
+ ```
252
+
253
+ ### 4b. The limit never triggers
254
+
255
+ **Symptom.** A public deployment accepts unlimited pairing attempts. Brute-forcing
256
+ a pairing code looks feasible.
257
+
258
+ **Cause.** Either `--trust-proxy` is on while the relay is **directly
259
+ reachable** — so a client can forge `X-Forwarded-For` and get a fresh budget per
260
+ request — or `pair_rate_limit` was set to `0` (disabled).
261
+
262
+ **Fix.** Keep `--trust-proxy` on only when the relay is *not* directly
263
+ reachable. In shape B that means the relay stays on `127.0.0.1` and only the
264
+ proxy listens on 443; confirm from outside:
265
+
266
+ ```bash
267
+ nc -zv w2m.<your-domain> 443 # must succeed
268
+ nc -zv w2m.<your-domain> 8787 # must fail
269
+ curl https://w2m.<your-domain>/healthz | grep -o '"pair_rate_limit":[0-9]*'
270
+ ```
271
+
272
+ A non-zero `pair_rate_limit` in `/healthz` means the limit is configured. `0`
273
+ means it is off; set it back (default `5`) unless you are on a private network.
274
+
275
+ **Reading a rejection.** The error body carries everything you need:
276
+
277
+ ```json
278
+ {"error":{"code":"RATE_LIMITED",
279
+ "message":"too many /v1/pair attempts from <ip>: limit is 2 per 60s",
280
+ "detail":{"retry_after_seconds":60,"limit":2,"window_seconds":60}}}
281
+ ```
282
+
283
+ The response is a real **`429`** with a `Retry-After: 60` header, so ordinary
284
+ HTTP retry logic works. `detail.retry_after_seconds` says how long to wait; the
285
+ message names the IP the relay counted against, which tells you immediately
286
+ whether it is the real client or the proxy. Successful pairings count toward the
287
+ limit as well, so scripted onboarding of many machines may need a higher
288
+ `--pair-rate-limit` (on a trusted network only).
289
+
290
+ ---
291
+
292
+ ## 5. Every machine must re-pair after the relay restarts
293
+
294
+ **Symptom.** The relay (or the VPS, or the container) restarts, and
295
+ `w2m_devices` comes back empty. Every machine has to be paired again, and any
296
+ stored `device_token` is now rejected with `401 UNAUTHORIZED`.
297
+
298
+ **Cause.** Persistence is off, or `--state` points somewhere ephemeral:
299
+
300
+ | Cause | How to recognise it |
301
+ |---|---|
302
+ | `--no-persist` was used | `/healthz` → `"persistence":{"enabled":false,"dir":null,…}` |
303
+ | `--state` points into `/tmp`, a container's writable layer, or a path that is wiped | `/healthz` → `"dir": "/tmp/…"` or a path you do not recognise |
304
+ | The state directory is not writable by the relay's user | `/healthz` → `persistence.warnings` non-empty, or `dir` set but nothing written |
305
+ | A systemd unit without `StateDirectory=` runs as a service user with no home | the directory does not exist after start |
306
+
307
+ **Fix.** Give the relay a durable directory and confirm the relay reports it:
308
+
309
+ ```bash
310
+ # systemd: StateDirectory=w2m creates /var/lib/w2m and exports %S
311
+ w2m-rabbit --host 127.0.0.1 --port 8787 --state /var/lib/w2m
312
+
313
+ # container: mount a named volume
314
+ # volumes: [ "w2m-state:/var/lib/w2m" ]
315
+ docker compose -f deploy/docker/docker-compose.yml exec relay ls -l /var/lib/w2m
316
+
317
+ # confirm what the relay actually used:
318
+ curl https://w2m.<your-domain>/healthz | grep -o '"persistence":{[^}]*}'
319
+ ```
320
+
321
+ You should see `"enabled":true` and the directory you intended, holding:
322
+
323
+ | File | Contents |
324
+ |---|---|
325
+ | `devices.json` | machine table with device tokens |
326
+ | `ledger.jsonl` | task ledger (append-only, one JSON per line) |
327
+ | `operator-token.txt` | the operator token, mode 0600 |
328
+
329
+ **Confirm recovery worked.** Restart and check that the devices came back:
330
+
331
+ ```bash
332
+ sudo systemctl restart w2m-rabbit
333
+ curl https://w2m.<your-domain>/healthz | grep -o '"revived_devices":[0-9]*'
334
+ ```
335
+
336
+ `revived_devices` greater than zero means the device table was restored from
337
+ disk and existing device tokens remain valid — no re-pairing needed.
338
+
339
+ **If `devices.json` is corrupt**, the relay starts with an empty table and says
340
+ so (`persistence.devices_corrupt: true`) rather than failing to start. Pair
341
+ again, and restore from backup if you have one. A corrupt `ledger.jsonl` line is
342
+ skipped and counted in `ledger_lines_skipped`, with details in
343
+ `persistence.warnings` — the relay keeps running.
344
+
345
+ **Back up before you need it:**
346
+
347
+ ```bash
348
+ sudo tar czf w2m-state-$(date +%F).tgz -C /var/lib w2m
349
+ ```
350
+
351
+ ---
352
+
353
+ ## 6. The event stream disconnects frequently
354
+
355
+ **Symptom.** The local-side log shows the stream opening and closing
356
+ repeatedly, with backoff retries. Task offers are delayed or missed. The
357
+ `notice` frames mention reconnection attempts.
358
+
359
+ **Cause.** Something in the path is imposing an **idle timeout** shorter than
360
+ the stream's quiet periods, or the tunnel itself is dropping.
361
+
362
+ The relay keeps the stream alive with a `: keepalive` comment every 15 seconds,
363
+ which is enough for most proxies. Reconnects usually mean one of:
364
+
365
+ | Cause | Where to look |
366
+ |---|---|
367
+ | Proxy read timeout shorter than the gaps | nginx `proxy_read_timeout` (default 60s); Caddy `read_timeout` |
368
+ | Tunnel idle timeout on a free tier | the tunnel client's log; Cloudflare/ngrok both close idle connections |
369
+ | MTU / path issues on the tunnel | large frames stall while keepalives survive — see §6's note below |
370
+ | The relay restarted | `/healthz` `relay_id` changed; the client logs a `relay_id` change |
371
+
372
+ **Fix.**
373
+
374
+ 1. Raise the proxy's read timeout past the keepalive interval — `86400s` in the
375
+ provided nginx example, `read_timeout 0` in the Caddy example.
376
+ 2. On a tunnel, prefer a stable named tunnel over a quick one, and check the
377
+ tunnel client's own idle settings.
378
+ 3. Treat reconnects as normal-but-visible: the client **is** designed to
379
+ reconnect with jittered backoff, and it exposes the attempt number in a
380
+ `notice` frame precisely so remote debugging does not require guessing.
381
+ A handful of reconnects after a network blip is fine. A tight loop is not.
382
+
383
+ **Confirm the diagnosis.** Watch the stream and measure the gap between frames:
384
+
385
+ ```bash
386
+ curl -sS -N -H "Authorization: Bearer <device_token>" \
387
+ https://w2m.<your-domain>/v1/stream \
388
+ | while IFS= read -r line; do printf '%s %s\n' "$(date +%T)" "$line"; done
389
+ ```
390
+
391
+ A healthy stream prints `: keepalive` roughly every 15 seconds and stays up. If
392
+ keepalives stop but the connection stays open, a proxy is buffering (see §1).
393
+ If the connection closes at a regular interval — 30s, 60s, 100s — that interval
394
+ is an idle timeout, and the component owning that number is the culprit.
395
+
396
+ **After a relay restart, expect one reconnect and a sequence reset.** The `ready`
397
+ frame carries the relay's `relay_id`; when it changes, the client clears its
398
+ local sequence cursor because old sequence numbers mean nothing in a new
399
+ process. If a stream is resumed and the relay no longer has the needed history,
400
+ the client receives `notice/REPLAY_TRUNCATED` with `oldest_available_seq`,
401
+ which tells it where to re-align instead of just "cannot catch up".
402
+
403
+ ---
404
+
405
+ ## 7. Latency is high — how high is too high?
406
+
407
+ **Symptom.** Tasks take noticeably longer to be picked up than on a LAN. The
408
+ question "is this normal for this link?" has no answer without numbers.
409
+
410
+ **How to measure.** `w2m_status` exposes the round-trip time the agent measures
411
+ on each heartbeat, kept as a rolling window of the last five samples
412
+ (`state.rttMs`). Ask for it from the model side:
413
+
414
+ ```
415
+ w2m_status
416
+ ```
417
+
418
+ and read `rttMs`. Cross-check the raw link with the platform tools:
419
+
420
+ ```bash
421
+ # From a client machine to the relay
422
+ ping -c 5 <relay-host> # macOS/Linux
423
+ ping -n 5 <relay-host> # Windows
424
+ # TCP-level, which includes TLS handshake cost:
425
+ curl -o /dev/null -sS -w 'connect=%{time_connect}s tls=%{time_appconnect}s total=%{time_total}s\n' \
426
+ https://w2m.<your-domain>/healthz
427
+ ```
428
+
429
+ **What is normal.** Latitude, not hops, dominates — a same-continent link is
430
+ single-digit to low-tens of milliseconds; an intercontinental link is routinely
431
+ 150–300 ms. Rough expectations:
432
+
433
+ | Path | Typical RTT | Notes |
434
+ |---|---|---|
435
+ | Same LAN / tailnet, same region | < 5 ms | if you see more, suspect the tunnel, not physics |
436
+ | Same continent, over the internet | 20–80 ms | normal |
437
+ | Cross-continent | 150–300 ms | normal; task *dispatch* latency is dominated by this |
438
+ | Cross-continent via a free tunnel | 200–500 ms | the tunnel adds a hop and a TLS handshake |
439
+ | Anything above ~500 ms sustained | investigate | usually a mis-routed tunnel edge, not distance |
440
+
441
+ **Interpretation.** RTT affects how quickly an offer reaches a machine, not how
442
+ long the command itself takes — the command runs locally on the target machine.
443
+ A 200 ms RTT added to a 30-second build is irrelevant; added to a hundred tiny
444
+ commands it is not. If `rttMs` is far above what `ping` reports, the difference
445
+ is handshake/TLS overhead, and reusing a stable connection (a named tunnel
446
+ rather than a quick one) is the fix.
447
+
448
+ **Persistent outliers.** If one machine has much higher `rttMs` than the
449
+ others, check whether it is going through a different tunnel edge or a relayed
450
+ (instead of direct) Tailscale path:
451
+
452
+ ```bash
453
+ tailscale ping <relay-host> # "via DERP" means a relayed path, not direct
454
+ ```
455
+
456
+ ---
457
+
458
+ ## 8. Other things worth checking before you file a bug
459
+
460
+ | Symptom | Likely cause | First command |
461
+ |---|---|---|
462
+ | `401 UNAUTHORIZED` on every endpoint | the machine never paired, or the device token was replaced by a later pairing | check `$DSH_HOME/xclient/device.json` exists; re-pair |
463
+ | Two "machines" on one host share an identity | both processes share one `DSH_HOME` | give each a separate `DSH_HOME` (see `DEPLOY.md` §8) |
464
+ | `400 PAIRING_INVALID: unknown pairing code` | the code was already consumed (codes rotate after each successful pairing) or it expired | read the **current** code from the relay log |
465
+ | `/healthz` `relay_id` changes on every request | the relay is crash-looping and restarting | `journalctl -u w2m-rabbit -n 200` |
466
+ | `403` from the proxy, never reaching the relay | proxy-level rule (IP allow-list, WAF, auth) | check the proxy's own access log |
467
+ | The model-side tools report no machines | the plugin is pointed at a different relay, or the operator token is missing for dispatch | compare `rabbitUrl` with `curl <rabbitUrl>/healthz` |
468
+
469
+ **A note on which status code to trust.** The relay now returns correct HTTP
470
+ statuses — `401` for `UNAUTHORIZED` and `OPERATOR_REQUIRED`, `429` for
471
+ `RATE_LIMITED`, `404` for `NOT_FOUND` — **and** a JSON `error.code`. Use the
472
+ status for retry/backoff decisions and the code for diagnosis; the code is the
473
+ stable contract (`PROTOCOL.md`), the status is the HTTP-idiomatic view of it.
474
+
475
+ ---
476
+
477
+ ## 9. The direct path is never used (`transport: "relay"`)
478
+
479
+ Start with the field that was put there for exactly this: `w2m_wait` reports
480
+ `transport` and `p2p.reason` per machine, and `w2m_report` prints the same as a
481
+ `路径` column. The reason names the failure; guessing from "it feels slow" does not.
482
+
483
+ | `p2p.reason` | What it means | What to do |
484
+ |---|---|---|
485
+ | `P2P_PUNCH_TIMEOUT` | HELLOs went out, nothing came back within the budget | read `p2p.mapping` — see below |
486
+ | `P2P_NO_CANDIDATES` | the peer has never announced, or its announcement expired | is the agent running, and is `p2pMode` not `relay` on it? `w2m_status` shows `peers_announced` on the relay |
487
+ | `P2P_DISABLED` | this side is `p2pMode: relay` | set `p2pMode: auto` (or `direct`) and restart |
488
+ | `P2P_UNAVAILABLE` | `p2pMode: direct` and the punch failed, so the relay offer was refused on purpose | that is the mode working; use `auto` if you want the fallback |
489
+
490
+ **`mapping: "endpoint-dependent"` is the answer, not a mystery.** Ask three STUN
491
+ servers for your address from one socket:
492
+
493
+ ```bash
494
+ node -e "import('./src/agent/stun.mjs').then(async (s) => {
495
+ const { socket } = await s.bindUdpSocket();
496
+ console.log(await s.discoverReflexive({ socket, servers: ['202.182.123.154:3478','stun.cloudflare.com:3478','stun.l.google.com:19302'] }));
497
+ socket.close(); })"
498
+ ```
499
+
500
+ If each server reports a **different port**, the network is a symmetric NAT: the
501
+ address a peer is told about is not the address your packets will come from, and
502
+ **no change on any server can fix that**. Measured twice on real networks
503
+ (iPhone hotspot in v0.3.9, China Mobile in v0.4.0 — three servers, three ports:
504
+ 36028, 5855, 26713). Use the relay, or put both machines on a WireGuard/Tailscale
505
+ network where there is no NAT between them.
506
+
507
+ One direction still works through a symmetric NAT, and it is worth understanding
508
+ why: a **punch you initiate** opens a mapping, and the peer's answer to the
509
+ datagram it just received returns through that mapping. So a symmetric-NAT
510
+ machine can reach a public peer (the shared server, a VPS) directly, while the
511
+ reverse — being dialled — cannot work. That is why the dispatcher dials the
512
+ executor and not the other way round.
513
+
514
+ **A punch that succeeds on one host proves nothing about NAT traversal.** Two
515
+ sockets on one machine share a path with no translator between them. If you are
516
+ debugging, make sure the two ends are actually on two networks.
517
+
518
+ **Windows Defender Firewall is a second NAT you cannot see in `mapping`.** The
519
+ punch socket is an ordinary inbound UDP port, so a *dialled* Windows machine can
520
+ drop an unsolicited HELLO while its own outbound dials work fine. That asymmetry
521
+ matters because the dispatcher is the side that dials: a Windows machine running
522
+ DSH reaches its peers directly, but a Windows machine acting as the executor for
523
+ somebody else may need an inbound rule for `node.exe` (UDP) before it answers.
524
+ If a punch fails in exactly one direction and `mapping` looks punchable on both
525
+ sides, look here first. On a host you control, pin the port with `--p2p-port <n>` so the
526
+ rule survives a restart: until v0.4.1 the node bound an ephemeral port, and a stale rule
527
+ is invisible except as a punch that times out and a task that quietly uses the relay.
528
+
529
+ ---
530
+
531
+ ## 10. `refused` with `COMMAND_NOT_ALLOWED`, and the command looks allowed
532
+
533
+ The allow-list matches **the whole argv, token by token**, and a string entry is
534
+ split on whitespace:
535
+
536
+ ```jsonc
537
+ ["node --test", "git status --porcelain"] // tokens: [node, --test] / [git, status, --porcelain]
538
+ ```
539
+
540
+ Two consequences that are easy to walk into:
541
+
542
+ * `node -e console.log` does **not** allow `node -e console.log(1)`: the third
543
+ token differs. Allow the prefix you actually mean (`node -e`), and remember that
544
+ this is a shell-free prefix match, not a glob.
545
+ * A single path containing a space **cannot** be written as a string entry —
546
+ `C:\Program Files\nodejs\node.exe` splits into two tokens and matches nothing.
547
+ Use an array entry, which is taken verbatim:
548
+
549
+ ```jsonc
550
+ [["C:\\Program Files\\nodejs\\node.exe", "-e"]]
551
+ ```
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`.)
556
+
557
+ To see what the machine actually received, ask the relay for the last task and
558
+ read the machine's `refusal_reason`, or run the agent in the foreground: the
559
+ refusal is logged with the full `argv` next to it.
560
+
@@ -0,0 +1,81 @@
1
+ # UI cards: what the contract actually is, and why nothing was added
2
+
3
+ Status: **settled.** No new card types were introduced, because the shipped runtime has no card
4
+ registry to choose from. This file records the evidence so the question is not re-opened from memory.
5
+
6
+ ## The question that was open
7
+
8
+ "UI cards" was listed as a v0.3.3 item, implying the eight W2M tools should render as something richer
9
+ than they do. All eight return `card: 'generic'`. That looks like a placeholder awaiting real card
10
+ types, and it sat open for several rounds for that reason.
11
+
12
+ ## What the runtime does
13
+
14
+ `defineTool` treats the return value as **opaque**. From the shipped
15
+ `@deepseek-ai/dsh-tools/lib/index.js`:
16
+
17
+ ```js
18
+ if (userPresentCall) tool.presentCall = (args) => {
19
+ if (validate(args).length > 0) return void 0;
20
+ return userPresentCall(args);
21
+ };
22
+ ```
23
+
24
+ That is the entire handling. The object is forwarded unvalidated — not `card`, not `title`, not
25
+ `kind`, not `rawInput`. Two consequences:
26
+
27
+ 1. **There is no list of card types to pick from.** Nothing in the shipped bundle reads `card`, so
28
+ there is no vocabulary to be wrong about and none to expand into.
29
+ 2. **A wrong field cannot be caught by the runtime.** If a card value were invalid, it would fail
30
+ silently in the UI rather than at the boundary — which is why guessing richer cards was the wrong
31
+ move, and why this file exists instead.
32
+
33
+ ## The only two reference implementations
34
+
35
+ These are the sole evidence for what the fields mean, and they agree on `card`:
36
+
37
+ | where | card | kind | rawInput |
38
+ |---|---|---|---|
39
+ | `dsh-tools/lib/index.js:1445` — built-in `run_code` | `"generic"` | `"execute"` | `args.code` (a **string**) |
40
+ | `dsh-plugin-manager/lib/types/tools.js:85` — the plugin manager | `'generic'` | `args.action.startsWith('list_') ? 'read' : 'other'` | `args` |
41
+
42
+ Both use `generic`. `kind` varies by action: the plugin manager reports `read` for its listing actions
43
+ and `other` for the ones that change something. `rawInput` is not necessarily an object.
44
+
45
+ ## The decision
46
+
47
+ Keep `card: 'generic'` everywhere — it is the only value with a reference implementation — and make
48
+ `kind` **truthful**, since that is the one field whose meaning is demonstrated by example and the one a
49
+ user actually reads. Concretely:
50
+
51
+ | tool | before | after | why |
52
+ |---|---|---|---|
53
+ | `w2m_devices`, `w2m_wait`, `w2m_report`, `w2m_status`, `w2m_history`, `w2m_stats` | `read` | `read` | accurate; unchanged |
54
+ | `w2m_run` with `write: true` | `write` | `write` | kept: it executes on other machines, which `read` would understate |
55
+ | `w2m_run` without `write` | `read` | `read` | unchanged |
56
+ | **`w2m_update`, `action: 'check'`** | **`read`** | **`other`** | **the defect: this action can install a new version into the profile it runs in** |
57
+ | `w2m_update`, `action: 'status'` | `read` | `read` | accurate; it only reports |
58
+
59
+ `w2m_update` reporting `read` for a check-and-install cycle was a false claim about safety in exactly
60
+ the place a user looks to judge safety — the same class of defect as the `intervalDays` option that
61
+ silently did nothing, and worth fixing for the same reason: a hint that lies is worse than no hint.
62
+
63
+ ## What was not done, and why
64
+
65
+ - **No new card types.** None exist to use.
66
+ - **`presentResult` was not adopted either.** It is a second, equally unvalidated hook
67
+ (`dsh-tools/lib/index.js:878`) whose result-side contract appears nowhere in the shipped bundle.
68
+ Adopting it would be the same guess this file declines to make.
69
+
70
+ To go further, the missing input is a **consumer**: the Web UI code that switches on `card`. It was not
71
+ in the extracted bundle (`dsh-tools`, `dsh-plugin-manager`), and finding it would mean extracting
72
+ further packages from the 121 MB `app.asar`. That is not worth doing for a cosmetic hint while a
73
+ correct and verified one is already in place.
74
+
75
+ ## Verification
76
+
77
+ `test/tool-cards.test.mjs` — five tests covering: every tool yields a `generic` card with a non-empty
78
+ title and a `kind`; the check-and-install action is `other` while `status` and the default are `read`;
79
+ a write-capable run stays distinguishable from a read-only one; **no credential reaches `rawInput`**,
80
+ which matters because that field is rendered; and every `presentCall` survives being called with no
81
+ arguments, which is what the runtime does on a schema-invalid call.
package/lib/index.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Intentionally empty plugin entry point.
3
+ *
4
+ * The profile mounts this package's `tools` subpath -- named explicitly by
5
+ * `cordis.patch.yml`'s insert entry -- so that reading the profile tells you
6
+ * which module of the package becomes the plugin, instead of relying on a
7
+ * "root export is the plugin" convention.
8
+ *
9
+ * This file exists only so the manifest never points at a path that does not
10
+ * resolve. All real behaviour lives in `../src/plugin/tools.mjs`, re-exported
11
+ * through `./tools.js`.
12
+ */
13
+ export {};
package/lib/tools.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Package-facing plugin entry: `@twinsearth/w2m-dsh-plugin/tools`.
3
+ *
4
+ * This is the module named by `cordis.patch.yml`, so it is the seam between the
5
+ * published package layout (`lib/`, what npm ships as the documented entry) and
6
+ * the implementation (`src/`, where the code is actually readable).
7
+ *
8
+ * Nothing is re-implemented here on purpose: a second definition of the plugin
9
+ * surface would be a second thing to keep in sync.
10
+ */
11
+ export { apply, inject, resetDefineToolCacheForTests } from '../src/plugin/tools.mjs';