@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.
- package/CHANGELOG.md +379 -3
- package/PROTOCOL-v0.4.0.md +11 -3
- package/PROTOCOL.md +69 -15
- package/README.md +197 -27
- package/SECURITY.md +69 -19
- package/TROUBLESHOOTING.md +129 -53
- package/bin/w2m-localside.mjs +5 -0
- 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 +242 -23
- package/src/agent/allowed-commands.mjs +480 -18
- package/src/agent/crypto.mjs +252 -40
- package/src/agent/p2p-node.mjs +136 -7
- package/src/agent/p2p.mjs +342 -37
- 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/persistence.mjs +72 -2
- package/src/relay/server.mjs +84 -11
- package/src/relay/state.mjs +9 -2
package/TROUBLESHOOTING.md
CHANGED
|
@@ -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.**
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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.**
|
|
163
|
-
shared state
|
|
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.
|
|
189
|
-
v0.5.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
**
|
|
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
|
|
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
|
-
| `
|
|
784
|
-
| `
|
|
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.
|
package/bin/w2m-localside.mjs
CHANGED
|
@@ -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,
|
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
|
|