@twinsearth/w2m-dsh-plugin 0.5.6 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -54,6 +54,27 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
54
54
  * Still unverified, and not claimed: how the control *renders*. No browser was opened. What is proven is
55
55
  the entire host side — the entry activates, the eight tools register, the boot-graph row is composed
56
56
  from the shipped bundle, the route is registered and attached — plus the seat shape in a VM test.
57
+ ## [0.5.7] — 2026-10-11
58
+
59
+ ### Fixed
60
+
61
+ * **v0.5.6 shipped without its own documents.** `TROUBLESHOOTING.md` and `SECURITY.md` were written,
62
+ reviewed and committed, and then **not packed**, because `files` names the documents individually
63
+ rather than by pattern. Anyone installing v0.5.6 got the code and none of the guidance. They are in the
64
+ package now, and `test/claims.test.mjs` is the place this class of defect belongs.
65
+ * **v0.5.6 shipped without its own gates wired in.** `test/claims.test.mjs`, `test/loader-entry.test.mjs`
66
+ and the two mDNS suites existed and passed, but were listed in none of `test`, `test:all` or `verify` —
67
+ a test that does not run protects nothing, which is the exact defect this release's own study
68
+ identified in someone else's repository. All four are wired in.
69
+ * A typographic character (a smart quote) in `test/mdns-discover.test.mjs`, caught by the repository's
70
+ own mojibake scan.
71
+
72
+ ### Notes
73
+
74
+ * Fix-forward, not a rewrite: v0.5.6 is published and stays published, because the alternative is
75
+ rewriting history someone may already have. Both defects are mine, and both are the class this project
76
+ keeps naming — a claim that nothing checks.
77
+ * No runtime change: the code in v0.5.7 is the code in v0.5.6.
57
78
  ## [0.5.6] — 2026-10-11
58
79
 
59
80
  ### Fixed
