@twinsearth/w2m-dsh-plugin 0.10.3 → 0.10.5

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
@@ -4,6 +4,120 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.10.5] — 2026-10-15
8
+
9
+ **FLEET-RULES.md enters the tree, with this host's reachability measured and written down.**
10
+
11
+ ### What was actually wrong
12
+
13
+ The 1.0.0 objective starts with *"local Windows and MacMini jointly"*, and on inspection the Windows host
14
+ had never been a git participant at all: its working copy was not a repository, and every release so far had
15
+ been published through the GitHub REST API rather than through git. That is a real gap and it was recorded
16
+ as one rather than glossed over.
17
+
18
+ ### Why it was that way, measured rather than assumed
19
+
20
+ `github.com:443` is unreachable from this host while every other GitHub endpoint answers. Three consecutive
21
+ `git fetch` attempts failed after ~21 s each, and from the same process minutes apart:
22
+
23
+ | Endpoint | Result |
24
+ |---|---|
25
+ | `github.com:443` | **TIMEOUT** |
26
+ | `api.github.com:443` | OPEN 73 ms |
27
+ | `codeload.github.com:443` | OPEN 77 ms |
28
+ | `ssh.github.com:443` | OPEN 95 ms |
29
+ | `objects.githubusercontent.com:443` | OPEN 228 ms |
30
+
31
+ The blocking is **per hostname, not per port** — `ssh.github.com` answers on the same 443 that `github.com`
32
+ does not. It is also **intermittent at the TCP layer**: a raw `net.connect` to `20.205.243.166:443` timed out
33
+ while a `tls.connect` to the same IP with `servername: 'github.com'` completed `authorized: true` on TLS 1.3.
34
+ An intermittent block is worse than a solid one, because a single success is not evidence the path works.
35
+
36
+ The first two attempts at this treated the failure as a configuration problem and tried proxy settings and
37
+ retries. Both were wrong, and both cost a round. The measurements are cheaper to read than to repeat, which
38
+ is why they are now rule 8 of `FLEET-RULES.md` rather than a note in a session log.
39
+
40
+ ### What now works, and what the evidence is
41
+
42
+ The repository is materialised from a `codeload` tarball (the one path that is reachable), giving a real git
43
+ work tree containing exactly `main` — verified by hashing both trees: **227 files, 0 differences,
44
+ `IDENTICAL`**. `scripts/fleet-sync.mjs` then ran against a real repository for the first time, having only
45
+ ever been tested against a temporary one:
46
+
47
+ | Step | Result |
48
+ |---|---|
49
+ | initial | `state=up-to-date action=none` |
50
+ | local commit | `state=ahead action=push ahead=1` |
51
+ | `--write` | `performed: true - pushed 1 commit(s)`; mirror and HEAD agree |
52
+ | peer commit arrives | `state=behind action=fast-forward behind=1` |
53
+ | `--write` | `performed: true - now at d42d5c0`; 228 files, peer file present |
54
+ | final | `state=up-to-date action=none` |
55
+
56
+ The same run also demonstrated the refusal path for free: when a fast-forward was rejected by git, the
57
+ result was `performed: false`, the tree was **left untouched**, and the state stayed `behind` — an automatic
58
+ sync that fails must not half-apply.
59
+
60
+ ### Stated plainly rather than implied
61
+
62
+ This host can *pull* the truth and can *run* the sync machinery, but it **cannot push over git**. A round
63
+ that needs a commit published must use the API, and a round that exercises `fleet-sync` must do it against a
64
+ local bare remote. That is a real test of the state machine and **not** a test of the network, and the
65
+ difference is written into the rule so it is not mistaken for one.
66
+
67
+ ## [0.10.4] — 2026-10-15
68
+
69
+ **A result channel over exit codes, for machines whose text cannot be read back.**
70
+
71
+ ### Why this exists, stated without euphemism
72
+
73
+ The relay carries a per-machine `exit_code` faithfully and carries **no stdout at all**. That is a
74
+ deliberate property of the ledger and the right design for a system whose job is to say whether machines
75
+ agree — but the consequence is that an orchestrator can learn *that* a task failed and never *why*, and on
76
+ this fleet that cost several rounds: a health check returning a real verdict was indistinguishable from one
77
+ that crashed, because both produce a number and nothing else.
78
+
79
+ Every channel that would carry text is unavailable here. The relay host's SSH and its sink are refused, an
80
+ inbound receiver on the orchestrator never received a single connection, and a machine's `allowedCommands`
81
+ may permit only a narrow set of commands. What has worked on every machine every time is the exit status. So
82
+ this encodes bytes into exit codes. It is slow and it is ugly, and both are the point: **a diagnostic that
83
+ arrives slowly is worth more than one that never arrives.**
84
+
85
+ ### The two design choices that are not arbitrary
86
+
87
+ - **`126` and `127` never carry data.** Shells write them for "not executable" and "not found", so a real
88
+ 127 and a data 127 would be indistinguishable and the reader would investigate the wrong failure. The
89
+ alphabet is therefore a mapping over `1..125` and `128..253`, not a constant offset — the first version
90
+ used `value + 1` across the whole range and its own test caught the collision.
91
+ - **Truncation is not completion.** `254` ends the message and `255` says the sender failed. A channel that
92
+ stopped mid-message must not be read as a whole message, because that turns "the machine stopped talking"
93
+ into "the machine said this" with no way for the reader to tell.
94
+
95
+ ### Three failed fixes, and what actually located the bug
96
+
97
+ `encodeBytes` emits remainders as it divides, so its output is **least significant first**; the decoder's
98
+ Horner accumulator consumes digits **most significant first**. Feeding one into the other inverts the
99
+ message, and it presents as byte-order corruption rather than reversal — `"first"` came back as
100
+ `"Y~\u0010\u0018\u00fc"`.
101
+
102
+ Two attempts to fix it failed because they were fixing the wrong half: a stray `.reverse()` was added to the
103
+ *output* buffer, then removed, then removed again, while the real fault was that the *input* needed
104
+ reversing. The first attempt also had a genuine second bug — the encoder mutated the array it was iterating
105
+ — which masked the first. What settled it was computing the digits with `BigInt` and comparing: `encodeBytes`
106
+ matched the reference exactly, which located the fault on the decode side and in the input order rather than
107
+ the output.
108
+
109
+ ### What is documented rather than papered over
110
+
111
+ Leading NUL bytes are not recoverable: they do not change the number, so `[0, 0, 1]` and `[1]` are the same
112
+ value and the same digit list. Recovering them needs a length declared on the wire, and this channel carries
113
+ text. That is asserted in the suite, because a future reader will otherwise treat it as a bug and add a
114
+ length prefix that changes the wire format for every existing message.
115
+
116
+ A 29-byte verdict costs 30 dispatches. `costOf()` reports this so a caller can see that a log file is not
117
+ worth sending this way — and fix the real channel instead.
118
+
119
+ **17 tests, 0 failures.**
120
+
7
121
  ## [0.10.3] — 2026-10-15
