@twinsearth/w2m-dsh-plugin 0.10.2 → 0.10.4
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 +81 -0
- package/package.json +5 -4
- package/src/agent/exit-channel.mjs +242 -0
- package/src/agent/health.mjs +11 -4
- package/src/plugin/tools.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,87 @@ 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.4] — 2026-10-15
|
|
8
|
+
|
|
9
|
+
**A result channel over exit codes, for machines whose text cannot be read back.**
|
|
10
|
+
|
|
11
|
+
### Why this exists, stated without euphemism
|
|
12
|
+
|
|
13
|
+
The relay carries a per-machine `exit_code` faithfully and carries **no stdout at all**. That is a
|
|
14
|
+
deliberate property of the ledger and the right design for a system whose job is to say whether machines
|
|
15
|
+
agree — but the consequence is that an orchestrator can learn *that* a task failed and never *why*, and on
|
|
16
|
+
this fleet that cost several rounds: a health check returning a real verdict was indistinguishable from one
|
|
17
|
+
that crashed, because both produce a number and nothing else.
|
|
18
|
+
|
|
19
|
+
Every channel that would carry text is unavailable here. The relay host's SSH and its sink are refused, an
|
|
20
|
+
inbound receiver on the orchestrator never received a single connection, and a machine's `allowedCommands`
|
|
21
|
+
may permit only a narrow set of commands. What has worked on every machine every time is the exit status. So
|
|
22
|
+
this encodes bytes into exit codes. It is slow and it is ugly, and both are the point: **a diagnostic that
|
|
23
|
+
arrives slowly is worth more than one that never arrives.**
|
|
24
|
+
|
|
25
|
+
### The two design choices that are not arbitrary
|
|
26
|
+
|
|
27
|
+
- **`126` and `127` never carry data.** Shells write them for "not executable" and "not found", so a real
|
|
28
|
+
127 and a data 127 would be indistinguishable and the reader would investigate the wrong failure. The
|
|
29
|
+
alphabet is therefore a mapping over `1..125` and `128..253`, not a constant offset — the first version
|
|
30
|
+
used `value + 1` across the whole range and its own test caught the collision.
|
|
31
|
+
- **Truncation is not completion.** `254` ends the message and `255` says the sender failed. A channel that
|
|
32
|
+
stopped mid-message must not be read as a whole message, because that turns "the machine stopped talking"
|
|
33
|
+
into "the machine said this" with no way for the reader to tell.
|
|
34
|
+
|
|
35
|
+
### Three failed fixes, and what actually located the bug
|
|
36
|
+
|
|
37
|
+
`encodeBytes` emits remainders as it divides, so its output is **least significant first**; the decoder's
|
|
38
|
+
Horner accumulator consumes digits **most significant first**. Feeding one into the other inverts the
|
|
39
|
+
message, and it presents as byte-order corruption rather than reversal — `"first"` came back as
|
|
40
|
+
`"Y~\u0010\u0018\u00fc"`.
|
|
41
|
+
|
|
42
|
+
Two attempts to fix it failed because they were fixing the wrong half: a stray `.reverse()` was added to the
|
|
43
|
+
*output* buffer, then removed, then removed again, while the real fault was that the *input* needed
|
|
44
|
+
reversing. The first attempt also had a genuine second bug — the encoder mutated the array it was iterating
|
|
45
|
+
— which masked the first. What settled it was computing the digits with `BigInt` and comparing: `encodeBytes`
|
|
46
|
+
matched the reference exactly, which located the fault on the decode side and in the input order rather than
|
|
47
|
+
the output.
|
|
48
|
+
|
|
49
|
+
### What is documented rather than papered over
|
|
50
|
+
|
|
51
|
+
Leading NUL bytes are not recoverable: they do not change the number, so `[0, 0, 1]` and `[1]` are the same
|
|
52
|
+
value and the same digit list. Recovering them needs a length declared on the wire, and this channel carries
|
|
53
|
+
text. That is asserted in the suite, because a future reader will otherwise treat it as a bug and add a
|
|
54
|
+
length prefix that changes the wire format for every existing message.
|
|
55
|
+
|
|
56
|
+
A 29-byte verdict costs 30 dispatches. `costOf()` reports this so a caller can see that a log file is not
|
|
57
|
+
worth sending this way — and fix the real channel instead.
|
|
58
|
+
|
|
59
|
+
**17 tests, 0 failures.**
|
|
60
|
+
|
|
61
|
+
## [0.10.3] — 2026-10-15
|
|
62
|
+
|
|
63
|
+
**Fix: the macOS health check walked a user's home directory, and timed out instead of reporting.**
|
|
64
|
+
|
|
65
|
+
Found on a real machine, not by reading the code.
|
|
66
|
+
|
|
67
|
+
`sample()` on macOS was scanning `/Users` for files that grow. On a developer machine that is hundreds of
|
|
68
|
+
thousands of files, and the symptom was not "slow" but **total failure**: the health suite exceeded the
|
|
69
|
+
fleet's own ~50-second lease ceiling and the task expired with `exit_code: null` and no result at all.
|
|
70
|
+
|
|
71
|
+
A monitoring tool that times out because it is too expensive to monitor is worse than no monitoring,
|
|
72
|
+
because **the timeout looks like a machine problem**. The contrast that identified it, measured on the same
|
|
73
|
+
host minutes apart:
|
|
74
|
+
|
|
75
|
+
| Command | Time | Result |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `node -e "process.exit(1)"` | 8 s | `exit_code: 1` — the machinery was fine |
|
|
78
|
+
| `health.test.mjs` (before) | > 50 s | expired, no result |
|
|
79
|
+
| the same suite (authoring host) | 280 ms | pass |
|
|
80
|
+
|
|
81
|
+
`/Users` is replaced by `/Library/Logs`, which is where an unbounded system writer actually writes. The rule
|
|
82
|
+
this file already stated — that a health check must not be a load source — was written in the module header
|
|
83
|
+
while the path list violated it, which is the useful part of the lesson: the principle was written down and
|
|
84
|
+
still not applied to the one line that needed it.
|
|
85
|
+
|
|
86
|
+
Sampling now costs 53 ms. 20 tests, 0 failures.
|
|
87
|
+
|
|
7
88
|
## [0.10.2] — 2026-10-15
|
|
8
89
|
|
|
9
90
|
**Tiered storage — temporary, compressed, permanent, and the two kinds of knowledge that persist forever.**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.4",
|
|
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",
|
|
@@ -112,13 +112,13 @@
|
|
|
112
112
|
}
|
|
113
113
|
},
|
|
114
114
|
"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",
|
|
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/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
116
|
"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
117
|
"test:relay": "node --test --test-force-exit test/relay.test.mjs",
|
|
118
118
|
"test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
|
|
119
119
|
"test:tools": "node --test --test-force-exit test/tools.test.mjs",
|
|
120
120
|
"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",
|
|
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/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
122
|
"pack": "node scripts/pack.mjs",
|
|
123
123
|
"verify:join": "node scripts/verify-join.mjs",
|
|
124
124
|
"lint": "eslint .",
|
|
@@ -133,7 +133,8 @@
|
|
|
133
133
|
"test:approval-index": "node --test --test-force-exit test/approval-index.test.mjs",
|
|
134
134
|
"test:fleet-sync": "node --test --test-force-exit test/fleet-sync.test.mjs",
|
|
135
135
|
"test:health": "node --test --test-force-exit test/health.test.mjs",
|
|
136
|
-
"test:storage": "node --test --test-force-exit test/storage.test.mjs"
|
|
136
|
+
"test:storage": "node --test --test-force-exit test/storage.test.mjs",
|
|
137
|
+
"test:exit-channel": "node --test --test-force-exit test/exit-channel.test.mjs"
|
|
137
138
|
},
|
|
138
139
|
"keywords": [
|
|
139
140
|
"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
|
+
}
|
package/src/agent/health.mjs
CHANGED
|
@@ -48,13 +48,20 @@ export const LOG_GROWTH_WARN_BYTES_PER_DAY = 1 * GB;
|
|
|
48
48
|
/**
|
|
49
49
|
* Which paths are worth checking for growth, by platform.
|
|
50
50
|
*
|
|
51
|
-
* Deliberately short
|
|
52
|
-
*
|
|
53
|
-
*
|
|
51
|
+
* Deliberately short, and deliberately **not including a user's home directory**.
|
|
52
|
+
*
|
|
53
|
+
* A health check that walks a whole filesystem is itself a load source, and this list got that wrong on the
|
|
54
|
+
* first attempt: macOS was given `/Users`, and on a developer machine that is hundreds of thousands of files.
|
|
55
|
+
* The symptom appeared on a real machine as a health check that **exceeded the fleet's own ~50-second lease
|
|
56
|
+
* ceiling** and expired with no result at all — a monitoring tool timing out because it was too expensive to
|
|
57
|
+
* monitor. On the machine that wrote it, the same suite runs in 280 ms.
|
|
58
|
+
*
|
|
59
|
+
* So each entry is a place where an unbounded writer has actually been observed, which is what the 14.7
|
|
60
|
+
* GB/day incident was: two files in `/var/log`, not a general untidiness.
|
|
54
61
|
*/
|
|
55
62
|
export const LOG_PATHS = Object.freeze({
|
|
56
63
|
linux: ['/var/log', '/var/lib/w2m', '/tmp'],
|
|
57
|
-
darwin: ['/var/log', '/
|
|
64
|
+
darwin: ['/var/log', '/Library/Logs', '/tmp'],
|
|
58
65
|
win32: ['C:\\Windows\\Logs', 'C:\\Windows\\Temp'],
|
|
59
66
|
});
|
|
60
67
|
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -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.
|
|
188
|
+
const PLUGIN_VERSION = '0.10.4';
|
|
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;
|