@@ -0,0 +1,123 @@
1
+ # Contributing
2
+
3
+ One page. Everything here is a rule you can check, not a preference — if a line in this document
4
+ cannot be verified by reading the diff, it does not belong here.
5
+
6
+ ## Setup
7
+
8
+ ```bash
9
+ git clone https://github.com/TwinsEarth/dsh-windows2macos
10
+ cd dsh-windows2macos
11
+ npm install # dev tools only: eslint, prettier, globals
12
+ node --version # must satisfy package.json engines: >=20.19.0
13
+ ```
14
+
15
+ There are **no runtime dependencies** — `dependencies` in `package.json` is `{}` and stays that way.
16
+ Adding one is a design change, not a convenience, and it will be refused. `@deepseek-ai/dsh-tools` is
17
+ an optional peer: the plugin imports it when DSH provides it and falls back to a small offline shim
18
+ when it does not, so the suites run with no Harness installed.
19
+
20
+ ## The commands
21
+
22
+ ```bash
23
+ # The unit suites (what scripts.test runs), and everything including e2e
24
+ node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs
25
+ npm test
26
+ npm run test:all
27
+
28
+ # One suite while you work
29
+ node --test --test-force-exit test/claims.test.mjs
30
+
31
+ # Lint: must be 0 errors, and 0 warnings
32
+ node node_modules/eslint/bin/eslint.js .
33
+
34
+ # Entry points import: lib/tools.js and lib/index.js, the specifiers a profile mounts
35
+ node scripts/check-entrypoints.mjs
36
+
37
+ # The installed copy registers its tools (point it at a profile's node_modules copy)
38
+ node scripts/verify-installed.mjs <path-to-installed-package>
39
+ ```
40
+
41
+ `scripts/check-entrypoints.mjs` proves the entry points **import**. It does not prove the loader
42
+ **composes** them — those are different failures, and the second one shipped once (v0.5.4: the entry
43
+ failed inside the real Loader and every tool disappeared while `verify-installed.mjs` passed). When
44
+ your change touches mounting, exports, `cordis.patch.yml` or the client half, run
45
+ `test/loader-entry.test.mjs` too: it installs the packed tarball with `dsh plugin add` into a
46
+ throwaway profile under a throwaway `DSH_HOME` and boots it with the real `runProfile`.
47
+
48
+ ## Conventions
49
+
50
+ Each line is checkable against a diff.
51
+
52
+ 1. **Zero runtime dependencies.** `dependencies === {}` in `package.json`. Node built-ins only.
53
+ 2. **Argv execution, never a shell string.** A command is an array of arguments passed to
54
+ `spawn`/`execFile` with no shell. No `exec`, no `shell: true`, no string interpolation into a
55
+ command line. This is what makes the allow-list meaningful: a shell would re-parse the argv the
56
+ allow-list just approved.
57
+ 3. **One named constant per network bound.** Every timeout, port, retry count, size limit and
58
+ interval is a named `const` in one place, exported if a test needs it. A literal `5000` in a
59
+ request call is a review comment.
60
+ 4. **A failure carries a reason code, not a boolean.** `{ ok: false, code: 'W2M_UNREACHABLE', ... }`
61
+ or a thrown error with `code` — never `false`, never `null`, never a bare string. `undefined` may
62
+ mean "absent"; it may not mean "failed".
63
+ 5. **Commit format: `<type>: <one line>`.** Types: `feat`, `fix`, `docs`, `test`, `chore`, `perf`,
64
+ `refactor`, `build`, `ci`. One line, imperative, no trailing period; put the argument for the
65
+ change in the body, not in the subject.
66
+ 6. **Docs changes are code changes.** If a claim in `README.md`, `PROTOCOL.md` or a deploy guide is
67
+ now false, fix it in the same PR. `test/claims.test.mjs` gates the machine-checkable subset.
68
+ 7. **A test that would pass against a broken artifact is not evidence.** See below.
69
+ 8. **Say what you verified, and how.** In the PR: what was run live, what was covered by a test, and
70
+ what is not verified at all. "Not verified" is an acceptable answer; an implied verification is
71
+ not.
72
+
73
+ ## Rule 7, in full: prefer the real runtime to a stand-in
74
+
75
+ This project learned it the hard way. The v0.5.4 loader failure was invisible to **every** existing
76
+ test, because those tests drove the plugin with a plain-object context where reading an undeclared
77
+ service property merely yields `undefined`. The real Loader throws instead. The fake was more
78
+ forgiving than the runtime, so the suite was green while the shipped artifact broke the application.
79
+
80
+ Therefore:
81
+
82
+ - **Drive the real thing when the real thing is reachable.** The entry points, the packed tarball,
83
+ the real Loader, a real pnpm install into a throwaway profile, the real `fetch` against a local
84
+ server. `test/loader-entry.test.mjs` exists precisely to pay this cost once.
85
+ - **When a stand-in is unavoidable, prove it in both directions.** A fix is not demonstrated by a
86
+ green test alone. Take the broken artifact, keep it, and show the new test **fails** against it —
87
+ then show it passes against the fix. `test/loader-entry.test.mjs` does this: 6/6 pass on the fixed
88
+ tree and 6/6 fail against the preserved v0.5.4 tarball, on the exact loader error.
89
+ - **Make the fake stricter than the runtime, never more forgiving.** If your stub silently accepts
90
+ something the runtime throws on, the test is measuring your stub.
91
+ - **A skip must be loud and must name what it could not reach.** A test that quietly returns when a
92
+ probe is missing is worse than no test: it reports confidence it does not have. Say which probe you
93
+ tried and how to enable it.
94
+ - **Fix a claim's drift at its source.** If a document, a count or a version is wrong, correct it
95
+ rather than exempting it in the test that caught it. An exemption list is where a gate goes to die.
96
+
97
+ ## Pull requests
98
+
99
+ 1. **Branch** off `main`; do not commit to `main`.
100
+ 2. **Keep it one idea.** A rename plus a behaviour change plus a doc rewrite is three PRs.
101
+ 3. **Run before you push**, and paste the real output into the PR:
102
+
103
+ ```bash
104
+ npm test
105
+ node node_modules/eslint/bin/eslint.js .
106
+ node scripts/check-entrypoints.mjs
107
+ ```
108
+
109
+ 4. **Describe verification, not effort.** The PR body answers: what changed, what proves it, what is
110
+ not proven, and which commands you ran with their result counts.
111
+ 5. **Add a CHANGELOG entry** under the version you are working on, in the existing style. A behaviour
112
+ change with no CHANGELOG line will be sent back.
113
+ 6. **Reviewers check the conventions list above**, line by line, against the diff. Expect to be asked
114
+ "which constant?", "which code?", "what did you run?".
115
+ 7. **A version that must not be installed gets marked.** If a release is broken after publication,
116
+ add `DO NOT INSTALL` to its CHANGELOG entry, in its own section, and say why. `test/claims.test.mjs`
117
+ reads that marker: it refuses to let the current version be one the CHANGELOG tells users not to
118
+ install.
119
+
120
+ ## Security
121
+
122
+ Do not open a public issue for a vulnerability. See [SECURITY.md](SECURITY.md) for the private
123
+ channel and what to include.
package/SECURITY.md ADDED
@@ -0,0 +1,162 @@
1
+ # Security policy
2
+
3
+ This document covers two things: how to report a vulnerability **privately**, and the consent model —
4
+ what a machine that joins your fleet can and cannot do. The second is the claim this project asks you
5
+ to trust, so it is stated as behaviour you can check, not as a promise.
6
+
7
+ ---
8
+
9
+ ## Reporting a vulnerability
10
+
11
+ **Do not open a public issue for a vulnerability.** A public issue is readable by everyone the moment
12
+ it is filed, including anyone who has not updated yet, and it turns a fix into a race. This project
13
+ deliberately does **not** offer a public-issue route for security reports.
14
+
15
+ Use GitHub's private vulnerability reporting — the form creates a private advisory only the
16
+ maintainers can see:
17
+
18
+ ```
19
+ https://github.com/TwinsEarth/dsh-windows2macos/security/advisories/new
20
+ ```
21
+
22
+ If that page is unavailable, open a **non-sensitive** issue that says only _"I have a security report
23
+ and need a private channel"_, with no details, no reproducer and no affected version. A maintainer
24
+ will open the private thread. Do not put the substance in the issue.
25
+
26
+ ### What to include
27
+
28
+ The report is only actionable if it starts from a shape we can reproduce. Please include:
29
+
30
+ 1. **What you can do that you should not be able to** — one sentence, then the steps.
31
+ 2. **Affected version**, exactly as installed
32
+ (`node -p "require('./node_modules/@twinsearth/w2m-dsh-plugin/package.json').version"`), and which
33
+ of the two halves is involved: the DSH plugin, the relay (`w2m-rabbit`), the per-machine agent
34
+ (`w2m-localside`), or the STUN responder.
35
+ 3. **The trust boundary you crossed.** This project's boundaries are: the operator token vs. the
36
+ device token; the per-machine allow-list; and the direct UDP path vs. the relay. Saying which one
37
+ moved is most of the analysis.
38
+ 4. **A reproducer**, or the exact commands and their output. If it needs two machines, say what the
39
+ second one was doing.
40
+ 5. **Your assessment of impact**, and whether the issue is public already.
41
+
42
+ ### What we will do
43
+
44
+ - Acknowledge the report in the private thread.
45
+ - Say whether we can reproduce it, and if not, say what we would need.
46
+ - Tell you when a fixed version is published, and credit you unless you ask us not to.
47
+
48
+ No bug bounty is offered. Do not test against infrastructure you do not own: the shared relay is a
49
+ real service and other people's machines are paired to it.
50
+
51
+ ---
52
+
53
+ ## Supported versions
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. |
65
+
66
+ The withdrawals are not a security judgement: those versions are not installable, so they are not
67
+ supported. `CHANGELOG.md` is the authority — an entry marked `DO NOT INSTALL` is not supported
68
+ whatever this table says, and the reverse is also true.
69
+
70
+ ### Installing a supported version
71
+
72
+ ```bash
73
+ # The newest installable version from the registry
74
+ dsh plugin --profile <profile> add @twinsearth/w2m-dsh-plugin@0.5.3
75
+
76
+ # Or the same bytes from the release, verified against SHA256SUMS from the same release
77
+ 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
79
+ ```
80
+
81
+ A version published minutes ago may be invisible to pnpm's `minimumReleaseAge` gate. That is the
82
+ gate working, not a supply-chain problem — see `TROUBLESHOOTING.md` §11.
83
+
84
+ ---
85
+
86
+ ## The consent model
87
+
88
+ Pairing a machine is a decision. This is exactly what it grants, and what it does not.
89
+
90
+ ### Two credentials, on purpose
91
+
92
+ | Credential | Held by | Can | Cannot |
93
+ | ------------------ | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
94
+ | **Operator token** | the dispatcher: your DSH profile (`operatorToken` config or `W2M_OPERATOR_TOKEN`) | authorise a _dispatch_ — send work to machines | read another machine's results, or act as an agent. No request that dispatches is sent without it |
95
+ | **Device token** | each machine's agent, in its own state directory (§16 of `TROUBLESHOOTING.md`) | take work offered to **that** machine and report that machine's results | dispatch work to any machine, including itself |
96
+
97
+ The split is the point: a machine that can receive work does not thereby gain the ability to command
98
+ the others. If a machine's device token is stolen, the damage is confined to that machine's own
99
+ execution surface; it cannot be used to drive the fleet.
100
+
101
+ The relay writes the operator token once, on first start, and prints it once on stderr. It is
102
+ deliberately absent from `--json` output — a token in a log is a leak. Treat it as a credential: it is
103
+ never needed in a bug report.
104
+
105
+ ### What a paired machine can do
106
+
107
+ - **Run commands you allow, in the project directory it was pointed at.** The allow-list is
108
+ **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`.
111
+ - **Read the project** it was started with, and compute git anchors and fingerprints over it.
112
+ - **Report results**, including command output, to the relay — where the operator who dispatched the
113
+ work can read them.
114
+ - **Reach its peers' punch port over UDP**, if the punch succeeds.
115
+
116
+ ### What a paired machine cannot do
117
+
118
+ - **Run anything outside its allow-list.** A refused command is reported as `refused` with
119
+ `COMMAND_NOT_ALLOWED`, not executed.
120
+ - **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.
125
+ - **Ask for your interactive confirmation per command.** There is no approval prompt in the loop. The
126
+ allow-list is the gate, which is why it is default-deny and why you should make it as narrow as the
127
+ work allows.
128
+ - **Write to `~/.ssh/authorized_keys` — on any machine, ever.** Neither the plugin, the relay nor the
129
+ agent installs an SSH key, opens an SSH server, or adds a key file anywhere. The design reason is
130
+ that cross-machine execution here does not need SSH: every machine makes only outbound connections,
131
+ and Windows has no `sshd` by default. If you find anything in this project writing to an
132
+ `authorized_keys` file, that is a security report — please use the private channel above.
133
+
134
+ ### What it touches on your machine
135
+
136
+ Declared here and machine-readably in `package.json`'s `disclosure`, because installing a plugin runs
137
+ its code with your permissions:
138
+
139
+ | Surface | What it is |
140
+ | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
141
+ | **Filesystem** | reads and writes inside the project directory you name (anchors, fingerprints), plus its own state directory: the spool, `agent-state.json`, `device.json` and `device.key` |
142
+ | **Network** | the configured relay (default: the shared server), the STUN servers (one is the shared server, three are public), and UDP to peer machines |
143
+ | **Inbound** | peers can reach this machine's punch port. It is **ephemeral unless you pin it** with `--p2p-port`; pinning is what makes a narrow firewall rule possible, and an ephemeral port means a broader rule is needed |
144
+ | **Credentials** | the operator token in profile config or `W2M_OPERATOR_TOKEN`; the device token and key under the state directory |
145
+ | **Jurisdiction** | data crosses between wherever your machines are and wherever the relay is; the reference deployment is CN ↔ JP |
146
+
147
+ ### What this project does not claim
148
+
149
+ - **The relay sees your task metadata and results.** It is the ledger. If that is not acceptable for
150
+ a workload, run your own relay — `deploy/` covers shared-server, nginx, Caddy, Docker, WireGuard and
151
+ systemd shapes — or do not use this plugin for that workload.
152
+ - **The direct path is a security boundary.** A successful UDP hole punch means a peer can send you
153
+ packets on that socket. The data plane's encryption status is a separate, in-progress subject and is
154
+ specified in `docs/P2P-CRYPTO.md`; `w2m_status` reports what is actually in force rather than what is
155
+ intended.
156
+ - **`chmod 0600` means the same thing everywhere.** On NTFS it is best-effort: the file mode does not
157
+ restrict other users of that Windows machine. The key file's protection on Windows is the account's
158
+ own filesystem ACLs, not the mode this project sets.
159
+ - **Anything is verified by the attacker's absence.** The plugin's own test suites are the evidence we
160
+ have, and they are listed in `README.md` with what is and is not covered. Two things in particular
161
+ are **not** verified: a real Windows↔macOS pair over the network, and one account driving two
162
+ machines' model sessions.
@@ -0,0 +1,787 @@
1
+ # W2M troubleshooting
2
+
3
+ Symptom first. Find the sentence you are looking at, follow the checks in order, and stop when one
4
+ of them answers. Every check is a command you can run; every claim about a cause says whether it is
5
+ ours, the operating system's, the network's, or another tool's.
6
+
7
+ If you are about to file a bug, run the **triage block** at the end of §15 first — it collects the
8
+ five facts a maintainer will ask for anyway.
9
+
10
+ **Where things live:** §16 is a table of every file and directory this project creates, per platform.
11
+ You will want it before §4 and §9.
12
+
13
+ ---
14
+
15
+ ## The first four minutes
16
+
17
+ Most reports are one of four things. In this order:
18
+
19
+ | # | Check | Command |
20
+ | --- | ------------------------------------------------------------------ | ------------------------------------------------- |
21
+ | 1 | Are the tools there at all? | in DSH: _list the w2m tools_ — then `w2m_devices` |
22
+ | 2 | Is the relay reachable from **this** machine? | `w2m_status` and read `rabbit_source` / the probe |
23
+ | 3 | Is the agent running on the machine that is not answering? | see §5 |
24
+ | 4 | Does the profile's `cordis.patch.yml` still contain the `w2m` row? | §2, §3 — an installer can rewrite it |
25
+
26
+ ---
27
+
28
+ ## 1. `w2m_*` tools are missing after an upgrade
29
+
30
+ **Symptom.** You upgraded the plugin and the eight tools are gone from the session. Sometimes DSH
31
+ still starts; sometimes it does not (then read §3).
32
+
33
+ **What this is.** Almost always a _composed_ problem, not an installed one: the package is in the
34
+ profile's `node_modules`, but no `w2m` row was composed into the running config, so nothing
35
+ registered. "It installed" is not "it loads".
36
+
37
+ **Decision procedure.**
38
+
39
+ ```bash
40
+ # 1. What is actually installed, and where?
41
+ cat "$DSH_HOME/profiles/<profile>/node_modules/@twinsearth/w2m-dsh-plugin/package.json" | grep '"version"'
42
+
43
+ # 2. Does that copy register eight tools on its own?
44
+ # Run this from a checkout of this repository: scripts/verify-installed.mjs imports the real
45
+ # entry and calls apply() against a stub tools registry.
46
+ node scripts/verify-installed.mjs \
47
+ "$DSH_HOME/profiles/<profile>/node_modules/@twinsearth/w2m-dsh-plugin"
48
+ ```
49
+
50
+ `PASS: 8 tools registered` with the tools missing in the session means the artifact is fine and the
51
+ composition is not — go to step 3. A `FAIL:` here means the installed copy itself is broken; reinstall
52
+ (§3).
53
+
54
+ ```bash
55
+ # 3. Does the profile still carry the w2m row?
56
+ grep -n -A4 'id: w2m' "$DSH_HOME/profiles/<profile>/cordis.patch.yml"
57
+
58
+ # 4. Did the loader skip the patch? DSH prints the reason.
59
+ dsh --profile <profile> --dump-config 2>&1 | grep -i -e w2m -e patch -e skip
60
+ ```
61
+
62
+ `patch: entry "w2m" not found` or `patch: name mismatch ... skipping` means the row was composed from
63
+ a file the loader is not mounting — see §2.
64
+
65
+ **Fix.**
66
+
67
+ 1. Confirm the row exists and names the **bare package**, not a subpath (§2).
68
+ 2. Restart DSH. A `cordis.patch.yml` change is read at startup, not on file change.
69
+ 3. If the row is missing entirely, re-add the plugin and re-apply your config from the backup §2
70
+ tells you to take.
71
+
72
+ **Not us.** If step 1 fails with `ERR_PNPM_NO_MATCHING_VERSION` or a message about the release being
73
+ **too new**, that is pnpm's `minimumReleaseAge` supply-chain gate refusing a version published
74
+ minutes ago — not a broken release. See §11.
75
+
76
+ ---
77
+
78
+ ## 2. The profile's `cordis.patch.yml` lost my configuration
79
+
80
+ **Symptom.** After installing or upgrading, the tools work but every setting is back to its default:
81
+ `rabbitUrl` points at the shared server, `operatorToken` is empty, `stateDir` and `machineName` are
82
+ blank, the allow-list is empty. Nothing errored.
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
90
+ placeholder `[]`. That is an empty YAML _sequence_. Appending a mapping block to it produces invalid
91
+ YAML, and the loader fails on the file as a whole — one bad row takes the entire profile down, not
92
+ just this plugin. Both cases are why this file is treated as hand-edited state, not as a generated
93
+ artifact.
94
+
95
+ **Decision procedure.**
96
+
97
+ ```bash
98
+ # 1. What does the file look like right now?
99
+ cat "$DSH_HOME/profiles/<profile>/cordis.patch.yml"
100
+ ```
101
+
102
+ - First characters are `[]` → the placeholder case; see the safe-edit rule below.
103
+ - The `w2m` row is absent → it was dropped; re-add it (§2 fix 3).
104
+ - The row is present but values are empty → it was reset; re-apply them.
105
+
106
+ ```bash
107
+ # 2. Is the file still valid YAML? (python3 ships with macOS and most Linux; Windows: use node)
108
+ node -e "const fs=require('fs');const t=fs.readFileSync(process.argv[1],'utf8');console.log('non-empty:', t.trim().length>0, 'starts with []:', t.trim().startsWith('[]'))" \
109
+ "$DSH_HOME/profiles/<profile>/cordis.patch.yml"
110
+
111
+ # 3. Which config did the loader actually compose? (goes to stderr)
112
+ dsh --profile <profile> --dump-config 2>&1 | grep -n -i -e 'id: w2m' -e 'patch:'
113
+ ```
114
+
115
+ `patch: entry "w2m" not found` means the loader looked for an entry named `w2m` to _replace_ and found
116
+ none, so it skipped the whole patch silently. `patch: name mismatch ... skipping` means the `name:`
117
+ in the profile's row and the mounted package disagree — usually because one of them carries the
118
+ `/tools` subpath and the other does not.
119
+
120
+ **Fix.**
121
+
122
+ 1. **Back the file up before every install or upgrade.** This is the one habit that makes the rest
123
+ recoverable:
124
+
125
+ ```bash
126
+ cp "$DSH_HOME/profiles/<profile>/cordis.patch.yml" \
127
+ "$DSH_HOME/profiles/<profile>/cordis.patch.yml.bak-$(date +%Y%m%d-%H%M%S)"
128
+ ```
129
+
130
+ 2. **If the file is the `[]` placeholder, delete the placeholder before writing your block.** Do not
131
+ append to `[]`:
132
+
133
+ ```yaml
134
+ # WRONG -- a sequence followed by a mapping is invalid YAML, and it takes the profile down
135
+ []
136
+ - id: w2m
137
+ config: { ... }
138
+
139
+ # RIGHT -- one top-level list, with your row inside it
140
+ - id: w2m
141
+ name: '@twinsearth/w2m-dsh-plugin'
142
+ config:
143
+ rabbitUrl: ''
144
+ operatorToken: ''
145
+ stateDir: /absolute/path/to/$DSH_HOME/xclient
146
+ machineName: win-desktop
147
+ p2pMode: auto
148
+ allowedCommands: []
149
+ ```
150
+
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.
153
+
154
+ 3. **The `name:` must be the bare package.** `@twinsearth/w2m-dsh-plugin` — not
155
+ `@twinsearth/w2m-dsh-plugin/tools`, not a path, not `.js`. A client package is identified by the
156
+ specifier its Loader row carries, so a subpath resolves to no `package.json`, the browser half is
157
+ never composed, and the failure is invisible on the host side.
158
+ 4. Restart DSH, then re-run `w2m_status` and check the values came back.
159
+ 5. If the file is unparseable, restore the backup and restart; a malformed patch file is a whole-file
160
+ failure.
161
+
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.
164
+
165
+ ---
166
+
167
+ ## 3. DSH will not start (or the entry failed to load) after installing this plugin
168
+
169
+ **Symptom.** The application refuses to start, or the log shows one of:
170
+
171
+ ```
172
+ w2m (@twinsearth/w2m-dsh-plugin/tools): Error: cannot get property "connection" without inject
173
+ include:@twinsearth/w2m-dsh-plugin/tools reports fiberPhase: failed
174
+ ```
175
+
176
+ and **all eight tools disappear** even though the package installed cleanly.
177
+
178
+ **Cause.** A defect in the installed version, not in your configuration. This exact failure shipped
179
+ in **v0.5.4 and v0.5.5**: the loader entry failed, so the whole host entry was skipped.
180
+
181
+ **Decision procedure.**
182
+
183
+ ```bash
184
+ # Which version is in the profile?
185
+ node -p "require('$DSH_HOME/profiles/<profile>/node_modules/@twinsearth/w2m-dsh-plugin/package.json').version"
186
+ ```
187
+
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
190
+ installing a version you have not run before.
191
+
192
+ ```bash
193
+ # Uninstall the broken copy, then install a known-good one
194
+ dsh plugin --profile <profile> remove @twinsearth/w2m-dsh-plugin
195
+ dsh plugin --profile <profile> add @twinsearth/w2m-dsh-plugin@0.5.3
196
+ ```
197
+
198
+ The plugin being uninstalled must not leave a dangling row: if the profile still lists the package
199
+ after removal, remove the `w2m` row from `cordis.patch.yml` by hand (§2) before restarting.
200
+
201
+ **Not us.** If the failure is in a _different_ plugin's entry and this one is merely in the same
202
+ profile, the loader reports the package name in the same line — read it before assuming it is ours.
203
+
204
+ ---
205
+
206
+ ## 4. A machine is listed but never answers
207
+
208
+ **Symptom.** `w2m_devices` lists the machine, `w2m_run` dispatches to it, and the task sits in
209
+ `claimed` (or the wait times out). The relay says the device is `online`.
210
+
211
+ **Cause, in the order they actually happen.** Three different failures look identical here:
212
+
213
+ 1. **The agent is not running.** The relay lists a device by its last heartbeat; a stopped agent is
214
+ listed while its lease has not yet expired. A machine shown online whose agent is stopped is the
215
+ normal case, not a bug.
216
+ 2. **The agent is running but cannot reach the relay** (carrier-grade NAT, a corporate proxy, DNS).
217
+ Then it reconnects forever and the machine looks listed-but-deaf.
218
+ 3. **The agent is running and reachable, but the task's `cwd` does not exist** on that machine, or
219
+ the command is refused — both are then reported as a `refused` result, not as silence. If you are
220
+ getting silence, it is 1 or 2.
221
+
222
+ **Decision procedure.**
223
+
224
+ ```bash
225
+ # 1. On the machine that is not answering: is the agent process alive?
226
+ # Windows (the scheduled task deploy/windows/install-service.ps1 creates):
227
+ Get-ScheduledTask -TaskName 'W2M Localside agent' | Get-ScheduledTaskInfo
228
+ Get-Content "$env:USERPROFILE\.dsh\w2m\agent.log" -Tail 40
229
+
230
+ # macOS / Linux, started by hand:
231
+ ps aux | grep -v grep | grep w2m-localside
232
+
233
+ # 2. What does the agent's own state say? (written by the agent; no network needed)
234
+ cat "$DSH_HOME/xclient/localside/agent-state.json"
235
+ ```
236
+
237
+ Read `machine_id`, the last heartbeat and the last error in that file. `agent-state.json` is the
238
+ answer to "was the agent ever up", independent of what the relay thinks.
239
+
240
+ ```bash
241
+ # 3. From the dispatching side: what does the relay believe, and for how long?
242
+ w2m_devices # compare `last_seen` / `state` across machines
243
+ w2m_status # relay probe, configured rabbit source, per-machine path
244
+ ```
245
+
246
+ A device whose `state` is `expired` was reaped after two missed 10-second heartbeats plus the lease
247
+ grace (≈50 s). A device listed `online` but whose `last_seen` is minutes old is case 1 or 2.
248
+
249
+ **Fix.**
250
+
251
+ 1. **Start the agent** (case 1). On Windows, re-run the supervisor installer from `deploy/windows/`;
252
+ on macOS/Linux start `w2m-localside` the way you normally do and check its first lines for the
253
+ relay URL it resolved.
254
+ 2. **If the agent cannot reach the relay** (case 2), read §13 — it is almost always the network, not
255
+ the relay, and the agent's log names the error.
256
+ 3. **If the machine is behind a NAT you do not control** and you need the fast path, read §6 and §13.
257
+
258
+ **Not us.** A machine that is asleep, throttled, or on a VPN that resets connections produces exactly
259
+ this symptom; the agent cannot report from a suspended process. Check `agent.log` timestamps before
260
+ suspecting the relay.
261
+
262
+ ---
263
+
264
+ ## 5. "Online" but no agent: how to tell
265
+
266
+ **Symptom.** You are not sure whether the agent is running, and `w2m_devices` is not enough to
267
+ decide.
268
+
269
+ **Decision procedure.** The three sources disagree in a specific order, and the disagreement _is_ the
270
+ diagnosis:
271
+
272
+ | Source | What it proves | How to read it |
273
+ | --------------------------------- | --------------------------------------------- | ------------------------------------- |
274
+ | Relay device list (`w2m_devices`) | the machine heartbeat within the lease window | `state: online`, `last_seen_at_ms` |
275
+ | Agent state file (§16) | the agent wrote a snapshot on this machine | `agent-state.json` mtime and contents |
276
+ | The machine itself | a process is listening/looping | `agent.log` last line, process list |
277
+
278
+ If the relay says `online` but the state file's modification time is older than the last heartbeat
279
+ interval, the agent has stopped since the relay last heard from it — expect the lease to expire in
280
+ under a minute. If the state file is _fresh_ but the relay lists the device as `expired`, the agent is
281
+ alive and cannot reach the relay: go to §13.
282
+
283
+ **Fix.** None needed beyond §4; this section exists so you stop guessing which of the two is lying.
284
+ They are not lying, they measure different things.
285
+
286
+ ---
287
+
288
+ ## 6. The direct path is never used (`transport: "relay"`)
289
+
290
+ **Symptom.** Every result reports `transport: "relay"` (or `p2p.offer_path`/`p2p.result_path` say
291
+ `relay`), and the punch never lands. Results still arrive, so nothing looks broken — it is just slow.
292
+
293
+ **Cause.** The fallback is deliberate: a failed punch costs latency, not the task. The reason is
294
+ attached to the result rather than summarised as "no direct path". Common reasons:
295
+
296
+ - `P2P_PUNCH_TIMEOUT` — the candidates were wrong or the network blocks UDP;
297
+ - both peers behind carrier-grade NAT (CGNAT) with address-dependent mapping;
298
+ - a firewall that allows the port but not the **program** (see below);
299
+ - one side pinned `--p2p-port` and the other cannot send to it.
300
+
301
+ **Decision procedure.**
302
+
303
+ ```bash
304
+ # 1. Ask what path each machine reports, and the measured NAT mapping
305
+ w2m_status
306
+ w2m_devices
307
+
308
+ # 2. Force the question: run with the direct path disabled, then with it required.
309
+ # (relay-only proves the control plane is fine; direct-only makes the punch failure loud)
310
+ # --p2p-mode is the agent's flag; W2M_P2P_MODE is the environment equivalent.
311
+ ```
312
+
313
+ - `p2pMode: relay` → no UDP socket is bound at all. Use it to take the punch out of the picture.
314
+ - `p2pMode: direct` → a failed punch is **not** hidden: the executor refuses an offer that did not
315
+ arrive over the direct path (`refusal_reason: P2P_UNAVAILABLE`). Use it to make the failure loud.
316
+
317
+ If direct-only refuses while relay-only succeeds, the control plane is healthy and the problem is
318
+ UDP path traversal — §13 and the firewall note below.
319
+
320
+ **Fix.**
321
+
322
+ 1. **Pin the punch port on the machine that has an inbound firewall.** An ephemeral port cannot be
323
+ named in a rule that survives a restart:
324
+
325
+ ```bash
326
+ node bin/w2m-localside.mjs --p2p-port 41234 ... # or W2M_P2P_PORT=41234
327
+ ```
328
+
329
+ 2. **Write the firewall rule against the program, not only the port.** This is the failure that
330
+ wastes the most time: a rule that permits UDP 41234 but not the Node binary that owns the socket
331
+ still drops the traffic, and from the other side it is indistinguishable from nobody listening.
332
+
333
+ ```powershell
334
+ # Windows: allow the program, then confirm the rule is enabled and actually matching
335
+ New-NetFirewallRule -DisplayName 'w2m p2p (node)' -Direction Inbound -Protocol UDP `
336
+ -Program 'C:\path\to\node.exe' -Action Allow
337
+ Get-NetFirewallRule -DisplayName 'w2m p2p*' | Select-Object DisplayName,Enabled,Direction,Action
338
+ ```
339
+
340
+ ```bash
341
+ # Linux (ufw): allow the port for the program's user, and check the rule is loaded
342
+ sudo ufw allow 41234/udp
343
+ sudo ufw status verbose
344
+ ```
345
+
346
+ ```bash
347
+ # macOS: the application firewall prompts per binary; confirm the entry exists
348
+ sudo /usr/libexec/ApplicationFirewall/socketfilterfw --listapps | grep -i node
349
+ ```
350
+
351
+ 3. Confirm from **outside** the machine that the port is really reachable — a rule you wrote is not
352
+ a port that answers. `deploy/networking/LESSONS.md` records what this cost previously.
353
+
354
+ **Not us.** A symmetric NAT or a CGNAT you do not control cannot be fixed by configuration. The
355
+ relay is the permanent fallback precisely for that case; a persistently relay-only fleet is a
356
+ network property, not a defect.
357
+
358
+ ---
359
+
360
+ ## 7. A command is refused by the allow-list
361
+
362
+ **Symptom.** The result comes back `refused` with:
363
+
364
+ ```
365
+ COMMAND_NOT_ALLOWED
366
+ `<your command>` is not in this plugin's allowedCommands (<the list as configured>)
367
+ ```
368
+
369
+ and the agent log has `refused <task_id>: COMMAND_NOT_ALLOWED`.
370
+
371
+ **Cause.** The allow-list is a **default-deny prefix matcher over the whole `argv`**, token by token —
372
+ not over the executable alone. Two consequences surprise people:
373
+
374
+ - `node -e console.log` does **not** allow `node -e console.log(1)`: the tokens after the prefix must
375
+ match too, and here the third token differs (`console.log` vs `console.log(1)`).
376
+ - A string entry is split on whitespace. A path containing a space therefore cannot be expressed as a
377
+ string entry — write it as a **JSON array element** instead.
378
+
379
+ **Decision procedure.**
380
+
381
+ ```bash
382
+ # 1. What list is in force on that machine? The refusal message prints it verbatim
383
+ # ("not in this plugin's allowedCommands (node --test, git status --porcelain)").
384
+ # The plugin-side list comes from the profile config:
385
+ grep -n -A6 'allowedCommands' "$DSH_HOME/profiles/<profile>/cordis.patch.yml"
386
+ # The agent-side list comes from --allowed-commands or W2M_* on that machine.
387
+ ```
388
+
389
+ ```bash
390
+ # 2. Reproduce the matcher's decision locally: it is a pure function, no agent and no relay.
391
+ # From a checkout of this repo:
392
+ node --input-type=module -e "
393
+ import { parseAllowedCommands, matchAllowedCommand } from './src/agent/allowed-commands.mjs';
394
+ const prefixes = parseAllowedCommands(['node --test','git status --porcelain']);
395
+ for (const argv of [['node','--test','x.test.mjs'],['node','--testify','x'],['node'],['git','rev-parse','HEAD']]) {
396
+ const r = matchAllowedCommand(argv, prefixes);
397
+ console.log((r.allowed ? 'ALLOW ' : 'REFUSE') + ' ' + JSON.stringify(argv));
398
+ }
399
+ "
400
+ ```
401
+
402
+ Measured against that list, so you know what the gate does rather than what it is supposed to do:
403
+
404
+ | argv | decision | why |
405
+ | ------------------------ | -------- | ---------------------------------------------------------------------- |
406
+ | `node --test x.test.mjs` | ALLOW | `node --test` is a prefix |
407
+ | `node --test` | ALLOW | a prefix may be the whole command |
408
+ | `node --testify x` | REFUSE | token 2 is `--testify`, not `--test` — a prefix is not a glob |
409
+ | `node` | REFUSE | shorter than the prefix |
410
+ | `git rev-parse HEAD` | REFUSE | not a prefix of `git status --porcelain` |
411
+ | `/usr/bin/node --test a` | ALLOW | the executable token is compared by name, so a full path still matches |
412
+
413
+ **Fix.**
414
+
415
+ 1. Add the **shortest prefix that still constrains the command**, as an array entry when a token
416
+ contains a space:
417
+
418
+ ```bash
419
+ --allowed-commands '["node --test","git status --porcelain",["C:\\Program Files\\nodejs\\node.exe","--version"]]'
420
+ ```
421
+
422
+ 2. Restart the agent: the list is read at startup.
423
+ 3. An **empty** list means "no plugin-side gate", not "nothing runs" — but the agent-side
424
+ `--allowed-commands` still applies, and a command must pass both.
425
+
426
+ **Not us.** Windows argument quoting is the usual culprit when a command you believe is allowed is
427
+ refused: the refusal message prints the tokens the agent actually received. Compare them with what
428
+ you wrote before editing the list.
429
+
430
+ ---
431
+
432
+ ## 8. Results are reported `unverifiable`
433
+
434
+ **Symptom.** A verdict comes back `⚠️ unverifiable` instead of `consistent`/`divergent`, with a reason
435
+ such as `envelope status = unverifiable`, `step1_unverifiable`, or
436
+ `aggregation refused: Step 1 anchor violation (see unverifiable machines)`.
437
+
438
+ **Cause.** A machine that **cannot reproduce the anchor** is reported `unverifiable` rather than
439
+ compared. This is the honest outcome, and it is deliberate: comparing a checkout that is not at the
440
+ anchor would produce a confident, wrong "divergent". The usual reasons:
441
+
442
+ 1. The project on that machine has uncommitted changes, or is on a different commit than the anchor.
443
+ 2. The command did not produce the required envelope fields, so the result is missing what a
444
+ comparison needs (the agent treats a missing required field as `unverifiable`, not as a failure).
445
+ 3. The machine never returned anything (then §4/§5 apply first).
446
+
447
+ **Decision procedure.**
448
+
449
+ ```bash
450
+ # On the machine reporting unverifiable: what do the anchors say?
451
+ cd <project>
452
+ git rev-parse HEAD
453
+ git status --porcelain # any output at all means the tree is not at the anchor
454
+
455
+ # From the dispatching side: which machine, and which reason?
456
+ w2m_report # per-machine outcome plus the reasons it lists
457
+ w2m_history # recent tasks with their modes and outcomes
458
+ ```
459
+
460
+ **Fix.**
461
+
462
+ 1. Commit or stash the local changes, check out the anchor commit, and re-run — a dirty tree is by far
463
+ the most common cause.
464
+ 2. If the tree is clean and the anchors match, treat it as a missing/partial envelope and go to §15 so
465
+ the report includes the attempt count and the machine's own `agent-state.json`.
466
+ 3. Do **not** "fix" it by making the comparison lenient: an anchor that does not reproduce is exactly
467
+ the case where a comparison would mislead.
468
+
469
+ **Not us.** Line-ending conversion, a `core.autocrlf` difference, or a case-insensitive filesystem can
470
+ make an identical tree differ. `git status --porcelain` is the arbiter; if it is clean, the anchors
471
+ match.
472
+
473
+ ---
474
+
475
+ ## 9. The relay is unreachable
476
+
477
+ **Symptom.** One of:
478
+
479
+ ```
480
+ W2M_UNREACHABLE: could not reach the Rabbit at <url>: <underlying error>
481
+ W2M_RABBIT_REFUSED: ... this machine is not paired, or its device_token is stale - pair the agent again
482
+ no Rabbit URL is configured, so the fleet cannot be listed
483
+ ```
484
+
485
+ Every `w2m_*` tool that talks to the relay fails; `w2m_status` cannot probe it.
486
+
487
+ **Cause, in order of likelihood.** The relay is not running; the URL is wrong; DNS resolves but the
488
+ port is filtered; the machine is behind carrier-grade NAT (CGNAT) with no outbound route to that
489
+ host; the relay is up but the token is wrong (that is `W2M_RABBIT_REFUSED`, a _different_ answer).
490
+
491
+ **Decision procedure.**
492
+
493
+ ```bash
494
+ # 1. Which URL is this machine actually using, and where did it come from?
495
+ w2m_status # reports rabbit_source: "config" | "env" | "shared-default"
496
+
497
+ # 2. Can this machine reach it at all? (transport, before HTTP)
498
+ curl -sS -m 10 -o /dev/null -w '%{http_code} in %{time_total}s\n' <rabbitUrl>/healthz
499
+
500
+ # 3. Is it DNS, TCP, or the application?
501
+ nslookup <relay-host>
502
+ # Windows: Test-NetConnection <relay-host> -Port 8787
503
+ # macOS/Linux: nc -vz <relay-host> 8787
504
+
505
+ # 4. Is the relay itself healthy? (run on the relay host)
506
+ curl -sS http://127.0.0.1:8787/healthz
507
+ ```
508
+
509
+ Resolution order is `rabbitUrl` config → `W2M_RABBIT_URL` → the shared default, and an explicit value
510
+ always wins. `rabbit_source: "shared-default"` means you are talking to somebody else's host — that
511
+ is reported so it can never be a surprise.
512
+
513
+ **Fix.**
514
+
515
+ 1. **The relay is down:** restart it (`deploy/systemd/w2m-rabbit.service`, or the container) and check
516
+ its own state directory is intact (§16) — a relay started with `--no-persist` loses its device
517
+ table on every restart, and every machine must pair again.
518
+ 2. **The URL is wrong:** set it in the profile config (§2) or `W2M_RABBIT_URL`, then restart DSH. A
519
+ URL with a sub-path is supported.
520
+ 3. **DNS or TCP fails but the host is up:** the path is filtered. Try the same `curl` from the relay
521
+ host to confirm which side is blocking.
522
+ 4. **`W2M_RABBIT_REFUSED` (HTTP 401):** the relay answered, so nothing is wrong with the network. The
523
+ machine is not paired, or its device token is stale — pair the agent again. Do not chase the
524
+ network.
525
+
526
+ **Not us.** A relay you do not control, a corporate proxy that intercepts plain HTTP, and a CGNAT that
527
+ blocks the outbound port all produce step 2 failures with no HTTP status. `curl`'s error text names
528
+ which one; a timeout is a path problem, a refused connection is a listener problem, and a TLS/HTTP
529
+ error is neither.
530
+
531
+ ---
532
+
533
+ ## 10. Port 8787 or the pinned punch port is already in use
534
+
535
+ **Symptom.** The relay or the agent exits immediately with `EADDRINUSE`.
536
+
537
+ **Decision procedure.**
538
+
539
+ ```powershell
540
+ # Windows
541
+ Get-NetTCPConnection -LocalPort 8787 -ErrorAction SilentlyContinue | Select-Object LocalAddress,State,OwningProcess
542
+ Get-NetUDPEndpoint -LocalPort 41234 -ErrorAction SilentlyContinue | Select-Object LocalAddress,OwningProcess
543
+ ```
544
+
545
+ ```bash
546
+ # macOS / Linux
547
+ lsof -nP -iTCP:8787 -sTCP:LISTEN
548
+ lsof -nP -iUDP:41234
549
+ ss -lntup | grep -e 8787 -e 41234
550
+ ```
551
+
552
+ **Fix.** Stop the other listener, or move the pin (`--p2p-port`, `W2M_P2P_PORT`) and update the
553
+ firewall rule in §6 to the new port.
554
+
555
+ **Not us.** On developer machines the usual occupant is a leftover agent from a previous run, or a
556
+ second relay started from another checkout. Check the owning process before assuming a port conflict
557
+ with something unrelated.
558
+
559
+ ---
560
+
561
+ ## 11. The version you just published will not install (`minimumReleaseAge`)
562
+
563
+ **Symptom.** Installing a version published minutes ago fails even though it exists on npm:
564
+
565
+ ```
566
+ ERR_PNPM_NO_MATCHING_VERSION No matching version found for @twinsearth/w2m-dsh-plugin@<version>
567
+ ```
568
+
569
+ or the resolver reports the version as too new to be selected. Hours later the same command works.
570
+
571
+ **Cause.** **Not this plugin.** pnpm's `minimumReleaseAge` supply-chain gate refuses packages younger
572
+ than a configured age so that a compromised release has time to be yanked. A just-published version is
573
+ invisible to the resolver until it ages out.
574
+
575
+ **Decision procedure.**
576
+
577
+ ```bash
578
+ # 1. Does the version exist in the registry at all?
579
+ npm view @twinsearth/w2m-dsh-plugin versions --json
580
+
581
+ # 2. What is this machine's configured gate?
582
+ pnpm config get minimumReleaseAge
583
+ ```
584
+
585
+ If step 1 lists the version and step 2 is a non-zero number of minutes, that is the cause.
586
+
587
+ **Fix.** Pick one, consciously:
588
+
589
+ ```bash
590
+ # Wait for the gate to pass (a tarball URL bypasses the resolver entirely -- but read the release
591
+ # notes first, and prefer a version that is not withdrawn; see CHANGELOG.md).
592
+ 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
594
+ ```
595
+
596
+ Or raise the gate for this machine — a deliberate supply-chain trade, so make it deliberately:
597
+
598
+ ```bash
599
+ pnpm config set minimumReleaseAge 0 # per-machine, weakens the gate for every package
600
+ ```
601
+
602
+ **Not us.** This is pnpm's behaviour and it applies to every package you install, not ours. If you
603
+ bypass the gate with a tarball URL, verify it against `SHA256SUMS` from the same release.
604
+
605
+ ---
606
+
607
+ ## 12. A machine is listed and paired but not _selectable_ in the composer
608
+
609
+ **Symptom.** The dispatch-target control beside the permission control shows the machine, but it
610
+ cannot be picked.
611
+
612
+ **Cause.** The control shows the relay's own device list through the same helper `w2m_devices` uses, so
613
+ the tool and the control cannot disagree about who is online. A machine the relay does not list as
614
+ online is shown but not selectable — that is the design, not a rendering bug. "Unknown" is not
615
+ "reachable": a device record with neither an `online` boolean nor `state: online` is treated as not
616
+ online.
617
+
618
+ **Decision procedure.** `w2m_devices` and compare `state`/`online` with what the control shows. If the
619
+ tool lists it as `expired`, §4/§5 apply; the control is telling you the truth.
620
+
621
+ **Fix.** Get the agent heartbeating again (§4), then reload the page: the roster is read on load.
622
+
623
+ **Not us.** A stale browser tab keeps the roster it loaded. If the tool and the control disagree after
624
+ a reload, that is ours — report it (§15).
625
+
626
+ ---
627
+
628
+ ## 13. Behind carrier-grade NAT: nothing works from that machine
629
+
630
+ **Symptom.** A machine on a mobile, hotel or ISP CGNAT link cannot reach the relay at all, or reaches
631
+ it but never forms a direct path. `curl` to the relay from that machine times out; other machines are
632
+ fine.
633
+
634
+ **Cause.** CGNAT puts many subscribers behind one address and typically blocks inbound connections;
635
+ outbound to a non-standard port may also be filtered. **We cannot fix this from inside the plugin.**
636
+ What we can do is report it instead of hiding it.
637
+
638
+ **Decision procedure.**
639
+
640
+ ```bash
641
+ # 1. Outbound reachability first, from the affected machine
642
+ curl -sS -m 10 -o /dev/null -w '%{http_code}\n' <rabbitUrl>/healthz || echo "no route to relay"
643
+
644
+ # 2. If that works, the control plane is fine and only the punch is blocked: check the reported path
645
+ w2m_status # transport per machine
646
+ w2m_devices # the measured NAT mapping
647
+ ```
648
+
649
+ **Fix.**
650
+
651
+ 1. **Put the relay somewhere both machines can reach** — a VPS, or the shared server. `deploy/` has
652
+ shapes for a shared server, nginx, Caddy, Docker, WireGuard and systemd; `deploy/networking/LESSONS.md`
653
+ records what was measured on real networks.
654
+ 2. **If the relay must be reached over an outbound-only path**, a tunnel or WireGuard carries it;
655
+ `p2pMode: relay` then removes the UDP question entirely.
656
+ 3. **Accept relay-only.** The relay is the permanent fallback by design; a fleet that stays on it is
657
+ slower, not broken.
658
+
659
+ **Not us.** The CGNAT. This is the case the architecture was built around; a machine that cannot be
660
+ punched is expected, and the reported reason (`P2P_PUNCH_TIMEOUT`, `transport: "relay"`) is the honest
661
+ answer rather than a silent degradation.
662
+
663
+ ---
664
+
665
+ ## 14. After a reboot the relay "forgot" every machine
666
+
667
+ **Symptom.** Every machine must pair again after the relay restarts; `revived_devices` is `0` or
668
+ absent.
669
+
670
+ **Cause.** The relay persists its device table (`devices.json`) and its ledger (`ledger.jsonl`) under
671
+ its state directory (§16). If it was started with `--no-persist`, or the state directory is inside a
672
+ container that was recreated, or the file is unreadable, the table starts empty.
673
+
674
+ **Decision procedure.**
675
+
676
+ ```bash
677
+ # On the relay host: is the state directory mounted, and is it the same one as last time?
678
+ curl -sS http://127.0.0.1:8787/healthz | grep -o '"revived_devices":[0-9]*'
679
+ ls -l /var/lib/w2m/devices.json /var/lib/w2m/ledger.jsonl
680
+ ```
681
+
682
+ `revived_devices` greater than zero means the table was restored and existing device tokens remain
683
+ valid — no re-pairing needed. A corrupt `devices.json` is reported as
684
+ `persistence.devices_corrupt: true` and the relay starts empty rather than refusing to start; a corrupt
685
+ `ledger.jsonl` line is skipped, counted in `ledger_lines_skipped`, and detailed in
686
+ `persistence.warnings`.
687
+
688
+ **Fix.** Point the state directory at persistent storage (`systemd`: `StateDirectory=w2m`; container: a
689
+ named volume) and restart. Back it up before you need it:
690
+
691
+ ```bash
692
+ sudo tar czf w2m-state-$(date +%F).tgz -C /var/lib w2m
693
+ ```
694
+
695
+ **Not us.** A container without a volume is the usual cause: the relay behaved correctly, its state
696
+ directory was deleted underneath it.
697
+
698
+ ---
699
+
700
+ ## 15. Before you file a bug: the triage block
701
+
702
+ Run these five and paste the output. They are the questions a maintainer asks first, and the untested
703
+ one is usually the answer.
704
+
705
+ ```bash
706
+ # 1. Versions: what is installed, and is it a withdrawn version?
707
+ node -p "require('$DSH_HOME/profiles/<profile>/node_modules/@twinsearth/w2m-dsh-plugin/package.json').version"
708
+ grep -n 'DO NOT INSTALL' CHANGELOG.md
709
+
710
+ # 2. The composed config: does the w2m row exist, and what does the loader say?
711
+ grep -n -A6 'id: w2m' "$DSH_HOME/profiles/<profile>/cordis.patch.yml"
712
+ dsh --profile <profile> --dump-config 2>&1 | grep -i -e w2m -e 'patch:'
713
+
714
+ # 3. The relay as this machine sees it
715
+ w2m_status
716
+
717
+ # 4. The fleet as the relay sees it, and the last few tasks
718
+ w2m_devices
719
+ w2m_history
720
+
721
+ # 5. On the machine that misbehaved: the agent's own log tail and state file
722
+ # Windows: Get-Content "$env:USERPROFILE\.dsh\w2m\agent.log" -Tail 50
723
+ # Unix: tail -n 50 <stateDir>/../agent.log (see §16 for where yours is)
724
+ cat "$DSH_HOME/xclient/localside/agent-state.json"
725
+ ```
726
+
727
+ State plainly which of these you ran and which you did not. "I could not reproduce it" is a useful
728
+ report; "it does not work" is not.
729
+
730
+ **Report security issues privately — never in a public issue.** See
731
+ [SECURITY.md](SECURITY.md).
732
+
733
+ ---
734
+
735
+ ## 16. Where every piece of state lives
736
+
737
+ Relative to `$DSH_HOME`, which is `%DSH_HOME%` or `~/.dsh` on Windows and `$DSH_HOME` or `~/.dsh`
738
+ elsewhere. `<kind>` is the sub-directory a program is started with: `localside` for an agent,
739
+ `rabbit` for a relay.
740
+
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.**
760
+
761
+ 1. **Nothing here is inside your project.** The spool, the state and the key live under the state
762
+ directory on purpose: a read-only project directory must never be written to, and the spool is not
763
+ part of the project.
764
+ 2. **Two "machines" sharing one `device.json` are one machine.** Duplicating a state directory to run
765
+ a second agent on one host makes both claim the same `machine_id`. Point `--state` (or `DSH_HOME`)
766
+ somewhere else instead.
767
+
768
+ ---
769
+
770
+ ## 17. Reference: the reason codes you will actually see
771
+
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 |
785
+
786
+ Nine aggregate verdicts exist, of which `unverifiable` (§8) is the one that needs explaining rather
787
+ than fixing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "description": "DSH: Windows2MacOS — drive the same project on online Windows and macOS machines from one DeepSeek account, across networks and regions. P2P hole punching is the default path (relay + shared server as rendezvous, ledger and fallback), with a bundled STUN responder, Tailscale/public-VPS/tunnel shapes, Rabbit relay + per-machine Localside + eight DSH tools (replicate / split), nine aggregate verdicts and signed requests.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -32,7 +32,10 @@
32
32
  "PROTOCOL-v0.1.2.md",