8
122
 
9
123
  **Fix: the macOS health check walked a user's home directory, and timed out instead of reporting.**
package/FLEET-RULES.md ADDED
@@ -0,0 +1,146 @@
1
+ # Fleet working rules
2
+
3
+ Standing rules for every machine and every session in this fleet. These are **rules**, not notes: a
4
+ session that finds itself working around one of these is either misreading it or should be proposing an
5
+ amendment, not quietly making an exception.
6
+
7
+ Established 2026-10-14/15. Amended by appending with a date and a reason — never by deleting, because
8
+ *"we believed X until we measured Y"* is itself knowledge, and it is what stops the same wrong conclusion
9
+ being reached twice.
10
+
11
+ ---
12
+
13
+ ## 1. Broadcast, sync, merge — automatically, in the same fleet
14
+
15
+ **Added 2026-10-15 at the operator's instruction.**
16
+
17
+ Any machine that is in the same fleet **broadcasts its state, syncs from the fleet, and merges without
18
+ being asked**. This is not an optimisation; it is the rule that removes the class of work that produced
19
+ 2026-10-14's largest time sink — a session holding one copy of the truth on one machine while another
20
+ machine held a different copy, and a human having to notice.
21
+
22
+ ### What the rule means concretely
23
+
24
+ | Verb | Obligation |
25
+ |---|---|
26
+ | **Broadcast** | Before finishing a unit of work, publish what changed to the shared record — commit to the remote, not to a local branch nobody else can see. |
27
+ | **Sync** | On starting a unit of work, fetch. A machine that has not fetched does not know what it is working on. |
28
+ | **Merge** | Reconcile rather than choose. Two machines that both changed something must end with one tree, not with one machine's version silently winning. |
29
+
30
+ ### The shared record is the remote
31
+
32
+ One repository, one remote, `main` as the integration point. Local-only work is not broadcast, and a
33
+ branch that is not pushed is a branch no other machine can sync from — which is why a work-in-progress
34
+ reaches another machine as a **pushed branch**, never as a file copied by hand.
35
+
36
+ ### What must never be merged
37
+
38
+ - **A tree that has not passed its own tests.** Merge is not the place to discover a failing suite. The
39
+ rule is: run the suite where the change will land, and merge after it is green.
40
+ - **Generated or machine-local state.** `dist/`, `node_modules/`, `memory.jsonl`, state directories and
41
+ credentials are per-machine facts; broadcasting them would spread one machine's local state over
42
+ another's. The repository already excludes these at the release boundary, and the same list applies
43
+ here.
44
+ - **Anything a machine could not itself verify.** An unverified claim is broadcast as *unverified*, never
45
+ as a fact — see rule 4.
46
+
47
+ ### Why the rule is safe to apply automatically
48
+
49
+ Git's fast-forward is the common case: one machine ahead, everyone else behind. A genuine divergence is
50
+ the case that needs judgement, so the automation is: **fast-forward automatically, rebase automatically
51
+ when the local work is not yet published, and stop and report when both sides have published** — because
52
+ that is the only case where merging could discard someone's verified work.
53
+
54
+ ---
55
+
56
+ ## 2. Do not claim a mechanism works from a scheduling record
57
+
58
+ Added 2026-10-14, after two mistakes in one session.
59
+
60
+ A dispatch being accepted, a lease being granted, and a verdict arriving are three different
61
+ observations. The reliable evidence is **a side effect on the target machine**, or **an exit code that
62
+ nothing else would produce**. "The relay said `exit_code: 0`" is not evidence that a command ran until
63
+ something else corroborates it.
64
+
65
+ ## 3. Run the suite where the change will land
66
+
67
+ Added 2026-10-14. A green suite on the development host and a green suite on the target machine are
68
+ different claims, and only the second one supports a merge. `scripts/` and `test/` are absent from the
69
+ published tarball by design, so "it passes at home" cannot be checked from an installed copy.
70
+
71
+ ## 4. Mark what could not be verified
72
+
73
+ Added 2026-10-14. If a check cannot run here — no network, no installation, no credential — it is reported
74
+ as `UNVERIFIED` with the reason, never as a pass. An unverifiable check is not a passing check.
75
+
76
+ ## 5. Facts carry their date and how they were measured
77
+
78
+ Added 2026-10-14, from `fleet-memory.md`.
79
+
80
+ A fact without provenance is a rumour that outlives the person who could have checked it. Correct in
81
+ place with a note rather than deleting: the correction is part of the record.
82
+
83
+ ## 6. Machine roles are not interchangeable
84
+
85
+ Added 2026-10-14.
86
+
87
+ | Machine | Role | Constraint |
88
+ |---|---|---|
89
+ | the Linux relay host | relay, STUN, archive, and the development shell over SSH | no personal data |
90
+ | MacMini | real-machine work: deploy, run, test, verify, report, and now development | no personal data |
91
+ | `ToFuture` (the DSH host) | orchestration | **hard-protected**: nothing outside the session workspace or non-system `C:` folders without the operator typing the password |
92
+ | other Windows hosts | as MacMini | — |
93
+
94
+ ## 7. A note about the rule itself
95
+
96
+ Rule 1 says merge automatically. That is a real transfer of authority from the human to the machines, and
97
+ it is only defensible while the checks in rules 2, 3 and 4 hold: **automatic merging is safe exactly to
98
+ the extent that "green" is trustworthy.** If a suite is ever found to pass without testing what it names
99
+ — which happened twice on 2026-10-14, once in a cycle-detection test that asserted nothing — then this
100
+ rule is the thing that spreads the damage. So a test that does not test is not a small defect here; it is
101
+ a hole in the merge gate.
102
+
103
+ ---
104
+
105
+ ## 8. This host's reachability, measured — do not re-derive it
106
+
107
+ **Added 2026-10-15 after round 3 of the 1.0.0 goal, because the same wrong assumption cost three rounds.**
108
+
109
+ The Windows host that writes this file cannot reach `github.com:443`. Measured, not inferred: three
110
+ consecutive `git fetch` attempts each failed after ~21 s with *"Failed to connect ... Could not connect to
111
+ server"*, while the following, probed from the same process minutes apart, all succeeded:
112
+
113
+ | Endpoint | Result |
114
+ |---|---|
115
+ | `github.com:443` | **TIMEOUT** |
116
+ | `api.github.com:443` | OPEN 73 ms |
117
+ | `codeload.github.com:443` | OPEN 77 ms |
118
+ | `ssh.github.com:443` | OPEN 95 ms |
119
+ | `objects.githubusercontent.com:443` | OPEN 228 ms |
120
+
121
+ The blocking is **per hostname, not per port** — `ssh.github.com` answers on the same 443 that
122
+ `github.com` does not. It is also **intermittent at the TCP layer**: a raw `net.connect` to
123
+ `20.205.243.166:443` timed out while a `tls.connect` to the same IP with `servername: 'github.com'`
124
+ completed with `authorized: true` and TLS 1.3. An intermittent block is worse than a solid one, because a
125
+ single success is not evidence that the path works.
126
+
127
+ ### What this means in practice, so the next session does not repeat it
128
+
129
+ * **`git clone` / `git fetch` / `git push` over HTTPS do not work on this host.** Do not build a plan
130
+ around them and do not retry them hoping for a different result.
131
+ * **The working path to materialise the repository is a tarball from `codeload`**:
132
+ `https://codeload.github.com/TwinsEarth/dsh-windows2macos/tar.gz/refs/heads/main`, unpacked with
133
+ `tar --strip-components=1` into a directory that is then `git init`-ed. That yields a real work tree
134
+ containing exactly `main`, which is what `fleet-sync` needs.
135
+ * **Publishing from this host goes through the REST API**, not through git. Every release so far was
136
+ pushed that way.
137
+ * **The consequence to state plainly:** this host can *pull* the truth and can *run* the sync machinery, but
138
+ it cannot *push* over git. A round that needs a commit published must use the API, and a round that needs
139
+ `fleet-sync` exercised must do it against a local bare remote — which is a real test of the state
140
+ machine, and is **not** a test of the network.
141
+
142
+ ### Why this is a rule and not a note
143
+
144
+ The first two attempts at "make Windows a git participant" assumed the failure was a configuration problem
145
+ and tried proxy settings and retries. Both were wrong, and both cost a round. **The measurements above are
146
+ cheaper to read than to repeat.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.10.3",
3
+ "version": "0.10.5",
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",
@@ -29,6 +29,7 @@
29
29
  "CHANGELOG.md",
