@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.7
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 +1309 -0
- package/LICENSE +21 -0
- package/PROTOCOL-v0.1.2.md +165 -0
- package/PROTOCOL-v0.3.0.md +454 -0
- package/PROTOCOL-v0.3.9.md +207 -0
- package/PROTOCOL-v0.4.0.md +255 -0
- package/PROTOCOL.md +339 -0
- package/README.md +665 -2
- package/bin/w2m-localside.mjs +303 -0
- package/bin/w2m-rabbit.mjs +396 -0
- package/bin/w2m-stun.mjs +231 -0
- package/cordis.patch.yml +94 -0
- package/deploy/caddy/Caddyfile.example +118 -0
- package/deploy/docker/Dockerfile +144 -0
- package/deploy/docker/README.md +338 -0
- package/deploy/docker/docker-compose.yml +179 -0
- package/deploy/nginx/w2m.conf.example +196 -0
- package/deploy/shared-server/README.md +118 -0
- package/deploy/shared-server/install.sh +255 -0
- package/deploy/systemd/w2m-rabbit.service +150 -0
- package/deploy/systemd/w2m-stun.service +69 -0
- package/deploy/windows/README.md +115 -0
- package/deploy/windows/install-service.ps1 +195 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
- package/deploy/windows/winsw/w2m-localside.xml +50 -0
- package/docs/DEPLOY.md +902 -0
- package/docs/LINT-AUDIT.md +492 -0
- package/docs/PIPELINE-DESIGN.md +95 -0
- package/docs/TROUBLESHOOTING.md +560 -0
- package/docs/UI-CARDS.md +81 -0
- package/lib/index.js +24 -0
- package/lib/tools.js +11 -0
- package/package.json +133 -4
- package/src/agent/agent.mjs +3317 -0
- package/src/agent/allowed-commands.mjs +116 -0
- package/src/agent/caps.mjs +437 -0
- package/src/agent/exec.mjs +356 -0
- package/src/agent/git.mjs +616 -0
- package/src/agent/identity.mjs +253 -0
- package/src/agent/p2p-node.mjs +1243 -0
- package/src/agent/p2p.mjs +605 -0
- package/src/agent/resolve.mjs +148 -0
- package/src/agent/spool.mjs +252 -0
- package/src/agent/stun.mjs +397 -0
- package/src/agent/url.mjs +127 -0
- package/src/plugin/auto-update.mjs +574 -0
- package/src/plugin/schedule.mjs +452 -0
- package/src/plugin/shared-config.mjs +264 -0
- package/src/plugin/tools.mjs +3891 -0
- package/src/plugin/update-install.mjs +764 -0
- package/src/plugin/update-source.mjs +686 -0
- package/src/relay/metrics.mjs +284 -0
- package/src/relay/peers.mjs +180 -0
- package/src/relay/persistence.mjs +207 -0
- package/src/relay/report.mjs +591 -0
- package/src/relay/server.mjs +1187 -0
- package/src/relay/state.mjs +2004 -0
- package/src/relay/stun-server.mjs +733 -0
- package/src/signing.mjs +359 -0
- 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
|
+
|
package/docs/UI-CARDS.md
ADDED
|
@@ -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,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package root entry.
|
|
3
|
+
*
|
|
4
|
+
* TWO READERS, ONE EXPORT LIST
|
|
5
|
+
*
|
|
6
|
+
* `cordis.patch.yml` names the `tools` subpath explicitly, and that stays true: mounting this
|
|
7
|
+
* profile reads the patch, sees `@twinsearth/w2m-dsh-plugin/tools`, and loads `./tools.js`. Nothing
|
|
8
|
+
* about the mounted plugin changes because of this file.
|
|
9
|
+
*
|
|
10
|
+
* But the root entry is what everything else looks at. The DSH plugin listing requirements -- and
|
|
11
|
+
* the marketplace checklists that copy them -- ask for a package that "exports an `apply(ctx)`
|
|
12
|
+
* module", and a package that does not is one of the most common reasons a submission is refused.
|
|
13
|
+
* This file used to be documentation only, so importing the package root yielded **no exports at
|
|
14
|
+
* all** (`Object.keys() === []`): a plugin that works perfectly when mounted by its patch and looks
|
|
15
|
+
* like an empty package to every tool that inspects it. That is a bad trade for one re-export line.
|
|
16
|
+
*
|
|
17
|
+
* WHY NOT MOVE THE PLUGIN HERE
|
|
18
|
+
*
|
|
19
|
+
* Re-exporting is not relocating: `./tools.js` stays the single definition, so there is still
|
|
20
|
+
* exactly one implementation of the plugin surface to keep in sync. Mounting *both* this entry and
|
|
21
|
+
* the subpath would register the eight tools twice, which is why the patch keeps naming the subpath
|
|
22
|
+
* instead of falling back to the "the root is the plugin" convention.
|
|
23
|
+
*/
|
|
24
|
+
export { apply, inject, resetDefineToolCacheForTests } from './tools.js';
|
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';
|