33
33
  "PROTOCOL-v0.3.0.md",
34
34
  "PROTOCOL-v0.3.9.md",
35
- "PROTOCOL-v0.4.0.md"
35
+ "PROTOCOL-v0.4.0.md",
36
+ "TROUBLESHOOTING.md",
37
+ "SECURITY.md",
38
+ "CONTRIBUTING.md"
36
39
  ],
37
40
  "dsh": {
38
41
  "plugin": true,
@@ -96,19 +99,19 @@
96
99
  }
97
100
  },
98
101
  "scripts": {
99
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/stun-server.test.mjs",
102
+ "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/claims.test.mjstest/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs",
100
103
  "test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs",
101
104
  "test:relay": "node --test test/relay.test.mjs",
102
105
  "test:agent": "node --test test/agent.test.mjs",
103
106
  "test:tools": "node --test test/tools.test.mjs",
104
107
  "test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
105
- "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs",
108
+ "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/claims.test.mjstest/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs",
106
109
  "pack": "node scripts/pack.mjs",
107
110
  "lint": "eslint .",
108
111
  "lint:fix": "eslint . --fix",
109
112
  "format": "prettier --write .",
110
113
  "format:check": "prettier --check .",
111
- "verify": "node scripts/check-entrypoints.mjs && node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/p2p-node.test.mjs test/p2p-agent.test.mjs",
114
+ "verify": "node scripts/check-entrypoints.mjs && node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/p2p-node.test.mjs test/p2p-agent.test.mjs test/client-bundle.test.mjs test/loader-entry.test.mjs",
112
115
  "test:p2p": "node --test --test-force-exit test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/stun-server.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs"
113
116
  },
114
117
  "keywords": [
@@ -134,7 +134,7 @@ const MAX_STAGES = 16;
134
134
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
135
135
  * overrides it at runtime for tests and packagers.
136
136
  */
137
- const PLUGIN_VERSION = '0.5.6';
137
+ const PLUGIN_VERSION = '0.5.7';
138
138
 
139
139
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
140
140
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;