@twinsearth/w2m-dsh-plugin 0.5.9 → 0.6.2

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.
@@ -81,17 +81,52 @@ minutes ago — not a broken release. See §11.
81
81
  `rabbitUrl` points at the shared server, `operatorToken` is empty, `stateDir` and `machineName` are
82
82
  blank, the allow-list is empty. Nothing errored.
83
83
 
84
- **Cause.** `dsh plugin add` (and the updater) **rewrites** `cordis.patch.yml`. It preserves its own
85
- bundle rows and can drop entries it does not recognise. This project has already lost a full config
86
- block that way — `rabbitUrl`, `operatorToken`, `stateDir`, `projectDir`, `machineName`, `p2pMode` and
87
- `allowedCommands` — and the only reason it was noticed is that someone looked.
88
-
89
- A second, sharper version of the same trap: a **fresh** profile's `cordis.patch.yml` is the
84
+ **Cause.** Two different mechanisms, and the one this section used to name was wrong. **`dsh plugin add`
85
+ does not rewrite `cordis.patch.yml`** — the CLI shares the plugin manager's package operations, which
86
+ are `pnpm` plus the profile manifest and lockfile: `package.json` and `pnpm-lock.yaml` are what a package
87
+ operation writes, and a failed or cancelled install restores exactly those two. The manager's own patch
88
+ writes are _plugin toggles_ in the Web plugin page, not installs.
89
+
90
+ What actually removes the file is **Desktop's fatal-recovery `sanitizeProfile`**. When DSH hits a native
91
+ fatal error it recovers the profile without loading plugins: it **renames `cordis.patch.yml` to
92
+ `cordis.patch.yml.bak-<unix-ms>`** and rewrites the bundle list. That is consistent with what was
93
+ observed here — the configuration block vanished around a crash, and a `.bak-<timestamp>` sibling is the
94
+ signature to look for. So the failure mode is not "the installer ate my settings"; it is "the app
95
+ crashed, recovered, and the patch went with it".
96
+
97
+ **This is why the backup habit below still matters, and matters more before an upgrade.** An upgrade is
98
+ when a crash is most likely to be provoked and least likely to be watched; the `.bak-<timestamp>` file is
99
+ what makes the settings recoverable afterwards, and it costs one `cp`.
100
+
101
+ A second, sharper trap, unrelated to either mechanism: a **fresh** profile's `cordis.patch.yml` is the
90
102
  placeholder `[]`. That is an empty YAML _sequence_. Appending a mapping block to it produces invalid
91
103
  YAML, and the loader fails on the file as a whole — one bad row takes the entire profile down, not
92
104
  just this plugin. Both cases are why this file is treated as hand-edited state, not as a generated
93
105
  artifact.
94
106
 
