@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +1309 -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 +24 -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
package/README.md CHANGED
@@ -1,3 +1,666 @@
1
- # Temporary Holding Version
1
+ # DSH: Windows2MacOS
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **One DeepSeek account. One instruction. Every machine you own runs the same project.**
4
+
5
+ English | [中文](#中文说明)
6
+
7
+ [![CI](https://github.com/TwinsEarth/dsh-windows2macos/actions/workflows/ci.yml/badge.svg)](https://github.com/TwinsEarth/dsh-windows2macos/actions/workflows/ci.yml)
8
+ [![npm](https://img.shields.io/npm/v/@twinsearth/w2m-dsh-plugin?label=npm)](https://www.npmjs.com/package/@twinsearth/w2m-dsh-plugin)
9
+ [![DSH plugin](https://img.shields.io/badge/DSH-plugin-4c6ef5)](#install)
10
+ [![dependencies](https://img.shields.io/badge/runtime%20dependencies-0-brightgreen)](#design-notes)
11
+ [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
12
+ [![platforms](https://img.shields.io/badge/CI-windows%20%7C%20macos%20%7C%20linux-4c6ef5)](#verified-and-not-verified)
13
+
14
+ ---
15
+
16
+ ## The problem
17
+
18
+ You have a Windows desktop and a Mac laptop. You are working on one project. You
19
+ want to say *"run the test suite"* once, and have **both** machines run it — then
20
+ see whether they agree.
21
+
22
+ DeepSeek Harness cannot do this today, and not by accident:
23
+
24
+ - one `dsh` process serves **one machine** — a profile is a local pnpm workspace;
25
+ - its transport is **loopback only** (the web server accepts `127.0.0.1`/`0.0.0.0`
26
+ and the CLI refuses `--host 0.0.0.0`, because it ships no TLS or origin policy);
27
+ - the official agent-team feature is explicit that it does **not** support
28
+ teammates with separate working directories or **several processes
29
+ coordinating over one team**.
30
+
31
+ So the cross-machine capability has to be built. This is that build.
32
+
33
+ ## What you get
34
+
35
+ ```
36
+ you, on any machine, talking to DSH
37
+ │
38
+ ▼
39
+ ┌──────────────────────────────────┐ ┌──────────────────────────┐
40
+ │ Windows · DSH session │ │ macOS · DSH session │
41
+ │ w2m plugin (8 tools) │ │ w2m plugin (8 tools) │
42
+ │ │ │ │ │ │
43
+ │ Localside agent │ │ Localside agent │
44
+ │ · three anchors │ │ · three anchors │
45
+ │ · local spool │ │ · local spool │
46
+ │ · executes argv │ │ · executes argv │
47
+ └──────────┬───────────────────────┘ └──────────┬───────────────┘
48
+ │ outbound POST + SSE │
49
+ ▼ ▼
50
+ ┌──────────────────────────────────────────────────────────┐
51
+ │ Rabbit relay: rendezvous, ledger, STUN responder │
52
+ └──────────────────────────────────────────────────────────┘
53
+ ```
54
+
55
+ Every machine only ever makes **outbound** connections. No public IP, no port
56
+ forwarding, no SSH server on Windows (which, measured, is not installed by
57
+ default — `ssh.exe` exists, `sshd` does not). The relay is the rendezvous and the
58
+ ledger; the payload path is a UDP hole punch between the machines themselves — on
59
+ by default in v0.4.0 — and it falls back to the relay loudly, naming the reason,
60
+ when the network will not allow it.
61
+
62
+ ### The direct path (v0.4.0, default)
63
+
64
+ Both machines announce the address a STUN server sees them as, the dispatching
65
+ machine punches to the other's candidate, and the task offer travels over the
66
+ punched channel instead of the relay. The relay holds its own copy of that offer
67
+ back for about a second while the punch is attempted, and offers it anyway if the
68
+ punch does not land — so a failed punch costs latency, not the task.
69
+
70
+ The result *notice* returns the same way, with one stated limit: the frame carries
71
+ the task and machine identity, not the result payload. `result_path` is one of the
72
+ envelope's own fields and the envelope's hash covers it, so the acknowledgement has
73
+ to be known before the envelope is built. The direct path therefore saves the
74
+ dispatcher the wait for the relay's copy, not the bytes — the payload still reaches
75
+ everyone through the ledger, which is the only writer allowed to hold it.
76
+
77
+ ```yaml
78
+ - id: w2m
79
+ config:
80
+ p2pMode: auto # auto (default) | direct | relay
81
+ # stunServers: ['202.182.123.154:3478'] # default: the shared server, then three public ones
82
+ ```
83
+
84
+ `auto` tries the direct path and falls back with the reason attached; `direct`
85
+ refuses the relay copy rather than falling back, so a punch failure becomes a
86
+ visible failure; `relay` is byte-for-byte v0.3.9 — no UDP socket is bound at all.
87
+ Every result carries `transport` plus a `p2p` object saying which path the offer
88
+ took and which path the result took, and `w2m_wait` shows it per machine.
89
+
90
+ **Why `auto` and not `direct`.** Punching cannot succeed behind a symmetric NAT,
91
+ and that is measured rather than assumed. On the reference Windows client network
92
+ (China Mobile), three STUN servers reported three different mapped ports for one
93
+ socket — `endpoint-dependent`, punchable: false. A default that hard-required the
94
+ direct path would break every deployment on such a network the moment it
95
+ upgraded. See [PROTOCOL-v0.4.0.md](PROTOCOL-v0.4.0.md) §1.
96
+
97
+ ### Two coordination modes
98
+
99
+ | Mode | What it does | When to use |
100
+ |---|---|---|
101
+ | `replicate` | every machine runs the **same** command; results compared side by side | "does this change pass on both platforms?" |
102
+ | `split` | work divided by `index` (`explicit` or `modulo` sharding) | one large test suite, several machines |
103
+
104
+ ### Six aggregation states
105
+
106
+ Not four — because "the environments differ" and "the results differ" are
107
+ different findings, and collapsing them turns noise into bug reports.
108
+
109
+ | State | Meaning |
110
+ |---|---|
111
+ | `consistent` | every machine agreed on every comparable field |
112
+ | `divergent` | machines disagreed — the report names the field and each machine's value |
113
+ | `divergent-platform` | only toolchain/platform differ, and the difference is confined to stdout text — expected |
114
+ | `failed` | every machine failed |
115
+ | `partial` | some succeeded, some failed |
116
+ | `unverifiable` | an **anchor** did not match, so comparison is refused |
117
+
118
+ ## Install
119
+
120
+ > **This package can run three ways, and all three start the same binaries.**
121
+ >
122
+ > ```bash
123
+ > node bin/w2m-rabbit.mjs ... # from a clone (what the examples below use)
124
+ > npx @twinsearth/w2m-dsh-plugin ... # if it is on a registry
125
+ > w2m-rabbit ... # once installed, via its bin entry
126
+ > ```
127
+ >
128
+ > The examples use the clone form because that is what is verified in this
129
+ > repository. Nothing needs to be installed to use the relay or the agent: they
130
+ > are plain Node scripts with **no runtime dependencies**.
131
+
132
+ ### 1. The relay + STUN (once, on any always-on machine)
133
+
134
+ > **v0.4.0 defaults to a shared server, so this step is optional.** With no
135
+ > `rabbitUrl` configured, the plugin uses `http://202.182.123.154:8787` — a
136
+ > rendezvous, ledger and STUN responder that is already running. Point it at your
137
+ > own host when you want your own; nothing else changes.
138
+
139
+ ```bash
140
+ node bin/w2m-rabbit.mjs --host 0.0.0.0 --port 8787 --state ~/.dsh/xclient/rabbit
141
+ node bin/w2m-stun.mjs --host 0.0.0.0 --port 3478 # the punch needs this
142
+ ```
143
+
144
+ `w2m-rabbit` prints a **pairing code**. Pair the first machine, and the relay
145
+ immediately prints a **new** code — so you can pair the second machine without
146
+ restarting anything. (For scripted onboarding, start it with
147
+ `pairingCodeReusable: true` and one code stays valid for its whole TTL.)
148
+
149
+ `w2m-stun` answers "what address does the world see me as", which is the address a
150
+ peer has to aim at. Machines fall back to three public STUN servers if it is not
151
+ there, so leaving it out degrades the punch rather than breaking it.
152
+
153
+ Deploying both on a fresh host, with the proxy and firewall rules, is one command:
154
+ `deploy/shared-server/install.sh` — see
155
+ [deploy/shared-server/README.md](deploy/shared-server/README.md), including the
156
+ part about TLS that this shape does not have by default.
157
+
158
+ ### 2. Localside (on every machine that should run the project)
159
+
160
+ ```bash
161
+ node bin/w2m-localside.mjs \
162
+ --rabbit http://<relay-host>:8787 \
163
+ --pair PAIR-XXXXXXXX \
164
+ --project /path/to/your/project \
165
+ --name win-desktop \
166
+ --state ~/.dsh/xclient/localside-win \
167
+ --p2p-mode auto
168
+ # --p2p-port 41234 # v0.4.1: pin the punch socket, so an inbound firewall
169
+ # # rule names one port and survives a restart
170
+ ```
171
+
172
+ `--allowed-commands` is a **default-deny whitelist** as a JSON array. Nothing
173
+ runs unless it matches a prefix:
174
+
175
+ ```bash
176
+ --allowed-commands '["node --test","git status --porcelain"]'
177
+ ```
178
+
179
+ > The allow-list matches the whole `argv`, token by token, and a string entry is
180
+ > split on whitespace — so `node -e console.log` does **not** allow
181
+ > `node -e console.log(1)`, and a path containing a space has to be written as an
182
+ > array entry. See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) §10.
183
+
184
+ > ⚠️ **v0.0.1 note:** Localside is verified as a standalone process. Running it
185
+ > *in-process* from the plugin (`autoStartAgent: true`) is implemented but has
186
+ > not been exercised end-to-end in this release — start it as its own process.
187
+
188
+ ### 3. The DSH plugin
189
+
190
+ **From npm** — the shortest form, and the one the marketplaces install:
191
+
192
+ ```bash
193
+ dsh plugin --profile <profile-name> add @twinsearth/w2m-dsh-plugin
194
+ ```
195
+
196
+ **From the release tarball** — the same bytes, and it needs no registry at all:
197
+
198
+ ```bash
199
+ dsh plugin --profile <profile-name> add \
200
+ https://github.com/TwinsEarth/dsh-windows2macos/releases/download/v0.4.6/twinsearth-w2m-dsh-plugin-0.4.6.tgz
201
+ ```
202
+
203
+ **From the repository** — the form a marketplace's one-click installer uses (it clones
204
+ the repo, builds nothing, and registers `cordis.patch.yml`):
205
+
206
+ ```bash
207
+ dsh plugin --profile <profile-name> install TwinsEarth/dsh-windows2macos
208
+ ```
209
+
210
+ All three end up with the same package: `release.yml` packs the tarball with
211
+ `scripts/pack.mjs`, the release publishes it with `SHA256SUMS`, and the npm publish uses
212
+ that exact file rather than repacking — so "which artifact did you install?" has one
213
+ answer, not three.
214
+
215
+ > Whichever form you use, the installer must end up with the plugin in the profile's
216
+ > `node_modules` **and** a row in the profile's `cordis.patch.yml`. A bundle that is
217
+ > listed in `package.json` but never composed is the failure this project hit once
218
+ > already — see `cordis.patch.yml`'s header for what that looked like.
219
+
220
+ Then give it the relay URL in that profile's `cordis.patch.yml` — or leave
221
+ `rabbitUrl` empty to use the shared server:
222
+
223
+ ```yaml
224
+ - id: w2m
225
+ config:
226
+ rabbitUrl: '' # empty => the shared server's default
227
+ operatorToken: '' # required to dispatch; the relay prints it once
228
+ stateDir: /absolute/path/to/$DSH_HOME/xclient # `!!js process.env.DSH_HOME` can evaluate to undefined
229
+ machineName: win-desktop
230
+ p2pMode: auto
231
+ ```
232
+
233
+ Restart DSH. You should see eight tools: `w2m_devices`, `w2m_run`, `w2m_wait`,
234
+ `w2m_report`, `w2m_status`, `w2m_update`, `w2m_history`, `w2m_stats`.
235
+
236
+ > `dsh` may not be on your `PATH`. On a packaged install it lives under
237
+ > `resources/runtime/cli/bin/`. If `dsh` is not found, call it by path.
238
+
239
+ ### What this plugin touches, before you install it
240
+
241
+ Stated here and machine-readably in `package.json`'s `disclosure`, because installing a
242
+ plugin runs its code with your permissions:
243
+
244
+ | | |
245
+ |---|---|
246
+ | **Cloud** | yes — a relay is required. The default is the shared server (`202.182.123.154:8787`, STUN on `:3478`); point `rabbitUrl` at your own host, or run the relay yourself with `deploy/shared-server/install.sh` |
247
+ | **Offline** | no. The relay is the ledger; peer-to-peer only moves the task offer, and the result still reaches the ledger |
248
+ | **Credentials** | the operator token (profile config or `W2M_OPERATOR_TOKEN`) authorises *dispatched work*; the device token lives in `$DSH_HOME/xclient/device.json` (mode 0600 where the platform honours it) and authorises *taking* work |
249
+ | **Filesystem** | reads and writes inside your project directory (anchors, fingerprints), plus its own spool and state under `--state` |
250
+ | **Network** | the configured relay, the STUN servers, and UDP to the peer machines. Peers can reach this machine's punch port, which is ephemeral unless you pin it with `--p2p-port` |
251
+ | **Jurisdiction** | data crosses between wherever your machines are and wherever the relay is; the reference deployment is CN ↔ JP |
252
+
253
+ ## Use
254
+
255
+ Then just talk to DSH:
256
+
257
+ > List my machines, then run `node --test` on all of them at the same commit and
258
+ > tell me whether the output matches.
259
+
260
+ The model calls `w2m_devices` → `w2m_run` → `w2m_wait` → `w2m_report` and hands
261
+ you the comparison.
262
+
263
+ ## Why you can trust the comparison
264
+
265
+ A commit SHA alone is not enough. Measured: on a **dirty** working tree,
266
+ `git checkout --detach <sha>` **exits 0 and keeps the local changes** — HEAD
267
+ matches the base while the working tree does not. Compare on SHA alone and you
268
+ will report a logic difference that is really a "these are not the same files"
269
+ difference.
270
+
271
+ So every result carries **three anchors**:
272
+
273
+ | Anchor | What it proves |
274
+ |---|---|
275
+ | `base_commit` | both machines started from the same commit |
276
+ | `pre_tree_fingerprint` | both had the **same working-tree bytes**, including untracked files |
277
+ | `command_hash` | both ran the same argv, in the same shell mode, in the same relative cwd |
278
+
279
+ The fingerprint algorithm is `git-temp-index-tree/v1`: a throwaway index file
280
+ (`git read-tree` → `git add -A` → `git write-tree`) that never touches your
281
+ working tree. Each call gets its **own** temporary index — sharing one
282
+ `GIT_INDEX_FILE` across processes makes them fail with `idx.lock: File exists`.
283
+
284
+ `git stash create` is *not* used: measured, it drops untracked files and returns
285
+ an empty string in both the "clean" and "only untracked files" cases, so the two
286
+ are indistinguishable.
287
+
288
+ **Prerequisite:** put this in your project root, or the two machines will never
289
+ fingerprint alike:
290
+
291
+ ```gitattributes
292
+ * text=auto eol=lf
293
+ ```
294
+
295
+ Measured: with that line, a `core.autocrlf=true` checkout and a
296
+ `core.autocrlf=false` checkout produce byte-identical working trees. Without it,
297
+ one is CRLF and the other LF.
298
+
299
+ ## Across networks and regions
300
+
301
+ > **New in 0.1.2.** Before this version the relay only worked at the root of a
302
+ > host: endpoints were built with `new URL('/v1/stream', rabbitUrl)`, which
303
+ > discards a path prefix, so anything mounted under `/w2m` answered 404 to
304
+ > everything. Sub-path deployment now works, and sending work requires its own
305
+ > credential. See [CHANGELOG](CHANGELOG.md#012--2026-10-07).
306
+
307
+ Machines only ever make **outbound** connections, so the relay can be anywhere
308
+ both sides can reach. Four shapes are supported and documented end to end in
309
+ [docs/DEPLOY.md](docs/DEPLOY.md):
310
+
311
+ | Shape | TLS terminated by | `rabbitUrl` looks like |
312
+ |---|---|---|
313
+ | **The shared server** (v0.4.0 default) | nothing — plain `http://` on a public IP | `http://202.182.123.154:8787` |
314
+ | **Your own VPS** | the relay (`--tls-cert`/`--tls-key`) or a proxy | `https://w2m.example.com` |
315
+ | **Tailscale / WireGuard** | the network — WireGuard encrypts | `http://100.x.y.z:8787` |
316
+ | **Tunnel** (Cloudflare Tunnel, ngrok) | the tunnel service | `https://<sub>.example.com` or with a sub-path |
317
+
318
+ > **The shared server has no TLS, and that is stated rather than implied.** There is
319
+ > no hostname to issue a certificate for. Pairing codes, tokens and results cross
320
+ > the wire in clear text; the direct path keeps the payload off the relay, and the
321
+ > machine-side allow-list is the load-bearing control. With a domain, add TLS and
322
+ > put `https://` in `rabbitUrl` — nothing else changes:
323
+ > [deploy/shared-server/README.md](deploy/shared-server/README.md).
324
+
325
+ > **Plain `http://` over a Tailscale address is not a mistake.** WireGuard already
326
+ > provides end-to-end encryption and authenticates both peers; adding TLS on top
327
+ > would add certificate plumbing without adding a property you do not already
328
+ > have.
329
+
330
+ If your proxy or tunnel mounts the relay under a prefix — `https://host/w2m/` —
331
+ then start it with `--base-path /w2m` and put the same prefix in `rabbitUrl`.
332
+ Sub-path deployment works because URLs are joined by concatenation; see
333
+ [CHANGELOG](CHANGELOG.md#012--2026-10-07) for the bug that made this impossible
334
+ before 0.1.2.
335
+
336
+ ### Two credentials, on purpose
337
+
338
+ | Credential | Held by | Authorises |
339
+ |---|---|---|
340
+ | `device_token` | each machine | taking work, reporting results |
341
+ | `operator_token` | **only you** | **sending work** (`POST /v1/task`) |
342
+
343
+ A machine that can be told what to do should not automatically be able to tell the
344
+ others. The relay writes the operator token to `<state>/operator-token.txt` on
345
+ first start and prints it once; the plugin takes it as `operatorToken`. Starting
346
+ the relay with `--operator-token ''` removes the requirement — it warns loudly,
347
+ and that is only appropriate for a trusted LAN.
348
+
349
+ ### Surviving a restart
350
+
351
+ `devices.json` plus `ledger.jsonl` mean a restarted relay keeps its paired
352
+ machines and its task history, which matters once the relay lives on a VPS you
353
+ will eventually reboot. A corrupt ledger line is skipped and reported; a corrupt
354
+ device file starts empty **and says so** rather than silently forgetting every
355
+ machine. `--no-persist` restores in-memory behaviour.
356
+
357
+ ## Staying up to date
358
+
359
+ > **New in 0.2.3.** A machine running this plugin checks its own GitHub releases
360
+ > at **00:00, 03:00 and 05:00 Beijing time** and installs a newer version if there
361
+ > is one. This is **off by default** — an updater that replaces the installed
362
+ > plugin is opt-in.
363
+
364
+ Turn it on in the profile patch:
365
+
366
+ ```yaml
367
+ # ~/.dsh/profiles/<profile>/cordis.patch.yml
368
+ - insert:
369
+ - id: w2m-tools
370
+ name: '@twinsearth/w2m-dsh-plugin/tools'
371
+ config:
372
+ rabbitUrl: 'http://100.x.y.z:8787'
373
+ autoUpdate: true # default false
374
+ # autoUpdateTimes: ['00:00:00', '03:00:00', '05:00:00'] # Beijing wall clock
375
+ # autoUpdateTimeZone: Asia/Shanghai # default
376
+ # autoUpdateDryRun: true # verify, install nothing
377
+ # updateRepo: TwinsEarth/dsh-windows2macos # default
378
+ ```
379
+
380
+ Ask it what it is doing from any session with `w2m_update` (`action: "status"` to
381
+ inspect, `action: "check"` to run one cycle now).
382
+
383
+ **What it does and does not do:**
384
+
385
+ - Compares the newest GitHub release against the version it was built as. It
386
+ installs **only a strictly newer, non-prerelease** version — a downgrade is
387
+ worse than a missed update, and a release candidate outranks the release it
388
+ precedes by SemVer, so the gate is explicit.
389
+ - Downloads the tarball and **verifies the SHA-256 published in that release's
390
+ `SHA256SUMS`** before anything is written. A mismatch installs nothing.
391
+ - Backs up the profile's `package.json` and `pnpm-lock.yaml`, then installs
392
+ through `dsh plugin --profile <p> add <tarball>` (falling back to the runtime's
393
+ own pnpm). If the install fails, the manifest is restored byte-for-byte.
394
+ - **It does not restart DSH.** The new version loads on the next start; the
395
+ running process keeps the code it loaded. `w2m_status` reports
396
+ `restart_required` once an install has happened, and the installed tarball
397
+ lives in `<profile>/.w2m-update/` so the dependency does not dangle.
398
+ - **It does not hot-swap the running plugin.** Nothing may rewrite the module a
399
+ live Cordis container already loaded, so claiming otherwise would be a lie in
400
+ the status output.
401
+
402
+ **Honest limits.**
403
+
404
+ - A missed slot runs once on the next start if it was missed by less than 90
405
+ minutes (a machine asleep across one slot). Wider than that and the check waits
406
+ for the next slot rather than firing at an arbitrary hour.
407
+ - If pnpm fails *after* it has already changed `node_modules`, restoring the
408
+ manifest is not enough to guarantee DSH still starts. The result then carries
409
+ `rollbackComplete: false` and a `reconciliation` command; `w2m_status` surfaces
410
+ it as `reconciliation_needed`. This is the one state that needs a human.
411
+ - A negative result is never reported as success: a GitHub lookup that fails is
412
+ recorded as an **error**, not as "already up to date", because those two look
413
+ identical in a log and only one of them is a problem.
414
+
415
+ ## Security
416
+
417
+ Read this before exposing the relay to anything.
418
+
419
+ - **Admission** is a single-use, rotating pairing code, then a per-device bearer
420
+ token stored in `device.json` (mode `0600`). Pairing attempts are rate limited
421
+ (5 per IP per minute by default, successes included).
422
+ - **Sending work needs the operator token**, not a device token.
423
+ - **`--trust-proxy` is off by default and should stay off** unless the relay
424
+ really is behind a proxy you control: it makes the relay believe
425
+ `X-Forwarded-For`, and believing that header without a proxy in front lets any
426
+ client forge its address and walk past the rate limit.
427
+ - **The relay holds no model credentials and no working copy.** It can forge
428
+ tasks, which is exactly why the machine-side whitelist is the load-bearing
429
+ control.
430
+ - **Commands are never shell strings.** argv arrays are passed to
431
+ `spawn(cmd, args, { shell: false })`, so nothing a caller types can be
432
+ reinterpreted as shell syntax.
433
+ - **Default-deny whitelist.** A machine runs nothing that does not match
434
+ `--allowed-commands`.
435
+ - **Read-only by default.** `replicate` tasks run with `write: false`; there is
436
+ no code path that writes to your project in that mode.
437
+ - **TLS is your choice, but a deliberate one.** Shape A gets it from WireGuard;
438
+ shapes B and C get it from the relay's `--tls-cert`/`--tls-key` or from the
439
+ proxy. Running shape B or C over plain `http://` would put the operator token
440
+ and every result on the wire in clear text — do not.
441
+ - The plugin **does not read** `.credentials.yaml`.
442
+
443
+ ## Verified, and not verified
444
+
445
+ This project's rule is that claims carry their evidence.
446
+
447
+ ### v0.4.0 — the direct path is the default, and here is what that is worth
448
+
449
+ v0.3.9 shipped the P2P transport and admitted in its own §8 that nothing read
450
+ `p2p.mode`, the announcer did not exist and the task path still went through the
451
+ relay. v0.4.0 is that admission paid off:
452
+
453
+ * **the announcer exists** — a machine binds one UDP socket, asks the STUN servers
454
+ what the world sees, announces its candidates and refreshes them;
455
+ * **the offer can arrive over the punched path**, and the result can return over it;
456
+ * **exactly-once survives both copies arriving** — the relay's offer is still
457
+ emitted, and a second copy of the same `task_id`+`attempt` is never executed;
458
+ * **every result says which path it took** (`transport`, `p2p.offer_path`,
459
+ `p2p.result_path`, and a `路径` column in the report);
460
+ * **the relay is still the ledger** — the direct copy is additional, never a
461
+ replacement, so the comparison cannot become less trustworthy because a network
462
+ was faster.
463
+
464
+ Three things are worth knowing before relying on it:
465
+
466
+ * **Punching cannot succeed against a symmetric NAT**, and this is measured twice
467
+ on two real networks: an iPhone hotspot (v0.3.9) and the reference Windows
468
+ client network, China Mobile (v0.4.0 — three servers, three mapped ports: 36028,
469
+ 5855, 26713). Nothing can dial *into* such a network, which is why the default is
470
+ `auto` with a *reported* fallback rather than `direct`.
471
+ * **A punch from a symmetric NAT to a public peer still lands** — measured:
472
+ `HELLO` → `HELLO_ACK` in **331.61 ms** from that same Chinese network to the
473
+ shared server in Tokyo, with the ledger recording `transport: p2p` for the
474
+ Japanese machine. A punch you initiate opens the mapping, and the peer's answer
475
+ to the datagram it just received comes back through it. That asymmetry is why the
476
+ dispatcher dials the executor.
477
+ * **A machine that must accept a punch needs a reachable UDP port.** On a public
478
+ host that is a firewall rule for the agent's punch port (the shared server has
479
+ one); behind a NAT it is the hole the punch opens. **v0.4.1 pins it**: `--p2p-port <n>`
480
+ (or `W2M_P2P_PORT`) makes the rule name one port instead of a range, which v0.4.0 could not
481
+ do — the node always bound an ephemeral one, so the rule had to be re-pointed after every
482
+ restart.
483
+ * **A two-NAT punch is still unverified.** Two sockets on one host share a path
484
+ with no translator between them, so the loopback tests prove the protocol and
485
+ nothing about NAT. What *has* now been measured is one real WAN hop: dispatcher
486
+ on a Chinese residential network, executor on the shared server in Tokyo. That is
487
+ one NAT, and it is labelled as one NAT.
488
+
489
+ **The same suites run on three platforms in CI** — see the badge above, or
490
+ [`.github/workflows/ci.yml`](.github/workflows/ci.yml). The matrix is
491
+ `windows-latest` × node 20/22, `macos-latest` × node 22, `ubuntu-latest` ×
492
+ node 22. That matters here more than in most projects: a Windows-and-macOS
493
+ coordination tool whose macOS half had never been executed would be a claim, not
494
+ a product.
495
+
496
+ | Suite | Tests | What it covers |
497
+ |---|---|---|
498
+ | relay | 91 | pairing, auth, SSE with `ready`-first and `seq` replay, leases with heartbeat renewal and expiry, dedupe, all six aggregation states, report generation, operation token, rate limiting, persistence and restart recovery, TLS, and the lost-offer recovery path |
499
+ | agent | 105 | whitelist allow/deny, timeout, output truncation, exit codes, anchors on clean and dirty trees, four concurrent fingerprint computations, spool, a 40-case URL join matrix, cursor lifecycle across relay restarts |
500
+ | plugin | 73 | eight tools registered, schemas, typed errors, polling, operator-token enforcement (no request is sent without it), sub-path endpoints |
501
+ | end to end + crossnetwork | 46 | two Localsides on two checkouts against one relay running real commands: consistent / divergent / failed / refused / unverifiable / deduped / long-lease / split, plus sub-path deployment, the two credential kinds, a SIGKILLed relay restarting with its ledger intact, pairing rate limiting, proxy-header trust boundaries, and the anti-buffering headers |
502
+ | schedule | 39 | the daily slots as exact UTC instants, zones that shift by 30 minutes for DST, a full simulated year of consecutive arming, and catch-up collapsing several missed slots into one run |
503
+ | auto-update | 34 | the install/skip decision, no downgrade, prerelease refused, an unverified tarball refused, a failed lookup recorded as an error rather than as "current", and no token ever persisted |
504
+ | update-source | 50 | version ordering, streaming SHA-256 verification, timeouts that really abort, rate-limit reporting, and one real GitHub API call (opt-in — see below) |
505
+ | update-install | 28 | zero writes on a hash mismatch, byte-for-byte restore, atomic staging, dry run, and two real-pnpm integration runs in throwaway profiles |
506
+ | update-wiring | 15 | configuration validation that names the setting, and one `ctx.effect` owning the timer whose disposer stops it |
507
+
508
+ 435 unit tests total: 434 pass, 1 skip. Run them with `scripts/verify.ps1`
509
+ (Windows) or `scripts/verify.sh` (macOS/Linux). The skip is one agent test that
510
+ asserts POSIX mode bits, which NTFS does not carry.
511
+
512
+ The one test that calls GitHub for real is **opt-in** (`W2M_NETWORK_TESTS=1`).
513
+ GitHub allows 60 unauthenticated API requests per hour *per IP* and hosted CI
514
+ runners share IPs, so it failed on `macos-latest` with HTTP 403 `RATE_LIMITED`
515
+ while every other job passed — and a release gate that depends on someone else's
516
+ rate-limit budget eventually blocks a release for a reason unrelated to the code.
517
+ CI still runs it in a separate **non-blocking** job with the workflow token, and
518
+ the same code paths are covered without a network in the blocking matrix.
519
+
520
+ Two behaviours are checked but not covered by a test file, because both concern
521
+ the real binaries rather than the modules:
522
+
523
+ - **CLI smoke test** (`scripts/smoke-cli.ps1`): real `w2m-rabbit` plus two real
524
+ `w2m-localside` processes, ending in a `consistent` verdict and a rendered
525
+ markdown report.
526
+ - **Release-artifact reproducibility**: CI packs the sources twice and fails if
527
+ the two tarballs differ, so a published hash is a statement about the sources
528
+ rather than about when the command ran.
529
+
530
+ Two extra gates exist because "it installed" is not "it loads" — the first
531
+ version of this package shipped a re-export naming a symbol that did not exist,
532
+ and it passed `node --check` and every unit test, failing only when DSH mounted
533
+ the plugin:
534
+
535
+ ```bash
536
+ node scripts/check-entrypoints.mjs # imports lib/tools.js, the exact specifier cordis.patch.yml mounts
537
+ node scripts/verify-installed.mjs <path-to-installed-package> # asserts the installed copy registers 5 tools
538
+ ```
539
+
540
+ **Not verified:**
541
+
542
+ - **A real Windows↔macOS pair.** The CI matrix proves each platform runs the
543
+ suites, but the two halves of a matrix job never talk to each other: CI does
544
+ not pair a Windows runner with a macOS runner over the network. The
545
+ machine-to-machine link has only been exercised between two processes on one
546
+ host. Burn-in on your own two machines is still the last step.
547
+ - **A single account driving both machines' model sessions.** This release
548
+ executes commands deterministically; it does not inject prompts into a remote
549
+ DSH session. Whether one account can run two concurrent model sessions at once
550
+ depends on your account's limits and is untested here.
551
+ - **`write: true`.** A write-enabled task is executed, but there is no branch
552
+ merge in 0.0.1.
553
+
554
+ ## Design notes
555
+
556
+ - **Zero third-party runtime dependencies.** Only `node:` built-in modules. The
557
+ relay is `node:http`; the downlink is Server-Sent Events and the uplink is
558
+ ordinary POST, so there is no WebSocket handshake to get wrong.
559
+ - **`@deepseek-ai/dsh-tools` is an optional peer dependency**, resolved at
560
+ runtime by DSH. It is deliberately *not* a hard dependency: declaring it would
561
+ pull roughly 287 `@deepseek-ai/*` packages into the install for one import.
562
+ - **Long lease + progress heartbeat**, never a fixed timeout. Measured risk: a
563
+ machine running a 40-minute test would be declared dead and the task re-sent to
564
+ another machine — both machines then running it, both producing side effects.
565
+ Only a *failed renewal* marks a lease dead, and only the relay's clock decides.
566
+
567
+ The full wire contract — every endpoint, field and state transition — is in
568
+ [PROTOCOL.md](PROTOCOL.md). It is frozen: changing it is a breaking change.
569
+
570
+ ## Development
571
+
572
+ ```bash
573
+ node --test test/ # everything
574
+ node --test test/relay.test.mjs
575
+ node --test test/agent.test.mjs
576
+ node --test test/tools.test.mjs
577
+ node --test --test-force-exit test/e2e.test.mjs # see note
578
+ ```
579
+
580
+ > The end-to-end suite needs `--test-force-exit`: each simulated machine holds an
581
+ > open SSE connection, and a long-lived stream keeps Node's event loop alive
582
+ > after the assertions finish. The suite also needs a git binary on `PATH`.
583
+
584
+ ## Contributing
585
+
586
+ Issues and pull requests are welcome. If you change observable behaviour, update
587
+ `PROTOCOL.md` in the same commit — that file is the contract three components
588
+ agree on.
589
+
590
+ ## License
591
+
592
+ [MIT](LICENSE)
593
+
594
+ ---
595
+
596
+ ## 中文说明
597
+
598
+ **同一个 DeepSeek 账号,一条指令,让在线的 Windows 与 Mac 跑同一个项目。**
599
+
600
+ ### 为什么需要它
601
+
602
+ DSH 目前**不能**跨机器协作,而且不是疏忽:
603
+
604
+ - 一个 `dsh` 进程只服务**一台机器**(一个 profile 就是一个本地 pnpm workspace);
605
+ - 传输层是**仅回环**的(web 服务器只接受 `127.0.0.1`/`0.0.0.0`,CLI 明确拒绝 `--host 0.0.0.0`,因为它自身不带 TLS 与来源策略);
606
+ - 官方 agent-team 明确**不支持** teammates 各自独立的工作目录,也**不支持多个进程协作同一个 team**。
607
+
608
+ 所以跨机器能力必须自建。这就是那个实现。
609
+
610
+ ### 核心设计
611
+
612
+ | 设计 | 理由 |
613
+ |---|---|
614
+ | **P2P 直连优先**(v0.4.0 默认) | 两端各报自己的反射地址,由派发方打洞并把任务直接送过去;打不通才回落到中继,并且**报告用了哪条路** |
615
+ | **中继 = 会合点 + 账本 + 回落** | 中继仍然收下每一条结果:它是账本,账本不完整,比较就不可信 |
616
+ | **默认只读**(`replicate` 且 `write: false`) | 一次性消除重复提交、覆盖写、推送竞争这三类最常见的事故 |
617
+ | **三锚校验** | 实测:脏工作区上 `git checkout --detach <sha>` **退出码 0 却保留本地改动** —— 只看 commit SHA 会把"不是同一份代码"误判成"结果分歧" |
618
+ | **六态聚合** | "环境不同"与"结果不同"是两件事,合并记账会把噪声当 bug |
619
+ | **能力不匹配 → `refused`** | 拒绝执行,而不是静默降级 |
620
+ | **长租约 + 进度心跳** | 固定超时会让跑 40 分钟的机器被判死并重投 → **两台同时跑、同时产生副作用** |
621
+ | **零第三方运行时依赖** | 只用 `node:` 内置模块;下行 SSE + 上行 POST,不用 WebSocket |
622
+
623
+ ### 快速开始
624
+
625
+ ```bash
626
+ # 1) 中继 + STUN(任一台常开的机器,一次)
627
+ # v0.4.0 已内置共享服务器作为默认值,这一步是可选的:
628
+ # http://202.182.123.154:8787 与 202.182.123.154:3478
629
+ node bin/w2m-rabbit.mjs --host 0.0.0.0 --port 8787 --state ~/.dsh/xclient/rabbit
630
+ node bin/w2m-stun.mjs --host 0.0.0.0 --port 3478
631
+ # 中继会打印一次性配对码 PAIR-XXXXXXXX
632
+
633
+ # 2) 每台参与机器(在其项目目录所在机器上跑)
634
+ node bin/w2m-localside.mjs --rabbit http://<中继地址>:8787 --pair PAIR-XXXXXXXX \
635
+ --project /path/to/your/project --name win-desktop --p2p-mode auto \
636
+ --allowed-commands '["node --test","git status --porcelain"]'
637
+
638
+ # 3) 装 DSH 插件
639
+ dsh plugin --profile <profile-name> add \
640
+ https://github.com/TwinsEarth/dsh-windows2macos/releases/download/v0.4.3/twinsearth-w2m-dsh-plugin-0.4.3.tgz
641
+ ```
642
+
643
+ 然后在 profile 的 `cordis.patch.yml` 里给插件 `rabbitUrl`(留空即用共享服务器)与 `operatorToken`,重启 DSH,即可直接用自然语言指挥:
644
+
645
+ > 列出我的机器,然后在所有机器上跑 `node --test`,告诉我输出是否一致。
646
+
647
+ ### 项目前置条件
648
+
649
+ 项目根必须有 `.gitattributes` 写 `* text=auto eol=lf`,否则两台机器的指纹永远不一致(实测:加了之后 `autocrlf=true` 与 `=false` 两种配置的工作区字节完全相同)。
650
+
651
+ ### 已实测 / 未实测
652
+
653
+ - ✅ **P2P 直连已接通(v0.4.0)**:宣告(announce)、打洞、任务通过直连通道送达、结果沿同一通道返回、两条路径同时到达时**只执行一次**、以及每条结果都带 `transport` / `p2p.offer_path` / `p2p.result_path`。回落到中继时带**具体原因**(`P2P_PUNCH_TIMEOUT` 等),不是一句"没走直连"。
654
+ - ✅ **跨真网一跳已实测**:派发端在**中国移动的对称 NAT 之后**(三台 STUN 服务器给出三个不同的映射端口:36028 / 5855 / 26713,`endpoint-dependent`,punchable: false),执行端在东京的共享服务器上。**实测打洞成功:`HELLO` → `HELLO_ACK` 331.61 ms**,账本为那台日本机器记录 `transport: p2p`(`result_path: p2p`)。对称 NAT 挡住的是"被拨入",挡不住"主动拨出" —— 这正是派发方负责打洞的原因。
655
+ - ✅ **要接受打洞的机器必须有可达的 UDP 端口**:公网主机上就是一条防火墙规则(共享服务器已加)。**v0.4.1 起可以用 `--p2p-port <n>`(或 `W2M_P2P_PORT`)固定端口**,规则只需写一个端口,不必覆盖区间。
656
+ - ✅ **共享服务器已上线**:中继(`:8787` 与经 nginx 的 `:80`)、STUN 响应器(`:3478/udp`)、systemd 单元、ufw 规则,一条命令可复现:`deploy/shared-server/install.sh`。
657
+ - ✅ **三平台 CI**:同一套用例在 `windows-latest`(node 20/22)、`macos-latest`(node 22)、`ubuntu-latest`(node 22)上跑,见上方徽章与 `.github/workflows/ci.yml`。
658
+ - ⚠️ **两个 NAT 之后的互通仍未实测**:本机环回上的打洞只证明协议正确,不证明能穿透 NAT;目前实测的是**一跳、一个 NAT**(见上)。
659
+ - ⚠️ **真实的 Windows↔macOS 互联未实测**:CI 矩阵只证明**各平台都能跑**这套代码;矩阵里的两台机器**并不会互相通信**。
660
+ - ⚠️ **UDP 直连通道本身不认证**(与 v0.3.9 §6 相同):session id 是 32 位随机数,UDP 源地址可伪造 —— 直连不比中继更可信,威胁模型包含路上攻击者时请开启请求签名。
661
+ - ⚠️ **单账号驱动两台机器的模型会话未实测**:本版本执行的是**确定性命令**,不向远端 DSH 会话注入 prompt。
662
+ - ⚠️ **`write: true` 不合并**:写模式的任务会执行,但没有分支合并。
663
+
664
+ ### 许可
665
+
666
+ [MIT](LICENSE)