30
30
  "HANDOFF.md",
31
31
  "ROADMAP.md",
32
+ "FLEET-RULES.md",
32
33
  "LICENSE",
33
34
  "PROTOCOL.md",
34
35
  "PROTOCOL-v0.1.2.md",
@@ -112,13 +113,13 @@
112
113
  }
113
114
  },
114
115
  "scripts": {
115
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.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/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
116
+ "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.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/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
116
117
  "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",
117
118
  "test:relay": "node --test --test-force-exit test/relay.test.mjs",
118
119
  "test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
119
120
  "test:tools": "node --test --test-force-exit test/tools.test.mjs",
120
121
  "test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
121
- "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.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 test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
122
+ "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.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 test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
122
123
  "pack": "node scripts/pack.mjs",
123
124
  "verify:join": "node scripts/verify-join.mjs",
124
125
  "lint": "eslint .",
@@ -133,7 +134,8 @@
133
134
  "test:approval-index": "node --test --test-force-exit test/approval-index.test.mjs",
134
135
  "test:fleet-sync": "node --test --test-force-exit test/fleet-sync.test.mjs",
135
136
  "test:health": "node --test --test-force-exit test/health.test.mjs",
136
- "test:storage": "node --test --test-force-exit test/storage.test.mjs"
137
+ "test:storage": "node --test --test-force-exit test/storage.test.mjs",
138
+ "test:exit-channel": "node --test --test-force-exit test/exit-channel.test.mjs"
137
139
  },