107
+ A third mechanism, and the only one where the file looks *perfect* and is still being ignored: **a
108
+ `name:` line in your override is an assertion, not a rename.** The bundle inserts this row itself with
109
+ the package name, so your row targets it by `id` and a `name:` only has to agree. When it does not, the
110
+ Loader drops the whole patch:
111
+
112
+ ```
113
+ patch: name mismatch for "w2m" (expected "@twinsearth/w2m-dsh-plugin",
114
+ got "@twinsearth/w2m-dsh-plugin/tools"), skipping
115
+ ```
116
+
117
+ Every value in that block is then absent — not overridden with something else, simply never applied —
118
+ and the plugin mounts on the bundle's empty defaults. This is the failure mode where `cat` shows your
119
+ settings sitting right there and `w2m_status` reports `paired: false` anyway.
120
+
121
+ It is also the one that arrives **on upgrade** rather than on a crash: the entry's expected name moved
122
+ from the `/tools` subpath to the package root in v0.5.4 (`cordis.patch.yml`'s header records why), and
123
+ every override still asserting the old subpath stopped being applied the moment the profile was
124
+ upgraded — with no edit to the profile and no error. If you keep a `name:`, an upgrade can silently
125
+ disconnect you from your own configuration.
126
+
127
+ **The fix is to delete the line.** `id: w2m` already identifies the row; asserting the name buys
128
+ nothing and turns any future rename by the bundle into a silent misconfiguration.
129
+
95
130
  **Decision procedure.**
96
131
 
97
132
  ```bash
@@ -101,6 +136,9 @@ cat "$DSH_HOME/profiles/<profile>/cordis.patch.yml"
101
136
 
102
137
  - First characters are `[]` → the placeholder case; see the safe-edit rule below.
103
138
  - The `w2m` row is absent → it was dropped; re-add it (§2 fix 3).
139
+ - The row carries a `name:` line → **delete that line first**, then re-check the values. Everything
140
+ else in this section assumes the patch is being applied at all, and a mismatched `name:` means it
141
+ is not.
104
142
  - The row is present but values are empty → it was reset; re-apply them.
105
143
 
106
144
  ```bash
@@ -128,7 +166,7 @@ in the profile's row and the mounted package disagree — usually because one of
128
166
  ```
129
167
 
130
168
  2. **If the file is the `[]` placeholder, delete the placeholder before writing your block.** Do not
131
- append to `[]`:
169
+ append to `[]`, and use the `insert:` form to create the row:
132
170
 
133
171
  ```yaml
134
172
  # WRONG -- a sequence followed by a mapping is invalid YAML, and it takes the profile down
@@ -136,20 +174,36 @@ in the profile's row and the mounted package disagree — usually because one of
136
174
  - id: w2m
137
175
  config: { ... }
138
176
 
139
- # RIGHT -- one top-level list, with your row inside it
177
+ # ALSO WRONG, and quieter -- a bare `- id:` is a NON-insert patch: it replaces supplied fields on
178
+ # an entry an earlier layer already created. No earlier layer creates `w2m`, so the loader warns
179
+ # `patch: entry "w2m" not found` and skips the whole row. The install succeeds, DSH starts, and
180
+ # none of the eight tools exist.
140
181
  - id: w2m
141
182
  name: '@twinsearth/w2m-dsh-plugin'
142
183
  config:
143
184
  rabbitUrl: ''
144
- operatorToken: ''
145
- stateDir: /absolute/path/to/$DSH_HOME/xclient
146
- machineName: win-desktop
147
- p2pMode: auto
148
- allowedCommands: []
185
+
186
+ # RIGHT -- `insert:` appends the entry, which is what creating a row means
187
+ - insert:
188
+ - id: w2m
189
+ name: '@twinsearth/w2m-dsh-plugin'
190
+ config:
191
+ rabbitUrl: ''
192
+ operatorToken: ''
193
+ stateDir: /absolute/path/to/$DSH_HOME/xclient
194
+ machineName: win-desktop
195
+ p2pMode: auto
196
+ allowedCommands: []
149
197
  ```
150
198
 
151
- In other words: replace the `[]` with your list, or append `- id: w2m` as a new list item at the
152
- same indentation as the existing items. Never leave the `[]` above your block.
199
+ **When each form is appropriate.** `insert:` is for _creating_ a row — it is the only form that can,
200
+ and it is what the packaged `cordis.patch.yml` uses for exactly that reason. A bare `- id:` (with no
201
+ `insert:`) is for _overriding fields on a row that already exists_, which is what the home-level patch
202
+ and a plugin toggle do; used against a row that nothing created, it is a silent no-op.
203
+
204
+ In other words: replace the `[]` with an `insert:` list, or add your `- insert:` item at the same
205
+ indentation as the existing items. Never leave the `[]` above your block, and never reach for the
206
+ bare `- id:` form to create something new.
153
207
 
154
208
  3. **The `name:` must be the bare package.** `@twinsearth/w2m-dsh-plugin` — not
155
209
  `@twinsearth/w2m-dsh-plugin/tools`, not a path, not `.js`. A client package is identified by the
@@ -159,8 +213,9 @@ in the profile's row and the mounted package disagree — usually because one of
159
213
  5. If the file is unparseable, restore the backup and restart; a malformed patch file is a whole-file
160
214
  failure.
161
215
 
162
- **Not us.** The rewrite itself is the installer's behaviour, not this plugin's. Treat the file as
163
- shared state: your edits can be reverted by any tool that manages the profile.
216
+ **Not us.** Neither mechanism is this plugin's: the install writes the manifest and lockfile, and the
217
+ crash-recovery rename is the host's. Treat the file as shared state — your edits can be reverted by any
218
+ tool that manages the profile, and the `.bak-<timestamp>` sibling is the receipt.
164
219
 
165
220
  ---
166
221
 
@@ -185,14 +240,16 @@ in **v0.5.4 and v0.5.5**: the loader entry failed, so the whole host entry was s
185
240
  node -p "require('$DSH_HOME/profiles/<profile>/node_modules/@twinsearth/w2m-dsh-plugin/package.json').version"
186
241
  ```
187
242
 
188
- **Fix.** Install a version that is not withdrawn. v0.5.3 and earlier are installable; v0.5.4 and
189
- v0.5.5 are not. `CHANGELOG.md` carries `DO NOT INSTALL` on the withdrawn entries — check it before
243
+ **Fix.** Install a version that is not withdrawn. **v0.5.9 is the current release and is installable**;
244
+ v0.5.4 and v0.5.5 are not, because they prevent the application from starting. Everything else from
245
+ v0.5.6 on is installable too, and v0.5.3 and earlier install but predate the client half.
246
+ `CHANGELOG.md` carries `DO NOT INSTALL` on the withdrawn entries — check it before
190
247
  installing a version you have not run before.
191
248
 
192
249
  ```bash
193
250
  # Uninstall the broken copy, then install a known-good one
194
251
  dsh plugin --profile <profile> remove @twinsearth/w2m-dsh-plugin
195
- dsh plugin --profile <profile> add @twinsearth/w2m-dsh-plugin@0.5.3
252
+ dsh plugin --profile <profile> add @twinsearth/w2m-dsh-plugin@0.5.9
196
253
  ```
197
254
 
198
255
  The plugin being uninstalled must not leave a dangling row: if the profile still lists the package
@@ -375,6 +432,14 @@ not over the executable alone. Two consequences surprise people:
375
432
  match too, and here the third token differs (`console.log` vs `console.log(1)`).
376
433
  - A string entry is split on whitespace. A path containing a space therefore cannot be expressed as a
377
434
  string entry — write it as a **JSON array element** instead.
435
+ - **An empty list means two opposite things, one per side, and the symptom looks the same.** The
436
+ plugin's list (`allowedCommands`) is **not a gate when empty**: with nothing configured the plugin
437
+ declines to refuse, deliberately, because the gate belongs to the machine that runs the command and it
438
+ will not duplicate a policy it was not given. The agent's list (`--allowed-commands`) is
439
+ **default-deny with nothing configured**: an empty list refuses **every** offer, with
440
+ `COMMAND_NOT_ALLOWED` and the detail _"the configured allow-list is empty, so every command is
441
+ refused"_. So "I set nothing on either side" runs nothing at all, and "I set nothing on the plugin
442
+ side" runs exactly what the agent allows — check both lists before concluding which one refused you.
378
443
 
379
444
  **Decision procedure.**
380
445
 
@@ -590,7 +655,7 @@ If step 1 lists the version and step 2 is a non-zero number of minutes, that is
590
655
  # Wait for the gate to pass (a tarball URL bypasses the resolver entirely -- but read the release
591
656
  # notes first, and prefer a version that is not withdrawn; see CHANGELOG.md).
592
657
  dsh plugin --profile <profile> add \
593
- https://github.com/TwinsEarth/dsh-windows2macos/releases/download/v0.5.3/twinsearth-w2m-dsh-plugin-0.5.3.tgz
658
+ https://github.com/TwinsEarth/dsh-windows2macos/releases/download/v0.5.9/twinsearth-w2m-dsh-plugin-0.5.9.tgz
594
659
  ```
595
660
 
596
661
  Or raise the gate for this machine — a deliberate supply-chain trade, so make it deliberately:
@@ -738,25 +803,25 @@ Relative to `$DSH_HOME`, which is `%DSH_HOME%` or `~/.dsh` on Windows and `$DSH_
738
803
  elsewhere. `<kind>` is the sub-directory a program is started with: `localside` for an agent,
739
804
  `rabbit` for a relay.
740
805
 
741
- | What | Path | Created by | Windows | macOS / Linux | Notes |
742
- | ------------------------------------ | ------------------------------------------------------------- | -------------------------------------- | -------------------------------------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
743
- | Device identity (`machine_id`, name) | `$DSH_HOME/xclient/device.json` | the agent, on first use | `%USERPROFILE%\.dsh\xclient\device.json` | `~/.dsh/xclient/device.json` | Override the _file_ with `DSH_DEVICE_FILE`; mode 0600 where the platform honours it |
744
- | Device private key | beside `device.json` — `$DSH_HOME/xclient/device.key` | the agent, on first use | `%USERPROFILE%\.dsh\xclient\device.key` | `~/.dsh/xclient/device.key` | JSON key material, mode 0600. **NTFS carries no POSIX modes: `chmod` there is best-effort and does not restrict other users** |
745
- | Agent state / spool root | `--state <dir>`, default `$DSH_HOME/xclient/<kind>` | `w2m-localside` (`--state`) | `%USERPROFILE%\.dsh\xclient\localside` | `~/.dsh/xclient/localside` | Explicit `--state` wins; then `$DSH_HOME/xclient/<kind>`; then `~/.dsh/xclient/<kind>` |
746
- | Agent snapshot | `<state>/agent-state.json` | the agent, continuously | `…\xclient\localside\agent-state.json` | `…/xclient/localside/agent-state.json` | The answer to "was the agent ever up", no network needed |
747
- | Write-ahead spool | `<state>/spool/<task_id>__a<attempt>/` | the agent, per task | `…\localside\spool\` | `…/localside/spool/` | `meta.json`, `task.json`, `envelope.json`. Offers are spooled **before** execution and envelopes before the POST; an entry is deleted only after the relay acknowledges it |
748
- | Auto-update bookkeeping | `$DSH_HOME/xclient/auto-update-state.json` | the plugin's updater | `%USERPROFILE%\.dsh\xclient\auto-update-state.json` | `~/.dsh/xclient/auto-update-state.json` | Only exists if `autoUpdate` was turned on |
749
- | Relay ledger | `<relay state>/ledger.jsonl` | `w2m-rabbit` | `%USERPROFILE%\.dsh\xclient\rabbit\ledger.jsonl` | `~/.dsh/xclient/rabbit/ledger.jsonl` | Append-only, one JSON object per line. A corrupt line is skipped and counted, not fatal |
750
- | Relay device table | `<relay state>/devices.json` | `w2m-rabbit` | `…\xclient\rabbit\devices.json` | `…/xclient/rabbit/devices.json` | Atomic snapshot (temp file + rename). On a server, `deploy/` puts this at `/var/lib/w2m/` |
751
- | Relay operator token | `<relay state>/operator-token.txt` | `w2m-rabbit`, **once**, on first start | `…\xclient\rabbit\operator-token.txt` | `…/xclient/rabbit/operator-token.txt` | Printed once on stderr and deliberately absent from `--json` output. Treat as a credential; do not paste it into a report |
752
- | Profile plugin list and patch | `$DSH_HOME/profiles/<profile>/cordis.patch.yml` | DSH, then you / the installer | `%USERPROFILE%\.dsh\profiles\<profile>\cordis.patch.yml` | `~/.dsh/profiles/<profile>/cordis.patch.yml` | **Rewritten by `dsh plugin add`; back it up (§2)** |
753
- | Profile dependencies | `$DSH_HOME/profiles/<profile>/package.json`, `pnpm-lock.yaml` | the installer | same shape | same shape | What the profile actually resolved; the updater backs these up before a replace |
754
- | Windows agent log | `%USERPROFILE%\.dsh\w2m\agent.log` | the Windows supervisor | `%USERPROFILE%\.dsh\w2m\agent.log` | n/a | stdout/stderr of a session-0 process goes nowhere, so the supervisor owns this file |
755
- | Windows supervisor heartbeat | `%USERPROFILE%\.dsh\w2m\supervisor-heartbeat.json` | the Windows supervisor | same | n/a | "Is this machine working?" without parsing a log that may itself be the casualty |
756
- | Agent log (Unix, started by hand) | wherever you redirected it | you | n/a | n/a | The agent logs to stderr, and there is no shipped systemd unit for it: redirect it to a file, or run it under a supervisor of your own |
757
- | Relay log | its stdout/stderr | you | the container log | journalctl -u w2m-rabbit | The one place the operator token is printed, and only on first start |
758
-
759
- **Two rules about this table.**
806
+ | What | Path | Created by | Windows | macOS / Linux | Notes |
807
+ | ------------------------------------ | ------------------------------------------------------------- | -------------------------------------- | -------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
808
+ | Device identity (`machine_id`, name) | `$DSH_HOME/xclient/device.json` | the agent, on first use | `%USERPROFILE%\.dsh\xclient\device.json` | `~/.dsh/xclient/device.json` | Override the _file_ with `DSH_DEVICE_FILE`; mode 0600 where the platform honours it |
809
+ | Device private key | beside `device.json` — `$DSH_HOME/xclient/device.key` | the agent, on first use | `%USERPROFILE%\.dsh\xclient\device.key` | `~/.dsh/xclient/device.key` | JSON key material, mode 0600. **NTFS carries no POSIX modes: `chmod` there is best-effort and does not restrict other users** |
810
+ | Agent state / spool root | `--state <dir>`, default `$DSH_HOME/xclient/<kind>` | `w2m-localside` (`--state`) | `%USERPROFILE%\.dsh\xclient\localside` | `~/.dsh/xclient/localside` | Explicit `--state` wins; then `$DSH_HOME/xclient/<kind>`; then `~/.dsh/xclient/<kind>` |
811
+ | Agent snapshot | `<state>/agent-state.json` | the agent, continuously | `…\xclient\localside\agent-state.json` | `…/xclient/localside/agent-state.json` | The answer to "was the agent ever up", no network needed |
812
+ | Write-ahead spool | `<state>/spool/<task_id>__a<attempt>/` | the agent, per task | `…\localside\spool\` | `…/localside/spool/` | `meta.json`, `task.json`, `envelope.json`. Offers are spooled **before** execution and envelopes before the POST; an entry is deleted only after the relay acknowledges it |
813
+ | Auto-update bookkeeping | `$DSH_HOME/xclient/auto-update-state.json` | the plugin's updater | `%USERPROFILE%\.dsh\xclient\auto-update-state.json` | `~/.dsh/xclient/auto-update-state.json` | Only exists if `autoUpdate` was turned on |
814
+ | Relay ledger | `<relay state>/ledger.jsonl` | `w2m-rabbit` | `%USERPROFILE%\.dsh\xclient\rabbit\ledger.jsonl` | `~/.dsh/xclient/rabbit/ledger.jsonl` | Append-only, one JSON object per line. A corrupt line is skipped and counted, not fatal |
815
+ | Relay device table | `<relay state>/devices.json` | `w2m-rabbit` | `…\xclient\rabbit\devices.json` | `…/xclient/rabbit/devices.json` | Atomic snapshot (temp file + rename). On a server, `deploy/` puts this at `/var/lib/w2m/` |
816
+ | Relay operator token | `<relay state>/operator-token.txt` | `w2m-rabbit`, **once**, on first start | `…\xclient\rabbit\operator-token.txt` | `…/xclient/rabbit/operator-token.txt` | Printed once, on the start that **generated** it, and deliberately absent from `--json` output. Later starts name the file instead; `--print-operator-token` / `W2M_PRINT_OPERATOR_TOKEN=1` prints it anyway. Treat as a credential; do not paste it into a report |
817
+ | Profile plugin list and patch | `$DSH_HOME/profiles/<profile>/cordis.patch.yml` | DSH, then you | `%USERPROFILE%\.dsh\profiles\<profile>\cordis.patch.yml` | `~/.dsh/profiles/<profile>/cordis.patch.yml` | **Not written by `dsh plugin add`** — a plugin toggle writes it, and Desktop's crash recovery renames it to `.bak-<ts>`. Back it up (§2) |
818
+ | Profile dependencies | `$DSH_HOME/profiles/<profile>/package.json`, `pnpm-lock.yaml` | the installer | same shape | same shape | What the profile actually resolved; the updater backs these up before a replace |
819
+ | Windows agent log | `%USERPROFILE%\.dsh\w2m\agent.log` | the Windows supervisor | `%USERPROFILE%\.dsh\w2m\agent.log` | n/a | stdout/stderr of a session-0 process goes nowhere, so the supervisor owns this file |
820
+ | Windows supervisor heartbeat | `%USERPROFILE%\.dsh\w2m\supervisor-heartbeat.json` | the Windows supervisor | same | n/a | "Is this machine working?" without parsing a log that may itself be the casualty |
821
+ | Agent log (Unix, started by hand) | wherever you redirected it | you | n/a | n/a | The agent logs to stderr, and there is no shipped systemd unit for it: redirect it to a file, or run it under a supervisor of your own |
822
+ | Relay log | its stdout/stderr | you | the container log | journalctl -u w2m-rabbit | The one place the operator token is printed, and only on the start that generated it (or with the opt-in) |
823
+
824
+ **Three rules about this table.**
760
825
 
761
826
  1. **Nothing here is inside your project.** The spool, the state and the key live under the state
762
827
  directory on purpose: a read-only project directory must never be written to, and the spool is not
@@ -764,24 +829,35 @@ elsewhere. `<kind>` is the sub-directory a program is started with: `localside`
764
829
  2. **Two "machines" sharing one `device.json` are one machine.** Duplicating a state directory to run
765
830
  a second agent on one host makes both claim the same `machine_id`. Point `--state` (or `DSH_HOME`)
766
831
  somewhere else instead.
832
+ 3. **A `device.json` token is a fleet-wide _metadata_ read credential, not a machine-local one.** A task's
833
+ aggregate is keyed by `task_id`, and `GET /v1/tasks/{task_id}` and the report accept a device token,
834
+ so **any** paired machine's token can read the aggregate of **any** task on **every** machine:
835
+ statuses, per-machine outcomes, anchors, comparable fields, the `transport`/`p2p` blocks and counts.
836
+ What it cannot read that way is the captured output — the aggregate carries `stdout_sha256` and
837
+ `stdout_bytes`, not the text. Only `POST /v1/task` (dispatch) requires the operator token. So "a
838
+ machine that can receive work cannot command the others" is true, and "it cannot see what the others
839
+ were asked to do, or how it went" is **not**. Keep the token file as private as the operator token.
767
840
 
768
841
  ---
769
842
 
770
843
  ## 17. Reference: the reason codes you will actually see
771
844
 
772
- | Code | Where it appears | First thing to check |
773
- | ------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------ |
774
- | `W2M_UNREACHABLE` | dispatching side, relay not answering | §9 |
775
- | `W2M_RABBIT_REFUSED` | dispatching side, HTTP 401 from the relay | §9 fix 4 — the network is fine |
776
- | `COMMAND_NOT_ALLOWED` | `refused` result, agent log | §7 |
777
- | `CAPABILITY_REFUSED` | `refused` result | the agent does not advertise the capability the task needs |
778
- | `NO_ONLINE_DEVICE` | dispatch | §4 — no machine the relay considers online |
779
- | `PAIRING_CODE_ROTATED` | pairing | pair again with the current code |
780
- | `LEASE_EXPIRED` | task status | §4/§5 — the executor stopped renewing its lease |
781
- | `P2P_UNAVAILABLE` | `refused` result in `p2pMode: direct` | §6 — the punch did not land and direct-only refuses the fallback |
782
- | `P2P_PUNCH_TIMEOUT` | reported transport reason | §6, §13 |
783
- | `RUNNER_THREW` | envelope status | the command ran and the runner itself failed; read the envelope's excerpt |
784
- | `SIGNATURE_*` (`MISMATCH`, `EXPIRED`, `REPLAY`, `REQUIRED`, `INCOMPLETE`) | relay, when signing is configured | the signing secret is rotated or the two sides disagree; `SIGNING_NOT_CONFIGURED` means signing is off |
845
+ | Code | Where it appears | First thing to check |
846
+ | ------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
847
+ | `W2M_UNREACHABLE` | dispatching side, relay not answering | §9 |
848
+ | `W2M_RABBIT_REFUSED` | dispatching side, HTTP 401 from the relay | §9 fix 4 — the network is fine |
849
+ | `COMMAND_NOT_ALLOWED` | `refused` result, agent log | §7 |
850
+ | `CAPABILITY_REFUSED` | `refused` result | the agent does not advertise the capability the task needs |
851
+ | `NO_ONLINE_DEVICE` | dispatch | §4 — no machine the relay considers online |
852
+ | `PAIRING_CODE_ROTATED` | pairing | pair again with the current code |
853
+ | `LEASE_EXPIRED` | task status | §4/§5 — the executor stopped renewing its lease |
854
+ | `P2P_UNAVAILABLE` | `refused` result in `p2pMode: direct` | §6 — the punch did not land and direct-only refuses the fallback |
855
+ | `P2P_PUNCH_TIMEOUT` | reported transport reason | §6, §13 |
856
+ | `P2P_CRYPTO_RELAY_NO_KEYS` | `p2p.encryption.reason` on a direct channel that came back plaintext | **the relay, not the peer.** This machine holds a key and the peer announced one, yet the relay served no key for it — this node has proved, from its own announcement, that the relay drops the `static_key` field. Almost always a relay older than v0.6.0: upgrade and restart it. `w2m_status` shows the same fact as `encryption.relay_serves_keys: false`. Replaced a misleading `PLAINTEXT_PEER` in v0.6.2 |
857
+ | `P2P_CRYPTO_PLAINTEXT_PEER` | `p2p.encryption.reason` | the peer genuinely publishes no key, so there is nothing to encrypt with. Check the peer's version before assuming it is merely misconfigured — and note that this code used to be reported for local causes too, which is why `P2P_CRYPTO_NO_KEY` and `..._RELAY_NO_KEYS` now exist |
858
+ | `P2P_CRYPTO_NO_KEY` | `p2p.encryption.reason` | **this** machine's gap, not the peer's: no device key of its own, no key for that peer, or a self-dial (a machine keeps no key for itself). `device.key` lives beside `device.json`, not in `--state` |
859
+ | `RUNNER_THREW` | the **auto-updater**'s spawn result (`w2m_update`), not an envelope status | the updater's own runner threw before or instead of running: read the message beside it, and check the install path. An envelope never carries this — `src/agent/exec.mjs` defines only `ok`, `nonzero_exit`, `timeout`, `crashed`, `refused` and `unverifiable` |
860
+ | `SIGNATURE_*` (`MISMATCH`, `EXPIRED`, `REPLAY`, `REQUIRED`, `INCOMPLETE`) | relay, when signing is configured | the signing secret is rotated or the two sides disagree; `SIGNING_NOT_CONFIGURED` means signing is off |
785
861
 
786
862
  Nine aggregate verdicts exist, of which `unverifiable` (§8) is the one that needs explaining rather
787
863
  than fixing.
@@ -230,6 +230,11 @@ async function main() {
230
230
  project,
231
231
  stateDir,
232
232
  identity,
233
+ // v0.6.2: the device key belongs beside the `device.json` this process actually loaded, and this
234
+ // is the only place that knows where that is. Without it the agent fell back to `--state`, a
235
+ // different directory — so on one machine the plugin and the agent each minted their own keypair
236
+ // and then overwrote each other's announcement under the one shared machine_id.
237
+ identityPath,
233
238
  caps,
234
239
  platform,
235
240
  allowedCommands,
@@ -45,6 +45,10 @@ Credentials and state:
45
45
  written to <state>/operator-token.txt if omitted.
46
46
  Pass an empty string to remove the requirement.
47
47
  [env W2M_OPERATOR_TOKEN]
48
+ --print-operator-token Print the token in this banner even when this start did
49
+ not generate it. Default off: the token is printed on
50
+ the start that generated it (and is always written to
51
+ <state>/operator-token.txt, 0600). [env W2M_PRINT_OPERATOR_TOKEN=1]
48
52
  --state <dir> State directory. Default <DSH_HOME>/xclient/rabbit.
49
53
  --no-persist Keep everything in memory; forget devices and tasks on
50
54
  restart.
@@ -102,6 +106,7 @@ try {
102
106
  'tls-key': { type: 'string' },
103
107
  'pair-rate-limit': { type: 'string' },
104
108
  'operator-token': { type: 'string' },
109
+ 'print-operator-token': { type: 'boolean', default: false },
105
110
  // v0.3.0 request signing. `--signing-secret ''` switches signing off explicitly.
106
111
  'signing-secret': { type: 'string' },
107
112
  'signing-secret-previous': { type: 'string' },
@@ -253,6 +258,10 @@ try {
253
258
  tlsKey: tlsKey ?? null,
254
259
  operatorToken,
255
260
  operatorTokenRequired,
261
+ // MEDIUM-6: the token is printed by the start that generated it, or when the operator asks for it
262
+ // here (--print-operator-token / W2M_PRINT_OPERATOR_TOKEN=1). The relay resolves both, so the
263
+ // banner below and the relay's own startup lines can never disagree about it.
264
+ printOperatorToken: values['print-operator-token'] === true,
256
265
  pairRateLimitPerMinute: pairRateLimit,
257
266
  persist: !values['no-persist'],
258
267
  // v0.3.0 request signing. Undefined means "not configured", which the relay treats as
@@ -346,11 +355,29 @@ if (values.json) {
346
355
 
347
356
  log('');
348
357
  if (operatorTokenRequired && resolvedOperatorToken) {
349
- log(' ┌────────────────────────────────────────────────────────────┐');
350
- log(' │ OPERATOR TOKEN (needed to SEND instructions, keep secret) │');
351
- log(` │ ${String(resolvedOperatorToken).padEnd(56)}│`);
352
- log(' └────────────────────────────────────────────────────────────┘');
353
- log(` Also written to ${stateDir}/operator-token.txt`);
358
+ // MEDIUM-6: this banner is the *machine's* stdout, and stdout is what journald, a container log or
359
+ // a CI transcript keeps. The token authorises dispatching to every machine in the group, so it is
360
+ // printed only on the start that generated it — the one moment it exists nowhere else — or when
361
+ // --print-operator-token / W2M_PRINT_OPERATOR_TOKEN=1 asked for it explicitly. Every other start
362
+ // says where to read it and how to change that answer.
363
+ //
364
+ // DELIBERATE, do not "fix": on that generating start the relay's own startup line prints the value
365
+ // too (on stderr). The pair is the intended shape — this box is the operator-facing handoff, that
366
+ // line is the log copy — and every other start prints it in neither place.
367
+ if (listenInfo.operatorTokenPrintable === true) {
368
+ log(' ┌────────────────────────────────────────────────────────────┐');
369
+ log(' │ OPERATOR TOKEN (needed to SEND instructions, keep secret) │');
370
+ log(` │ ${String(resolvedOperatorToken).padEnd(56)}│`);
371
+ log(' └────────────────────────────────────────────────────────────┘');
372
+ log(` Printed once because ${listenInfo.operatorTokenPrintReason === 'generated'
373
+ ? 'this start generated it'
374
+ : `${listenInfo.operatorTokenPrintReason ?? 'an option'} asked for it`}.`);
375
+ log(` Also written to ${stateDir}/operator-token.txt (0600).`);
376
+ } else {
377
+ log(' OPERATOR TOKEN — not printed (this start did not generate it).');
378
+ log(` Read it from ${stateDir}/operator-token.txt (0600).`);
379
+ log(' To print it here as well: --print-operator-token, or W2M_PRINT_OPERATOR_TOKEN=1.');
380
+ }
354
381
  log(' Device tokens cannot send work; this is what authorises it.');
355
382
  } else if (!operatorTokenRequired) {
356
383
  log(' ⚠ OPERATOR TOKEN REQUIREMENT IS OFF (--operator-token "").');
@@ -36,7 +36,24 @@ says plainly that this does not do what it sounds like:
36
36
  and the truth of our central claim, while solving only part of what the delivery ladder already
37
37
  addresses. If it is ever added, two invariants have to hold, and both belong in the ladder's
38
38
  documentation rather than in a commit message: the control plane and the punch sockets must be
39
- exempted from the tunnel, and tunnelled traffic must never be reported as `p2p`.
39
+ exempted from the tunnel, and **tunnelled traffic must never be reported as a server-free direct path**.
40
+
41
+ > **The second invariant, stated precisely — this is a correction to how it was first written.** This
42
+ > file said "tunnelled traffic must never be reported as `p2p`". That, taken literally, contradicts
43
+ > what the ledger does and should do. `transport` is the **socket path**: rung 4 *is* a socket between
44
+ > the two machines, so it reads `transport: p2p`, and calling that a lie would make the field useless.
45
+ > The rule that actually holds, and that `deploy/networking/README.md` states in the same words, is:
46
+ >
47
+ > * the ledger's `transport` **may** describe the socket path;
48
+ > * the tunnel **must be named explicitly** wherever it carried the traffic — the `p2p` block's
49
+ > `opened_by` / `offer_path`, or an equivalent field;
50
+ > * and "direct" means **"a socket between the two machines, forwarded by the hub"** — **never** "the
51
+ > bytes avoided a server".
52
+ >
53
+ > The intent of the original sentence is unchanged: a hub-and-spoke tunnel must not be able to pass
54
+ > itself off as a server-free path. What changed is the mechanism — name the tunnel, do not falsify the
55
+ > socket. `docs/AUDIT.md` §4 already put it this way ("direct socket over a tunnel the VPS forwards" —
56
+ > not "the bytes avoided a server"); this note is what makes the two files agree.
40
57
 
41
58
  ## Disciplines worth adopting, in the order we should adopt them
42
59
 
@@ -16,16 +16,39 @@ and a rung that cannot be opened is recorded with its reason rather than guessed
16
16
  | 1 | a channel is already open | `transport: p2p`, `offer_path: p2p` | nothing |
17
17
  | 2 | **simultaneous punch (A)** — the dispatcher punches, and the executor dials the same session it was told about | `transport: p2p`, `offer_path: p2p` | two NATs must agree, or one side must be dialable |
18
18
  | 3 | **reverse dial (B)** — no channel existed, so the receiving side dialled out to deliver the result | `transport: p2p`, `offer_path: relay` | the result found its own way home while the offer needed the relay |
19
- | 4 | **tunnel (C)** — WireGuard on the shared server, and every machine gets a routable `10.66.0.x` | `transport: p2p` — **indistinguishable from rung 1–2** | see the warning below |
19
+ | 4 | **tunnel (C)** — WireGuard on the shared server, and every machine gets a routable `10.66.0.x` | `transport: p2p`, **plus the tunnel named explicitly** in the same result (see below) — never a bare `p2p` with nothing else said | see the warning below |
20
20
  | 5 | relay | `transport: relay` + a `p2p.reason` naming why | the relay carries the data |
21
21
 
22
+ **What `transport` means, and what it must never be read as.** The ledger's `transport` describes the
23
+ **socket path the envelope took** — `p2p` means it was carried on a socket between the two machines
24
+ rather than on the relay's SSE stream. It does **not** mean the bytes avoided a server, and it must
25
+ never be read as "peer-to-peer" in that sense. So wherever a tunnel carried the traffic, **the tunnel
26
+ has to be named explicitly in the same result** — the `p2p` block's `opened_by` / `offer_path`, or an
27
+ equivalent field — so that a reader who only has the ledger can tell a hub-forwarded socket from one
28
+ between two machines alone.
29
+
30
+ **And "direct" in this directory means exactly one thing:** *a socket between the two machines,
31
+ forwarded by the hub.* Not "the bytes avoided a server" — never that.
32
+
33
+ **Where the code stands on this, stated rather than implied.** The two fields that exist today —
34
+ `p2p.offer_path` (`p2p` or `relay`) and `p2p.opened_by` (`dispatcher-punch`, `simultaneous-punch`,
35
+ `result-reverse-dial`; `P2P_OPENED_BY` in `src/agent/agent.mjs`) — say **which socket carried it and
36
+ which mechanism opened that socket**. Neither one currently distinguishes "a socket between two
37
+ machines" from "a socket the hub forwards", because the agent cannot see the routing table. So on
38
+ rung 4 the tunnel is named by **you**, in the deployment's own record (the `wg0` peer list and the
39
+ route that produced the `10.66.0.x` address), and that record has to travel with the result. Treating
40
+ a bare `transport: p2p` as proof of a server-free path is the reading this section exists to forbid;
41
+ if the naming is ever automated, the field belongs beside `opened_by`, not inside `transport`.
42
+
22
43
  **Rung 4 is the one to read carefully.** WireGuard has no NAT traversal of its own — that is exactly
23
44
  what Tailscale adds on top of it (disco/DERP) and what rungs 2 and 3 are for. So with two machines
24
- behind NAT, traffic between their `10.66.0.x` addresses is **forwarded through the VPS**. The ledger
25
- will say `p2p`, and it will not be lying: from W2M's point of view that socket is direct. But the
26
- bytes cross a server. If you asked for "the data goes peer to peer", rung 4 does not satisfy that —
27
- it satisfies "the fleet works regardless of NAT, encrypted, with no third party", which is a
28
- different and still useful promise. Rung 2/3 are the ones that make the data path direct.
45
+ behind NAT, traffic between their `10.66.0.x` addresses is **forwarded through the VPS**. Reading
46
+ `transport: p2p` alone, you cannot tell rung 4 from rungs 1–2 — which is precisely why rung 4 is
47
+ **not allowed to report a bare `p2p`**. Report the socket path *and* name the tunnel; a reader must be
48
+ able to tell "these two machines have a socket" from "these two machines have a socket and no third
49
+ party is on it". If you asked for "the data goes peer to peer", rung 4 does not satisfy that — it
50
+ satisfies "the fleet works regardless of NAT, encrypted, with no third party", which is a different
51
+ and still useful promise. Rungs 2/3 are the ones that make the data path direct *in the strong sense*.
29
52
 
30
53
  ## What is measured, and by what
31
54
 
@@ -122,7 +145,9 @@ Two port notes, both learned the hard way:
122
145
 
123
146
  * **Rung 5 is not a failure.** A task that completes over the relay completed. The ledger naming the
124
147
  path is the feature; pretending every path is direct would be the bug.
125
- * **A `p2p` reading on a tunnel (rung 4) does not mean the bytes avoided a server.** See above.
148
+ * **A `p2p` reading on a tunnel (rung 4) does not mean the bytes avoided a server.** See above. The
149
+ socket is between the two machines; the bytes are forwarded by the hub. `transport` answers "which
150
+ socket", never "was a third party on the path", and a tunnel result has to name the tunnel itself.
126
151
  * **A symmetric NAT cannot be dialled into, ever.** Not by this project, not by Tailscale. What can
127
152
  be done is to have the symmetric side dial *out* (rungs 2 and 3) or to stop being behind it
128
153
  (rung 4, or another network).
@@ -28,7 +28,15 @@ It registers **one** task that runs `w2m-agent-supervisor.mjs`, which owns the a
28
28
 
29
29
  * `-AtStartup` **and** `-AtLogOn`, `LogonType S4U` → runs whether the user is signed in or not,
30
30
  in session 0, with no console for anything to close;
31
- * `MultipleInstances IgnoreNew` → two agents must never share one device identity;
31
+ * a **watchdog trigger** every 5 minutes (`-WatchdogMinutes`), because a supervisor killed by a
32
+ console teardown cannot be restarted by `RestartCount` alone — the task simply ends;
33
+ * `MultipleInstances Parallel` → the trigger may start a second supervisor, which is safe because
34
+ the supervisor's own lock is what prevents a second *agent*: a generation that finds the lock held
35
+ logs a no-op line and exits without spawning. (This replaced `IgnoreNew`, which could not recover
36
+ from the one death that matters: measured, a task whose supervisor died while the task still
37
+ reported `Running` refused every later start with `0x800710E0` and stayed dead — one supervisor,
38
+ one agent, the port bound, and no way back. Re-register an existing installation with
39
+ `-UpdateTask`, which needs no elevation and touches only the triggers and settings.)
32
40
  * `RestartCount 999` / 1 min, `ExecutionTimeLimit 0`, `-StartWhenAvailable`.
33
41
 
34
42
  Everything except the task's own command line goes into **`%USERPROFILE%\.dsh\w2m\agent.json`**,
@@ -101,11 +109,22 @@ back to the relay with `offer_path: "relay"` in the ledger.
101
109
 
102
110
  ## Verify it, rather than trusting it
103
111
 
112
+ The supervisor's invariants — one generation, one agent, the punch port bound by that agent — have a
113
+ runnable check that needs no administrator right and cannot disturb a live installation (it drives
114
+ the supervisor against a stub worker on ports the OS picks):
115
+
116
+ ```powershell
117
+ node deploy\windows\verify-supervisor.mjs # 8 checks; --keep leaves the temp tree behind
118
+ ```
119
+
104
120
  ```powershell
105
121
  # the task is running, and in which session
106
122
  Get-ScheduledTask -TaskName 'W2M Localside agent' | Select-Object State
107
123
  Get-Process node | Where-Object { $_.SessionId -eq 0 } | Select-Object Id, SessionId, StartTime
108
124
 
125
+ # the supervisor's own account of itself: state, the live child, the reaped pids, the punch port
126
+ Get-Content "$env:USERPROFILE\.dsh\w2m\supervisor-heartbeat.json"
127
+
109
128
  # the agent's own narration: "stream ready" is the line that means the fleet can reach it
110
129
  Get-Content "$env:USERPROFILE\.dsh\w2m\agent.log" -Tail 20
111
130