@twinsearth/w2m-dsh-plugin 0.5.8 → 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/SECURITY.md CHANGED
@@ -52,16 +52,23 @@ real service and other people's machines are paired to it.
52
52
 
53
53
  ## Supported versions
54
54
 
55
- **The supported line is v0.5.3 and everything before it.** A version is installable unless the table
56
- says otherwise. Security fixes land on the newest installable version; older installable versions are
57
- not back-patched unless a report makes that necessary, and if it does, this table will say so.
58
-
59
- | Version | Installable | Note |
60
- | ---------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
61
- | **v0.5.6 and later** | not yet released | In progress as this document is written. Check `CHANGELOG.md` for the newest entry that is not marked withdrawn. |
62
- | v0.5.5 | **no — withdrawn** | Fails to activate during DSH's web boot, which makes the whole application refuse to start, and takes all eight tools with it. |
63
- | v0.5.4 | **no — withdrawn** | Same loader failure. `CHANGELOG.md` marks it `DO NOT INSTALL`. |
64
- | **v0.5.3 and earlier** | **yes** | The supported line. The client half was added in v0.5.4; v0.5.3 is a host-only plugin that loads. |
55
+ **The supported line is v0.5.9, the newest published version.** A version is installable unless the
56
+ table says otherwise. Security fixes land on the newest installable version; older installable
57
+ versions are not back-patched unless a report makes that necessary, and if it does, this table will
58
+ say so.
59
+
60
+ | Version | Installable | Note |
61
+ | ------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
62
+ | **v0.5.9** | **yes** | The supported line, and the current release. The client half is present and activates. |
63
+ | v0.5.6 – v0.5.8 | **yes** | Installable, and each one works, but each is superseded by v0.5.9. Prefer the newest. |
64
+ | v0.5.5 | **no — withdrawn** | Fails to activate during DSH's web boot, which makes the whole application refuse to start, and takes all eight tools with it. |
65
+ | v0.5.4 | **no — withdrawn** | Same loader failure. `CHANGELOG.md` marks it `DO NOT INSTALL`. |
66
+ | v0.5.3 and earlier | **yes — outdated** | Installable, and v0.5.3 is the last host-only release. Nothing before v0.5.4 has the client half, so no composer control. |
67
+
68
+ **v0.5.4 and v0.5.5 are withdrawn because they prevent the application from starting.** That is not a
69
+ degraded plugin or a missing feature: the loader entry fails, so the whole host entry is skipped and
70
+ DSH's web boot refuses, taking all eight tools with it. The failure and its cause are written up in
71
+ §3 of `TROUBLESHOOTING.md`.
65
72
 
66
73
  The withdrawals are not a security judgement: those versions are not installable, so they are not
67
74
  supported. `CHANGELOG.md` is the authority — an entry marked `DO NOT INSTALL` is not supported
@@ -71,11 +78,11 @@ whatever this table says, and the reverse is also true.
71
78
 
72
79
  ```bash
73
80
  # The newest installable version from the registry
74
- dsh plugin --profile <profile> add @twinsearth/w2m-dsh-plugin@0.5.3
81
+ dsh plugin --profile <profile> add @twinsearth/w2m-dsh-plugin@0.5.9
75
82
 
76
83
  # Or the same bytes from the release, verified against SHA256SUMS from the same release
77
84
  dsh plugin --profile <profile> add \
78
- https://github.com/TwinsEarth/dsh-windows2macos/releases/download/v0.5.3/twinsearth-w2m-dsh-plugin-0.5.3.tgz
85
+ https://github.com/TwinsEarth/dsh-windows2macos/releases/download/v0.5.9/twinsearth-w2m-dsh-plugin-0.5.9.tgz
79
86
  ```
80
87
 
81
88
  A version published minutes ago may be invisible to pnpm's `minimumReleaseAge` gate. That is the
@@ -98,6 +105,14 @@ The split is the point: a machine that can receive work does not thereby gain th
98
105
  the others. If a machine's device token is stolen, the damage is confined to that machine's own
99
106
  execution surface; it cannot be used to drive the fleet.
100
107
 