138
140
  "keywords": [
139
141
  "dsh-bundle",
@@ -0,0 +1,242 @@
1
+ /**
2
+ * A result channel over exit codes, for machines whose text cannot be read back.
3
+ *
4
+ * ## The problem this solves, stated honestly
5
+ *
6
+ * The relay carries a per-machine `exit_code` faithfully and carries **no stdout at all** — that is a
7
+ * deliberate property of the ledger, not a bug, and it is the right design for a system whose job is to say
8
+ * whether machines agree. The consequence is that an orchestrator can learn *that* a task failed and never
9
+ * *why*, and on this fleet that cost several rounds: a health check that returned a real verdict was
10
+ * indistinguishable from one that crashed, because both produce a number and nothing else.
11
+ *
12
+ * The channels that would carry text are all unavailable here: the relay host's SSH and its sink are
13
+ * refused, an inbound receiver on the orchestrator never received a single connection, and a machine's
14
+ * `allowedCommands` may permit only a narrow set of commands. What is left is the one thing that has worked
15
+ * on every machine every time: **the exit status**.
16
+ *
17
+ * So this encodes bytes into exit codes. It is slow and it is ugly, and both of those are the point — it is
18
+ * the channel of last resort, and a diagnostic that arrives slowly is worth more than a diagnostic that
19
+ * never arrives.
20
+ *
21
+ * ## Why base 251 and not base 256
22
+ *
23
+ * Exit codes are unsigned bytes *in principle* and considerably less than that in practice. `0` is taken:
24
+ * it is what a successful command returns, so it must mean "the message is finished" rather than "the byte
25
+ * is zero". Codes above 125 are unreliable — `126` and `127` are written by shells for "not executable"
26
+ * and "not found", and a value that collides with a shell's own error is a diagnostic that reads as a
27
+ * different failure. So the payload alphabet is **1..251**, which is 251 symbols, and a byte is carried as
28
+ * `value + 1`.
29
+ *
30
+ * That leaves `252..255` free for framing, and they are used as unambiguous terminators, because "the
31
+ * channel stopped" and "the message ended" must not look alike. A truncated report read as a complete one
32
+ * is the failure mode that makes a diagnostic worse than none.
33
+ *
34
+ * ## What is deliberately absent
35
+ *
36
+ * No compression, no encryption, no resumption. Each would be a way to make the transport cleverer than the
37
+ * thing it transports, and the whole reason this exists is that the clever transports were the ones that
38
+ * did not work. A caller with a large payload should fix the real channel instead of using this one.
39
+ */
40
+
41
+ /**
42
+ * The payload alphabet: 251 symbols, carried by the 253 exit codes that are safe to write.
43
+ *
44
+ * Codes `1..125` and `128..253` carry data; `126`, `127`, `254` and `255` do not. The two omissions are
45
+ * deliberate and are the whole reason this is a mapping rather than a constant offset:
46
+ *
47
+ * * **`126` and `127` are written by shells** for "not executable" and "not found". If they could carry
48
+ * data, a real 127 from a missing interpreter and a data 127 would be indistinguishable and the reader
49
+ * would investigate the wrong failure. The first version of this module used `value + 1` across the
50
+ * whole range and its own test caught the collision.
51
+ * * **`254` and `255` carry the framing** that makes "the message ended" distinguishable from "the channel
52
+ * died", and `0` is reserved because a successful command returns it.
53
+ */
54
+ export const SYMBOL_COUNT = 251;
55
+
56
+ /** Exit code for symbol `value`: skip the two shell-owned codes. */
57
+ export function codeForSymbol(value) {
58
+ if (!Number.isInteger(value) || value < 0 || value >= SYMBOL_COUNT) {
59
+ throw new RangeError(`symbol ${value} is outside 0..${SYMBOL_COUNT - 1}`);
60
+ }
61
+ const code = value + 1;
62
+ return code < 126 ? code : code + 2;
63
+ }
64
+
65
+ /** Symbol carried by an exit code, or `null` when the code is not a payload digit. */
66
+ export function symbolForCode(code) {
67
+ if (!Number.isInteger(code)) return null;
68
+ if (code >= 1 && code <= 125) return code - 1;
69
+ if (code >= 128 && code <= 253) return code - 3;
70
+ return null;
71
+ }
72
+
73
+ /** Frame codes, chosen from the exit codes that carry no payload. */
74
+ export const FRAME = Object.freeze({
75
+ /** The message ended cleanly. */
76
+ END: 254,
77
+ /** The sender failed and is saying so rather than ending. */
78
+ ERROR: 255,
79
+ /** Written by a shell for "not executable"; never emitted as data. */
80
+ SHELL_NOT_EXECUTABLE: 126,
81
+ /** Written by a shell for "not found"; never emitted as data. */
82
+ SHELL_NOT_FOUND: 127,
83
+ });
84
+
85
+ /** Exit code used by a sender that finished the whole message. */
86
+ export const DONE = FRAME.END;
87
+
88
+ /**
89
+ * Split bytes into base-251 digits, least significant first.
90
+ *
91
+ * A big integer would be the obvious representation and is the wrong one: `BigInt` arithmetic over a
92
+ * multi-kilobyte payload is slower than the digit loop and produces a value whose length is not obviously
93
+ * related to the input's. Here each output digit carries a known amount of text, so a truncated message is
94
+ * detectable by counting.
95
+ *
96
+ * The loop below is long division of `input` (read as a base-256 number) by 251, repeated until the
97
+ * quotient is zero, collecting remainders least-significant first. Each pass builds a **new** quotient array
98
+ * and rebinds it, rather than clearing and refilling the array being iterated -- the first version of this
99
+ * function did the latter, and mutating a collection while iterating it produced nine failing round-trips
100
+ * out of eighteen, including single bytes.
101
+ *
102
+ * @param {Buffer} bytes
103
+ * @returns {number[]} values in `0..250`
104
+ */
105
+ export function encodeBytes(bytes) {
106
+ const input = Buffer.isBuffer(bytes) ? bytes : Buffer.from(String(bytes), 'utf8');
107
+ const digits = [];
108
+ let current = Array.from(input);
109
+ while (!(current.length === 0 || (current.length === 1 && current[0] === 0))) {
110
+ const quotient = [];
111
+ let remainder = 0;
112
+ for (const byte of current) {
113
+ const value = remainder * 256 + byte;
114
+ const q = Math.floor(value / SYMBOL_COUNT);
115
+ remainder = value % SYMBOL_COUNT;
116
+ // Leading zeros in the quotient are dropped, because the divisor never produces a meaningful one and
117
+ // keeping them would make the loop's termination depend on the input's length rather than its value.
118
+ if (quotient.length > 0 || q > 0) quotient.push(q);
119
+ }
120
+ digits.push(remainder);
121
+ current = quotient;
122
+ }
123
+ return digits;
124
+ }
125
+
126
+ /**
127
+ * Rebuild bytes from base-251 digits, least significant first.
128
+ *
129
+ * ## The direction mismatch that made this wrong three times
130
+ *
131
+ * `encodeBytes` emits remainders as it divides, so its output is **least significant first**. The
132
+ * accumulator below is Horner's rule, which consumes digits **most significant first**. Feeding one into the
133
+ * other directly inverts the message, and it does so in a way that looks like byte-order corruption rather
134
+ * than a reversal: `"first"` came back as `"Y~\u0010\u0018\u00fc"`, the right multiset of bytes in the wrong
135
+ * order.
136
+ *
137
+ * Two attempts to fix that failed because they were fixing the wrong half -- first a stray `.reverse()` on
138
+ * the *output* buffer was added and removed, and then it was removed again, while the actual problem was
139
+ * that the *input* needed reversing. The reference check that settled it was computing the digits with
140
+ * `BigInt` and comparing: `encodeBytes` matched the reference exactly, which located the fault on the decode
141
+ * side and in the input order rather than the output.
142
+ *
143
+ * @param {number[]} digits least significant first, as `encodeBytes` produces
144
+ * @returns {Buffer}
145
+ */
146
+ export function decodeBytes(digits) {
147
+ let current = [0];
148
+ for (const digit of [...digits].reverse()) {
149
+ let carry = digit;
150
+ const next = [];
151
+ for (const value of current) {
152
+ const total = value * SYMBOL_COUNT + carry;
153
+ next.push(total % 256);
154
+ carry = Math.floor(total / 256);
155
+ }
156
+ while (carry > 0) {
157
+ next.push(carry % 256);
158
+ carry = Math.floor(carry / 256);
159
+ }
160
+ current = next;
161
+ }
162
+ // Trim high-order zero bytes, which the multiplication produces for any value that does not fill its last
163
+ // limb. This is also why leading NUL bytes are not recoverable: a leading zero does not change the number,
164
+ // so `[0, 0, 1]` and `[1]` are the same value and the same digit list. Recovering them would need a length
165
+ // declared on the wire, and this channel carries text.
166
+ while (current.length > 1 && current[current.length - 1] === 0) current.pop();
167
+ if (current.length === 1 && current[0] === 0) return Buffer.alloc(0);
168
+ // Little-endian in, little-endian out: reverse once, here, at the boundary.
169
+ return Buffer.from(current.reverse());
170
+ }
171
+
172
+ /**
173
+ * Turn a report into the exit codes that carry it, ending with the end frame.
174
+ *
175
+ * The terminator is part of the encoding rather than a convention the caller remembers, because a caller
176
+ * who forgets it produces a receiver that waits forever for a message that already finished.
177
+ *
178
+ * @param {string|Buffer} report
179
+ * @returns {number[]} exit codes, in order
180
+ */
181
+ export function toExitCodes(report) {
182
+ return [...encodeBytes(report).map(codeForSymbol), DONE];
183
+ }
184
+
185
+ /**
186
+ * Decode exit codes back into a report.
187
+ *
188
+ * Distinguishes three outcomes, because they need different responses: a complete message, a message that
189
+ * stopped early, and a sender that reported its own failure. Collapsing them is how "truncated" is read as
190
+ * "complete" and a half-answer is believed.
191
+ *
192
+ * @param {number[]} codes
193
+ * @returns {{ok: true, text: string, bytes: number} | {ok: false, reason_code: string, text: string|null, detail: string}}
194
+ */
195
+ export function fromExitCodes(codes) {
196
+ const digits = [];
197
+ for (let i = 0; i < codes.length; i += 1) {
198
+ const code = codes[i];
199
+ if (code === FRAME.END) {
200
+ // Everything after a clean end is ignored rather than appended: a sender that emitted more than one
201
+ // terminator is confused, and guessing which message was intended is not this function's job.
202
+ const bytes = decodeBytes(digits);
203
+ return { ok: true, text: bytes.toString('utf8'), bytes: bytes.length };
204
+ }
205
+ if (code === FRAME.ERROR) {
206
+ const partial = digits.length > 0 ? decodeBytes(digits).toString('utf8') : null;
207
+ return { ok: false, reason_code: 'SENDER_REPORTED_ERROR', text: partial, detail: 'the sender stopped and said so rather than ending the message' };
208
+ }
209
+ const symbol = symbolForCode(code);
210
+ if (symbol === null) {
211
+ // A code outside the alphabet. `126` and `127` land here, and naming them matters: they are what a
212
+ // shell writes when the command never ran at all, which is a different diagnosis from a bad digit.
213
+ const why = code === FRAME.SHELL_NOT_EXECUTABLE
214
+ ? 'exit code 126 is written by a shell for "not executable": the command never ran'
215
+ : code === FRAME.SHELL_NOT_FOUND
216
+ ? 'exit code 127 is written by a shell for "not found": the command never ran'
217
+ : `exit code ${code} at position ${i} is outside the payload alphabet`;
218
+ return { ok: false, reason_code: 'EXIT_CODE_OUT_OF_ALPHABET', text: null, detail: why };
219
+ }
220
+ digits.push(symbol);
221
+ }
222
+ // Ran out of codes without a terminator. This is the case worth being loud about.
223
+ const partial = digits.length > 0 ? decodeBytes(digits).toString('utf8') : null;
224
+ return {
225
+ ok: false,
226
+ reason_code: 'TRUNCATED',
227
+ text: partial,
228
+ detail: `the channel ended after ${codes.length} code(s) with no end frame; the message is incomplete and must not be read as a whole`,
229
+ };
230
+ }
231
+
232
+ /**
233
+ * How many dispatches a report costs.
234
+ *
235
+ * Reported rather than estimated at the call site, because the honest answer is what decides whether this
236
+ * channel is usable for a given payload: a one-line verdict is a handful of codes, and a log file is not
237
+ * worth sending this way at all.
238
+ */
239
+ export function costOf(report) {
240
+ const codes = toExitCodes(report);
241
+ return { exit_codes: codes.length, bytes: Buffer.byteLength(String(report), 'utf8') };
242
+ }
@@ -185,7 +185,7 @@ const MAX_STAGES = 16;
185
185
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
186
186
  * overrides it at runtime for tests and packagers.
187
187
  */
188
- const PLUGIN_VERSION = '0.10.3';
188
+ const PLUGIN_VERSION = '0.10.5';
189
189
 
190
190
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
191
191
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;