@twinsearth/w2m-dsh-plugin 0.5.9 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +288 -3
- package/PROTOCOL-v0.4.0.md +11 -3
- package/PROTOCOL.md +69 -15
- package/README.md +180 -27
- package/SECURITY.md +69 -19
- package/TROUBLESHOOTING.md +100 -53
- package/bin/w2m-rabbit.mjs +32 -5
- package/deploy/networking/LESSONS.md +18 -1
- package/deploy/networking/README.md +32 -7
- package/deploy/windows/README.md +20 -1
- package/deploy/windows/install-service.ps1 +98 -19
- package/deploy/windows/verify-supervisor.mjs +794 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +867 -40
- package/docs/AUDIT.md +10 -1
- package/docs/DEPLOY.md +16 -3
- package/docs/P2P-CRYPTO.md +105 -4
- package/docs/PIPELINE-DESIGN.md +35 -10
- package/docs/TROUBLESHOOTING.md +22 -3
- package/package.json +5 -5
- package/src/agent/agent.mjs +152 -5
- package/src/agent/allowed-commands.mjs +480 -18
- package/src/agent/crypto.mjs +238 -40
- package/src/agent/p2p-node.mjs +40 -0
- package/src/agent/p2p.mjs +323 -30
- package/src/agent/resolve.mjs +68 -3
- package/src/plugin/fleet-target.mjs +160 -15
- package/src/plugin/tools.mjs +27 -29
- package/src/relay/server.mjs +74 -11
package/TROUBLESHOOTING.md
CHANGED
|
@@ -81,12 +81,24 @@ 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.**
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
@@ -128,7 +140,7 @@ in the profile's row and the mounted package disagree — usually because one of
|
|
|
128
140
|
```
|
|
129
141
|
|
|
130
142
|
2. **If the file is the `[]` placeholder, delete the placeholder before writing your block.** Do not
|
|
131
|
-
append to `[]
|
|
143
|
+
append to `[]`, and use the `insert:` form to create the row:
|
|
132
144
|
|
|
133
145
|
```yaml
|
|
134
146
|
# WRONG -- a sequence followed by a mapping is invalid YAML, and it takes the profile down
|
|
@@ -136,20 +148,36 @@ in the profile's row and the mounted package disagree — usually because one of
|
|
|
136
148
|
- id: w2m
|
|
137
149
|
config: { ... }
|
|
138
150
|
|
|
139
|
-
#
|
|
151
|
+
# ALSO WRONG, and quieter -- a bare `- id:` is a NON-insert patch: it replaces supplied fields on
|
|
152
|
+
# an entry an earlier layer already created. No earlier layer creates `w2m`, so the loader warns
|
|
153
|
+
# `patch: entry "w2m" not found` and skips the whole row. The install succeeds, DSH starts, and
|
|
154
|
+
# none of the eight tools exist.
|
|
140
155
|
- id: w2m
|
|
141
156
|
name: '@twinsearth/w2m-dsh-plugin'
|
|
142
157
|
config:
|
|
143
158
|
rabbitUrl: ''
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
159
|
+
|
|
160
|
+
# RIGHT -- `insert:` appends the entry, which is what creating a row means
|
|
161
|
+
- insert:
|
|
162
|
+
- id: w2m
|
|
163
|
+
name: '@twinsearth/w2m-dsh-plugin'
|
|
164
|
+
config:
|
|
165
|
+
rabbitUrl: ''
|
|
166
|
+
operatorToken: ''
|
|
167
|
+
stateDir: /absolute/path/to/$DSH_HOME/xclient
|
|
168
|
+
machineName: win-desktop
|
|
169
|
+
p2pMode: auto
|
|
170
|
+
allowedCommands: []
|
|
149
171
|
```
|
|
150
172
|
|
|
151
|
-
|
|
152
|
-
|
|
173
|
+
**When each form is appropriate.** `insert:` is for _creating_ a row — it is the only form that can,
|
|
174
|
+
and it is what the packaged `cordis.patch.yml` uses for exactly that reason. A bare `- id:` (with no
|
|
175
|
+
`insert:`) is for _overriding fields on a row that already exists_, which is what the home-level patch
|
|
176
|
+
and a plugin toggle do; used against a row that nothing created, it is a silent no-op.
|
|
177
|
+
|
|
178
|
+
In other words: replace the `[]` with an `insert:` list, or add your `- insert:` item at the same
|
|
179
|
+
indentation as the existing items. Never leave the `[]` above your block, and never reach for the
|
|
180
|
+
bare `- id:` form to create something new.
|
|
153
181
|
|
|
154
182
|
3. **The `name:` must be the bare package.** `@twinsearth/w2m-dsh-plugin` — not
|
|
155
183
|
`@twinsearth/w2m-dsh-plugin/tools`, not a path, not `.js`. A client package is identified by the
|
|
@@ -159,8 +187,9 @@ in the profile's row and the mounted package disagree — usually because one of
|
|
|
159
187
|
5. If the file is unparseable, restore the backup and restart; a malformed patch file is a whole-file
|
|
160
188
|
failure.
|
|
161
189
|
|
|
162
|
-
**Not us.**
|
|
163
|
-
shared state
|
|
190
|
+
**Not us.** Neither mechanism is this plugin's: the install writes the manifest and lockfile, and the
|
|
191
|
+
crash-recovery rename is the host's. Treat the file as shared state — your edits can be reverted by any
|
|
192
|
+
tool that manages the profile, and the `.bak-<timestamp>` sibling is the receipt.
|
|
164
193
|
|
|
165
194
|
---
|
|
166
195
|
|
|
@@ -185,14 +214,16 @@ in **v0.5.4 and v0.5.5**: the loader entry failed, so the whole host entry was s
|
|
|
185
214
|
node -p "require('$DSH_HOME/profiles/<profile>/node_modules/@twinsearth/w2m-dsh-plugin/package.json').version"
|
|
186
215
|
```
|
|
187
216
|
|
|
188
|
-
**Fix.** Install a version that is not withdrawn. v0.5.
|
|
189
|
-
v0.5.
|
|
217
|
+
**Fix.** Install a version that is not withdrawn. **v0.5.9 is the current release and is installable**;
|
|
218
|
+
v0.5.4 and v0.5.5 are not, because they prevent the application from starting. Everything else from
|
|
219
|
+
v0.5.6 on is installable too, and v0.5.3 and earlier install but predate the client half.
|
|
220
|
+
`CHANGELOG.md` carries `DO NOT INSTALL` on the withdrawn entries — check it before
|
|
190
221
|
installing a version you have not run before.
|
|
191
222
|
|
|
192
223
|
```bash
|
|
193
224
|
# Uninstall the broken copy, then install a known-good one
|
|
194
225
|
dsh plugin --profile <profile> remove @twinsearth/w2m-dsh-plugin
|
|
195
|
-
dsh plugin --profile <profile> add @twinsearth/w2m-dsh-plugin@0.5.
|
|
226
|
+
dsh plugin --profile <profile> add @twinsearth/w2m-dsh-plugin@0.5.9
|
|
196
227
|
```
|
|
197
228
|
|
|
198
229
|
The plugin being uninstalled must not leave a dangling row: if the profile still lists the package
|
|
@@ -375,6 +406,14 @@ not over the executable alone. Two consequences surprise people:
|
|
|
375
406
|
match too, and here the third token differs (`console.log` vs `console.log(1)`).
|
|
376
407
|
- A string entry is split on whitespace. A path containing a space therefore cannot be expressed as a
|
|
377
408
|
string entry — write it as a **JSON array element** instead.
|
|
409
|
+
- **An empty list means two opposite things, one per side, and the symptom looks the same.** The
|
|
410
|
+
plugin's list (`allowedCommands`) is **not a gate when empty**: with nothing configured the plugin
|
|
411
|
+
declines to refuse, deliberately, because the gate belongs to the machine that runs the command and it
|
|
412
|
+
will not duplicate a policy it was not given. The agent's list (`--allowed-commands`) is
|
|
413
|
+
**default-deny with nothing configured**: an empty list refuses **every** offer, with
|
|
414
|
+
`COMMAND_NOT_ALLOWED` and the detail _"the configured allow-list is empty, so every command is
|
|
415
|
+
refused"_. So "I set nothing on either side" runs nothing at all, and "I set nothing on the plugin
|
|
416
|
+
side" runs exactly what the agent allows — check both lists before concluding which one refused you.
|
|
378
417
|
|
|
379
418
|
**Decision procedure.**
|
|
380
419
|
|
|
@@ -590,7 +629,7 @@ If step 1 lists the version and step 2 is a non-zero number of minutes, that is
|
|
|
590
629
|
# Wait for the gate to pass (a tarball URL bypasses the resolver entirely -- but read the release
|
|
591
630
|
# notes first, and prefer a version that is not withdrawn; see CHANGELOG.md).
|
|
592
631
|
dsh plugin --profile <profile> add \
|
|
593
|
-
https://github.com/TwinsEarth/dsh-windows2macos/releases/download/v0.5.
|
|
632
|
+
https://github.com/TwinsEarth/dsh-windows2macos/releases/download/v0.5.9/twinsearth-w2m-dsh-plugin-0.5.9.tgz
|
|
594
633
|
```
|
|
595
634
|
|
|
596
635
|
Or raise the gate for this machine — a deliberate supply-chain trade, so make it deliberately:
|
|
@@ -738,25 +777,25 @@ Relative to `$DSH_HOME`, which is `%DSH_HOME%` or `~/.dsh` on Windows and `$DSH_
|
|
|
738
777
|
elsewhere. `<kind>` is the sub-directory a program is started with: `localside` for an agent,
|
|
739
778
|
`rabbit` for a relay.
|
|
740
779
|
|
|
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
|
|
752
|
-
| Profile plugin list and patch | `$DSH_HOME/profiles/<profile>/cordis.patch.yml` | DSH, then you
|
|
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
|
|
758
|
-
|
|
759
|
-
**
|
|
780
|
+
| What | Path | Created by | Windows | macOS / Linux | Notes |
|
|
781
|
+
| ------------------------------------ | ------------------------------------------------------------- | -------------------------------------- | -------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
782
|
+
| 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 |
|
|
783
|
+
| 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** |
|
|
784
|
+
| 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>` |
|
|
785
|
+
| 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 |
|
|
786
|
+
| 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 |
|
|
787
|
+
| 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 |
|
|
788
|
+
| 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 |
|
|
789
|
+
| 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/` |
|
|
790
|
+
| 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 |
|
|
791
|
+
| 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) |
|
|
792
|
+
| 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 |
|
|
793
|
+
| 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 |
|
|
794
|
+
| 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 |
|
|
795
|
+
| 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 |
|
|
796
|
+
| 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) |
|
|
797
|
+
|
|
798
|
+
**Three rules about this table.**
|
|
760
799
|
|
|
761
800
|
1. **Nothing here is inside your project.** The spool, the state and the key live under the state
|
|
762
801
|
directory on purpose: a read-only project directory must never be written to, and the spool is not
|
|
@@ -764,24 +803,32 @@ elsewhere. `<kind>` is the sub-directory a program is started with: `localside`
|
|
|
764
803
|
2. **Two "machines" sharing one `device.json` are one machine.** Duplicating a state directory to run
|
|
765
804
|
a second agent on one host makes both claim the same `machine_id`. Point `--state` (or `DSH_HOME`)
|
|
766
805
|
somewhere else instead.
|
|
806
|
+
3. **A `device.json` token is a fleet-wide _metadata_ read credential, not a machine-local one.** A task's
|
|
807
|
+
aggregate is keyed by `task_id`, and `GET /v1/tasks/{task_id}` and the report accept a device token,
|
|
808
|
+
so **any** paired machine's token can read the aggregate of **any** task on **every** machine:
|
|
809
|
+
statuses, per-machine outcomes, anchors, comparable fields, the `transport`/`p2p` blocks and counts.
|
|
810
|
+
What it cannot read that way is the captured output — the aggregate carries `stdout_sha256` and
|
|
811
|
+
`stdout_bytes`, not the text. Only `POST /v1/task` (dispatch) requires the operator token. So "a
|
|
812
|
+
machine that can receive work cannot command the others" is true, and "it cannot see what the others
|
|
813
|
+
were asked to do, or how it went" is **not**. Keep the token file as private as the operator token.
|
|
767
814
|
|
|
768
815
|
---
|
|
769
816
|
|
|
770
817
|
## 17. Reference: the reason codes you will actually see
|
|
771
818
|
|
|
772
|
-
| Code | Where it appears
|
|
773
|
-
| ------------------------------------------------------------------------- |
|
|
774
|
-
| `W2M_UNREACHABLE` | dispatching side, relay not answering
|
|
775
|
-
| `W2M_RABBIT_REFUSED` | dispatching side, HTTP 401 from the relay
|
|
776
|
-
| `COMMAND_NOT_ALLOWED` | `refused` result, agent log
|
|
777
|
-
| `CAPABILITY_REFUSED` | `refused` result
|
|
778
|
-
| `NO_ONLINE_DEVICE` | dispatch
|
|
779
|
-
| `PAIRING_CODE_ROTATED` | pairing
|
|
780
|
-
| `LEASE_EXPIRED` | task status
|
|
781
|
-
| `P2P_UNAVAILABLE` | `refused` result in `p2pMode: direct`
|
|
782
|
-
| `P2P_PUNCH_TIMEOUT` | reported transport reason
|
|
783
|
-
| `RUNNER_THREW` | envelope status
|
|
784
|
-
| `SIGNATURE_*` (`MISMATCH`, `EXPIRED`, `REPLAY`, `REQUIRED`, `INCOMPLETE`) | relay, when signing is configured
|
|
819
|
+
| Code | Where it appears | First thing to check |
|
|
820
|
+
| ------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
821
|
+
| `W2M_UNREACHABLE` | dispatching side, relay not answering | §9 |
|
|
822
|
+
| `W2M_RABBIT_REFUSED` | dispatching side, HTTP 401 from the relay | §9 fix 4 — the network is fine |
|
|
823
|
+
| `COMMAND_NOT_ALLOWED` | `refused` result, agent log | §7 |
|
|
824
|
+
| `CAPABILITY_REFUSED` | `refused` result | the agent does not advertise the capability the task needs |
|
|
825
|
+
| `NO_ONLINE_DEVICE` | dispatch | §4 — no machine the relay considers online |
|
|
826
|
+
| `PAIRING_CODE_ROTATED` | pairing | pair again with the current code |
|
|
827
|
+
| `LEASE_EXPIRED` | task status | §4/§5 — the executor stopped renewing its lease |
|
|
828
|
+
| `P2P_UNAVAILABLE` | `refused` result in `p2pMode: direct` | §6 — the punch did not land and direct-only refuses the fallback |
|
|
829
|
+
| `P2P_PUNCH_TIMEOUT` | reported transport reason | §6, §13 |
|
|
830
|
+
| `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` |
|
|
831
|
+
| `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
832
|
|
|
786
833
|
Nine aggregate verdicts exist, of which `unverifiable` (§8) is the one that needs explaining rather
|
|
787
834
|
than fixing.
|
package/bin/w2m-rabbit.mjs
CHANGED
|
@@ -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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
|
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
|
|
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**.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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).
|
package/deploy/windows/README.md
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|