108
+ **It does, however, read the fleet's task metadata.** `GET /v1/tasks/{task_id}`, `POST /v1/heartbeat`
109
+ and the result/notice routes accept a **device** token, and a task's aggregate is keyed by `task_id`,
110
+ not by which machine is asking. So any paired machine's device token can read the aggregate of **any**
111
+ task on **every** machine — statuses, per-machine outcomes, anchors, comparable fields, counts. What it
112
+ cannot read that way is the captured output itself: the aggregate carries hashes and byte counts, not
113
+ `stdout`/`stderr` text. Treat the device token as a **fleet-wide metadata read credential**, not as a
114
+ credential scoped to its own machine.
115
+
101
116
  The relay writes the operator token once, on first start, and prints it once on stderr. It is
102
117
  deliberately absent from `--json` output — a token in a log is a leak. Treat it as a credential: it is
103
118
  never needed in a bug report.
@@ -106,8 +121,22 @@ never needed in a bug report.
106
121
 
107
122
  - **Run commands you allow, in the project directory it was pointed at.** The allow-list is
108
123
  **default-deny** and matches the whole `argv`, token by token: an entry `node --test` permits
109
- `node --test x.test.mjs` and refuses `node --testify x`. An empty list is not "nothing runs" — it
110
- means the plugin-side gate is not applied, so rely on the agent-side `--allowed-commands`.
124
+ `node --test x.test.mjs` and refuses `node --testify x`. That permission is **as wide as the tool**:
125
+ the test runner loads and runs the file it is handed, so `node --test` is a grant of code execution
126
+ from any file the machine can read — a prefix entry is not a sandbox. Two narrower rules apply on
127
+ top of the prefix match: trailing arguments that make the tool load code the entry never named
128
+ (`--test-reporter`, `--import`, `-e`, `python -c`, …) are refused with `COMMAND_REPARSING_ARGUMENT`,
129
+ and `argv[0]` is admitted as a path only when it is the file the entry's executable resolves to
130
+ (`COMMAND_EXECUTABLE_PATH_MISMATCH`; relative paths are refused outright with
131
+ `COMMAND_EXECUTABLE_RELATIVE_PATH`). Neither turns a prefix entry into a per-argument policy — if
132
+ you need that, name the arguments in the entry. **An empty allow-list means two opposite
133
+ things on the two sides, so set both.** The plugin's list (`allowedCommands`) is not a gate at all
134
+ when it is empty: with nothing configured the plugin declines to refuse, deliberately, and says so —
135
+ `checkAllowedCommand` returns "may proceed" rather than duplicating a policy it was not given. The
136
+ agent's list (`--allowed-commands`) is default-deny with nothing configured: an empty list refuses
137
+ **every** offer, and the refusal names the reason. So a fleet with no list on either side runs
138
+ nothing, and a fleet with the plugin's list empty but an agent list set runs exactly what the agent
139
+ allows — which is the only one of the two that is a control.
111
140
  - **Read the project** it was started with, and compute git anchors and fingerprints over it.
112
141
  - **Report results**, including command output, to the relay — where the operator who dispatched the
113
142
  work can read them.
@@ -116,12 +145,16 @@ never needed in a bug report.
116
145
  ### What a paired machine cannot do
117
146
 
118
147
  - **Run anything outside its allow-list.** A refused command is reported as `refused` with
119
- `COMMAND_NOT_ALLOWED`, not executed.
148
+ `COMMAND_NOT_ALLOWED` — or, for the narrower rules above, `COMMAND_REPARSING_ARGUMENT`,
149
+ `COMMAND_EXECUTABLE_PATH_MISMATCH` or `COMMAND_EXECUTABLE_RELATIVE_PATH` — not executed.
120
150
  - **Command other machines.** That requires the operator token, which the agent is not given.
121
- - **Read another machine's results.** This is the claim people most reasonably doubt, and it is worth
122
- being precise: the operator token authorises _dispatch_. A machine's results are visible to whoever
123
- holds the operator token for that relay — the ledger is the relay's, not the agent's — so an agent
124
- holding only its device token cannot enumerate or fetch another machine's output.
151
+ - **Read another machine's captured output.** This is the claim people most reasonably doubt, so it is
152
+ worth being exact rather than broad — see the read-scope paragraph above, which is the other half of
153
+ it. The operator token authorises _dispatch_, and no step of a dispatch needs a machine's `stdout`.
154
+ The relay's stored envelope does carry `stdout_head`/`stdout_tail`, but the aggregate that
155
+ `GET /v1/tasks/{task_id}` and the report render expose only `stdout_sha256`, `stdout_bytes` and the
156
+ comparable fields, so a device token alone cannot fetch that text. **Fleet-wide metadata, no captured
157
+ output** — if you need stronger than that, it is not there today.
125
158
  - **Ask for your interactive confirmation per command.** There is no approval prompt in the loop. The
126
159
  allow-list is the gate, which is why it is default-deny and why you should make it as narrow as the
127
160
  work allows.
@@ -131,6 +164,23 @@ never needed in a bug report.
131
164
  and Windows has no `sshd` by default. If you find anything in this project writing to an
132
165
  `authorized_keys` file, that is a security report — please use the private channel above.
133
166
 
167
+ ### The guarantees nobody has tested yet
168
+
169
+ The consent model above is the claim this project asks you to trust. Four of its statements are, as of
170
+ v0.5.9, **not backed by any test** — and the difference between "enforced", "true by absence" and "true
171
+ because we looked" matters, so each is labelled here the way an audit would label it rather than the way
172
+ a policy document would.
173
+
174
+ | Statement | Why it holds today | What it would take to be checkable |
175
+ | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
176
+ | **The relay holds no model credentials and no working copy.** | **Enforced by the code's shape, not by a test or a guard.** The relay is a process whose entire filesystem access is its own state: `src/relay/persistence.mjs` reads and writes `devices.json`, `ledger.jsonl` and `operator-token.txt`, and `src/relay/server.mjs` reads the TLS cert and key. It never reads a project directory, so there is no code path a model credential could arrive through. It _does_ hold your task metadata, results and the operator token — that is the ledger's job, and it is stated above. | A test that the packed relay, started in a temp home with a project directory containing a `.credentials.yaml`, opens no file under that directory. `test/claims.test.mjs` is the natural home. |
177
+ | **The plugin never reads `.credentials.yaml`.** | **True by absence.** There is **no code path that references it anywhere in this repository** — no `readFile`, no `join`, nothing but the one comment in `src/plugin/tools.mjs` that promises not to. That is the strongest evidence available and it is **weaker than a test**: it says nobody wrote it yet, not that nothing can. | A source-level assertion over the shipped files: no `src/**` or `bin/**` file mentions a credentials path. Cheap, and it would catch the day someone adds one. |
178
+ | **Nothing writes `authorized_keys`, and no SSH server is opened.** | **True by absence**, same as above, and the same caveat: no `authorized_keys`, `ssh-keygen` or equivalent string exists in any `.mjs` in this repository. The design reason is real (every machine makes only outbound connections, and Windows ships no `sshd`), but the design reason is not the check. | Same as above: a source-level scan asserted by a test, plus the existing `test/outbound-only.test.mjs`, which already shows the machine side opens no listening TCP port. |
179
+ | **There is no approval prompt in the loop.** | **True by design, and nameable.** The plugin contains no permission-gating code at all — the word does not appear in it outside a comment listing what the tool dispatcher does _not_ implement — so a command that reaches the agent runs, and the allow-list is the only gate. Note that this is deliberately **not** the DSH permission prompt: if the dispatching session's own policy asks you to approve a call, that prompt is the harness's and is unaffected. | Hard to assert negatively; a claims check that the plugin registers no `approval`/`permission` request surface is the nearest honest form. |
180
+
181
+ None of the four becomes a guarantee by being written down. If you need one of them to be load-bearing
182
+ for a workload, treat it as **unverified** until a test in `test/claims.test.mjs` carries it.
183
+
134
184
  ### What it touches on your machine
135
185
 
136
186
  Declared here and machine-readably in `package.json`'s `disclosure`, because installing a plugin runs
@@ -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.** `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
@@ -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
- # RIGHT -- one top-level list, with your row inside it
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
- operatorToken: ''
145
- stateDir: /absolute/path/to/$DSH_HOME/xclient
146
- machineName: win-desktop
147
- p2pMode: auto
148
- allowedCommands: []
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
- 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.
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.** 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.
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.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
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.3
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.3/twinsearth-w2m-dsh-plugin-0.5.3.tgz
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 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.**
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 | 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 |
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.
@@ -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).