@twinsearth/w2m-dsh-plugin 0.5.7 → 0.5.9
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 +159 -41
- package/CONTRIBUTING.md +14 -0
- package/package.json +3 -3
- package/src/agent/allowed-commands.mjs +67 -5
- package/src/agent/exec.mjs +60 -4
- package/src/agent/mdns-wire.mjs +54 -54
- package/src/agent/mdns.mjs +31 -31
- package/src/plugin/tools.mjs +295 -30
package/CHANGELOG.md
CHANGED
|
@@ -4,56 +4,118 @@ 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.5.
|
|
7
|
+
## [0.5.9] — 2026-10-11
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
* **A release pre-flight, as one entry point: `node scripts/preflight.mjs`.** A release is now
|
|
12
|
+
something you start by running it, and a green run is the precondition for the version bump. It
|
|
13
|
+
checks, in order: that every gate it delegates to exists on disk; that lint is at 0 errors; that the
|
|
14
|
+
mojibake scan is clean over the whole tree; that the suite declared in `package.json#scripts.test`
|
|
15
|
+
runs and passes; that the **packed tarball** — not the tree — contains every path `package.json#files`
|
|
16
|
+
names, names the tree's version, carries no forbidden path (`node_modules`, `test/`, `scripts/`,
|
|
17
|
+
`.git`, `*.proof-backup`/`*.orig`/`*.bak`) and registers its eight tools when executed from the
|
|
18
|
+
extracted copy; that the version, bundle-patch and document claims in `test/claims.test.mjs` still
|
|
19
|
+
hold; and that the CHANGELOG's title sits above its newest entry and its entries run newest-first.
|
|
20
|
+
Every check reports `PASS`, `FAIL` or `UNVERIFIABLE` **with a name and a reason**, and the run fails
|
|
21
|
+
if any registered check produces no result at all: "the gate ran" is itself a check, and the ledger
|
|
22
|
+
that enforces it is itself exercised on every run against a silent check, a throwing check and an
|
|
23
|
+
honest one — because a self-authored gate that reports success while executing zero checks is a
|
|
24
|
+
defect this project studied in another one. The first draft of that ledger counted a coerced `FAIL`
|
|
25
|
+
as a produced result, and the self-test caught it on its first run.
|
|
26
|
+
* **A wiring check that would have caught two of the defects below.** Every `test/*.test.mjs` on disk
|
|
27
|
+
must be named by some npm script, and every path an npm script names must exist. Measured now: 36
|
|
28
|
+
suites on disk, 36 named by a script, 0 orphans, 0 named-but-absent.
|
|
29
|
+
* **A CI compatibility gate** (`.github/workflows/compat.yml`, with its own section in
|
|
30
|
+
`CONTRIBUTING.md`). Three jobs. *mount-and-boot* builds a pinned host in `RUNNER_TEMP`
|
|
31
|
+
(`@deepseek-ai/dsh@0.2.0-rc.2`, `dsh-app-boot`, `dsh-client-modules`, `cordis@4.0.4`,
|
|
32
|
+
`cordis-plugin-loader@1.0.5`), runs the existing `test/loader-entry.test.mjs` against it **unmodified**
|
|
33
|
+
and classifies the TAP capture with nine named assertions, of which a `SKIP` counts as a failure.
|
|
34
|
+
*bare-import* packs, installs into an empty project, and asserts `dependencies === {}`, that no host
|
|
35
|
+
package resolves, and that the root export is a callable `apply`. *uninstall* asserts the composition
|
|
36
|
+
is byte-identical before and after, and documents why the manifest key set is not compared. Triggers:
|
|
37
|
+
PRs to main, pushes to main, `v*` tags, weekly, and dispatch; `contents: read`, no secrets. No-browser
|
|
38
|
+
is the single entry on `ACCEPTED_UNVERIFIABLE` and reports `UNVERIFIABLE` with its reason rather than
|
|
39
|
+
`PASS`. Validated end to end against a synthetic installation built with the workflow's own commands
|
|
40
|
+
(10/10 pass) and in the failing direction with `W2M_ROUTE_DEFECT=1`, which reports `FAIL` naming the
|
|
41
|
+
route assertion and the fiber sweep — the same both-directions rule the rest of this release follows.
|
|
42
|
+
* **Refusal codes, and the exposure boundary written down as executable text.** 26 codes in three
|
|
43
|
+
greppable tables (`REFUSAL_CODE` in `src/plugin/tools.mjs`, `MATCH_REFUSAL` in
|
|
44
|
+
`src/agent/allowed-commands.mjs`, `EXEC_REFUSAL` in `src/agent/exec.mjs`), with
|
|
45
|
+
`MATCH_REFUSAL.NOT_ALLOWED === REFUSAL.COMMAND_NOT_ALLOWED` asserted so the two ends of a dispatch
|
|
46
|
+
cannot drift apart. The defect this closes is that `applied: false` conflated three different facts:
|
|
47
|
+
a stored selection naming no machine was indistinguishable from "the whole fleet ran it", and that
|
|
48
|
+
state now has its own code (`SELECTION_UNUSABLE`). Every refusal carries `reason_code` and a `detail`
|
|
49
|
+
saying what would change it. The boundary itself — no tool parameter may name a credential, an
|
|
50
|
+
allow-list, a key path or a state directory — is stated in `src/plugin/tools.mjs`'s header and pinned
|
|
51
|
+
by four canaries run across all eight tools, proving that no result and no request body carries a
|
|
52
|
+
token. Proven by 17 mutations, 17 red, in a scratch copy outside the repository.
|
|
8
53
|
|
|
9
54
|
### Fixed
|
|
10
55
|
|
|
11
|
-
*
|
|
12
|
-
|
|
56
|
+
* **`CHANGELOG.md` had carried a mangled character into the published package.** v0.5.8's own entry —
|
|
57
|
+
the one describing the mojibake repair — contained the two garbled CJK sequences it was complaining
|
|
58
|
+
about, reproduced literally inside backticks. `CHANGELOG.md` is in `package.json#files`, so v0.5.8
|
|
59
|
+
shipped the damage it was apologising for, and `scripts/scan-mojibake.mjs` was red on the tree. The
|
|
60
|
+
line is ASCII now and the scan is clean over the whole tree.
|
|
61
|
+
* **The CHANGELOG's title and introduction were in the middle of the file.** The v0.5.8 entry was
|
|
62
|
+
prepended above `# Changelog` itself, which is the same mistake as the out-of-order entries v0.5.8
|
|
63
|
+
fixed, one level up: "newest first" was true of the version headings while the document's own header
|
|
64
|
+
had been pushed to line 21. The entry is below the title again, and the pre-flight now asserts the
|
|
65
|
+
shape rather than only the order.
|
|
66
|
+
* **The entire declared unit suite had not been running.** Wiring the new suites into
|
|
67
|
+
`package.json#scripts.test` and `#scripts.test:all` dropped one space, producing the path
|
|
68
|
+
`test/claims.test.mjstest/fleet-target.test.mjs`. `node --test` exits 1 on a missing path, so
|
|
69
|
+
`npm test` was failing immediately and *no* unit suite — not just `fleet-target` — was running. This
|
|
70
|
+
one is mine: it came from the edit that wired the new suites in, and it means the declared unit suite
|
|
71
|
+
was not being run at all between that edit and this release.
|
|
72
|
+
* **`test/p2p-crypto.test.mjs` was named by no script at all.** 33 tests, green, and wired into
|
|
73
|
+
nothing — the exact defect v0.5.7's release note identified in someone else's repository. A suite
|
|
74
|
+
that never runs protects nothing, and that is now asserted rather than assumed. It is in `test` and
|
|
75
|
+
`test:all`.
|
|
13
76
|
|
|
14
|
-
|
|
15
|
-
w2m (@twinsearth/w2m-dsh-plugin/tools): Error: cannot get property "connection" without inject
|
|
16
|
-
at registerTargetRoutes (src/plugin/fleet-target.mjs:483:25)
|
|
17
|
-
```
|
|
77
|
+
### Notes
|
|
18
78
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
*
|
|
30
|
-
|
|
31
|
-
|
|
79
|
+
* This release exists because three releases in one hour — v0.5.6, v0.5.7, v0.5.8 — each fixed a
|
|
80
|
+
defect the previous one introduced. **Every one of those defects was found after the tag was pushed**,
|
|
81
|
+
by a gate that already existed and simply was not run before the release. The gates were never
|
|
82
|
+
broken; the process around them was missing. That process is this release's subject.
|
|
83
|
+
* One check is `UNVERIFIABLE` on this machine and is reported as such rather than passed quietly: the
|
|
84
|
+
host has no `%DSH_HOME%\dsh-runtimes\`, while `src/agent/resolve.mjs` looks for the bundled runtime at
|
|
85
|
+
`dsh-runtimes/dsh-primary-runtime/dependencies` and the application bundle carries it at
|
|
86
|
+
`E:\DS\resources\runtime\primary-runtime\dependencies` instead. The suite's bundled-python claim is
|
|
87
|
+
measured against a throwaway home with the documented layout, and the host mismatch is printed next
|
|
88
|
+
to the verdict, as an open question rather than a silent pass.
|
|
89
|
+
* Two checks are known to be sensitive to a loaded machine rather than to the artifact, and both are
|
|
90
|
+
named here rather than discovered later: `test/relay.test.mjs`'s `runs a complete signed task end to
|
|
91
|
+
end`, whose `lease_until` is computed by the relay during the request and recomputed by the test
|
|
92
|
+
after the await, so the two can land in different seconds; and the capability probe in
|
|
93
|
+
`test/agent.test.mjs`, which measures the host's own runtime layout as well as the resolver. A red
|
|
94
|
+
run naming only these is a re-run, not a release defect — and a report that hides them behind a green
|
|
95
|
+
verdict is the kind of green this release exists to avoid.
|
|
96
|
+
|
|
97
|
+
## [0.5.8] — 2026-10-11
|
|
32
98
|
|
|
33
|
-
###
|
|
99
|
+
### Fixed
|
|
34
100
|
|
|
35
|
-
*
|
|
36
|
-
the
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
101
|
+
* **The CHANGELOG was out of order, and the claims gate caught it.** My bump scripts anchored the new
|
|
102
|
+
entry on the wrong heading, so v0.5.5 ended up *above* v0.5.6 and v0.5.7 and "the newest entry" stopped
|
|
103
|
+
meaning anything — which is exactly the assertion `test/claims.test.mjs` makes. Entries are ordered
|
|
104
|
+
newest-first again, and the anchor is the top of the file rather than a version I have to remember.
|
|
105
|
+
* **Mojibake in shipped code.** `src/agent/mdns-wire.mjs` is packed, and its comments carried an em dash
|
|
106
|
+
and a section sign that had been decoded as the wrong encoding and re-saved, so a reader would have
|
|
107
|
+
seen the two garbled CJK sequences where the RFC references belong. The same damage was in
|
|
108
|
+
`test/mdns-discover.test.mjs`.
|
|
109
|
+
All five mDNS files are ASCII now, and the repository's own mojibake scan is clean.
|
|
44
110
|
|
|
45
111
|
### Notes
|
|
46
112
|
|
|
47
|
-
*
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
from the broken one by version alone.
|
|
54
|
-
* Still unverified, and not claimed: how the control *renders*. No browser was opened. What is proven is
|
|
55
|
-
the entire host side — the entry activates, the eight tools register, the boot-graph row is composed
|
|
56
|
-
from the shipped bundle, the route is registered and attached — plus the seat shape in a VM test.
|
|
113
|
+
* Fix-forward again, and for the same reason: v0.5.6 and v0.5.7 are published and stay published.
|
|
114
|
+
* Three releases in an hour, each fixing a defect the previous one introduced and a gate caught. That is
|
|
115
|
+
the gates working, but it is also a release process with no pre-flight of its own — which is the next
|
|
116
|
+
thing this project should fix rather than the next thing it should be proud of.
|
|
117
|
+
* No runtime change: the code in v0.5.8 is the code in v0.5.6.
|
|
118
|
+
|
|
57
119
|
## [0.5.7] — 2026-10-11
|
|
58
120
|
|
|
59
121
|
### Fixed
|
|
@@ -75,6 +137,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
75
137
|
rewriting history someone may already have. Both defects are mine, and both are the class this project
|
|
76
138
|
keeps naming — a claim that nothing checks.
|
|
77
139
|
* No runtime change: the code in v0.5.7 is the code in v0.5.6.
|
|
140
|
+
|
|
78
141
|
## [0.5.6] — 2026-10-11
|
|
79
142
|
|
|
80
143
|
### Fixed
|
|
@@ -146,6 +209,58 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
146
209
|
Bonjour/Avahi peer answers. There is **no third-party interoperability test**, no probing or conflict
|
|
147
210
|
resolution, IPv4 only, and the responder is **built and tested but not started** by the plugin —
|
|
148
211
|
loading a plugin should not silently multicast on the host's network.
|
|
212
|
+
|
|
213
|
+
## [0.5.5] — 2026-10-11
|
|
214
|
+
|
|
215
|
+
### Fixed
|
|
216
|
+
|
|
217
|
+
* **The v0.5.4 loader failure, with the cause the loader actually reported** rather than the hypothesis
|
|
218
|
+
that stood in for it:
|
|
219
|
+
|
|
220
|
+
```
|
|
221
|
+
w2m (@twinsearth/w2m-dsh-plugin/tools): Error: cannot get property "connection" without inject
|
|
222
|
+
at registerTargetRoutes (src/plugin/fleet-target.mjs:483:25)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
cordis throws on reading a service property the fiber never declared, so the entry died and took all
|
|
226
|
+
eight tools with it. **Every existing test was blind to this**, because they drive the plugin with a
|
|
227
|
+
plain-object context where an undeclared read merely yields `undefined` — the fake was more forgiving
|
|
228
|
+
than the runtime. Fixed with `ctx.inject(['connection'], …)`, which is also the only shape that can
|
|
229
|
+
work: in the real composition the service is genuinely absent while `apply()` runs, and siblings are
|
|
230
|
+
mounted concurrently.
|
|
231
|
+
* **The client half was never composed either, for a second and independent reason:** the bundle row
|
|
232
|
+
named the subpath `@twinsearth/w2m-dsh-plugin/tools` instead of the package, and a client package is
|
|
233
|
+
identified by its Loader row specifier — a subpath resolves to no `package.json`, so `dsh.client` was
|
|
234
|
+
never read and no boot-graph row was built. The row now names the package.
|
|
235
|
+
* **A silent route rejection:** the control fetched `/w2m/target`, but fetch routes are validated
|
|
236
|
+
against `/api/`, and the refusal failed only a child fiber, so nothing surfaced. Now
|
|
237
|
+
`/api/w2m/target`, mirrored in the shipped bundle.
|
|
238
|
+
|
|
239
|
+
### Added
|
|
240
|
+
|
|
241
|
+
* **`test/loader-entry.test.mjs`** — the test that would have caught v0.5.4: it packs the tree, installs
|
|
242
|
+
the tarball with the installation's own `dsh plugin add` into a throwaway profile under a throwaway
|
|
243
|
+
`DSH_HOME`, and boots it with the real `runProfile` under the Harness runtime, asserting that the
|
|
244
|
+
entry activates, all eight tools are registered, the boot-graph row is composed from the shipped
|
|
245
|
+
bundle, and no failed entry names the package. **Proved in both directions**: 6/6 pass on the fixed
|
|
246
|
+
tree and 6/6 fail against the preserved v0.5.4 tarball, failing on precisely the loader error quoted
|
|
247
|
+
above. It skips loudly, naming the probes it tried, when no installation is found.
|
|
248
|
+
* Cheap always-on guards in `test/client-bundle.test.mjs` that need no Harness: the platform field, the
|
|
249
|
+
client export, the row must be the bare package name, and the bundle's route must equal the host's.
|
|
250
|
+
|
|
251
|
+
### Notes
|
|
252
|
+
|
|
253
|
+
* **An upgrade from v0.5.4 requires one edit outside this package:** `cordis.patch.yml` in the profile
|
|
254
|
+
must name the package (`@twinsearth/w2m-dsh-plugin`), not the `/tools` subpath. With the corrected
|
|
255
|
+
bundle row, a mismatch makes the loader skip the whole patch — silently reverting `rabbitUrl`,
|
|
256
|
+
`operatorToken`, `stateDir`, `projectDir`, `machineName`, `p2pMode` and `allowedCommands` to their
|
|
257
|
+
shipped defaults. Measured both ways with `--dump-config`.
|
|
258
|
+
* **0.5.4 remains DO NOT INSTALL.** This release exists so that a rebuilt artifact is distinguishable
|
|
259
|
+
from the broken one by version alone.
|
|
260
|
+
* Still unverified, and not claimed: how the control *renders*. No browser was opened. What is proven is
|
|
261
|
+
the entire host side — the entry activates, the eight tools register, the boot-graph row is composed
|
|
262
|
+
from the shipped bundle, the route is registered and attached — plus the seat shape in a VM test.
|
|
263
|
+
|
|
149
264
|
## [0.5.4] — 2026-10-11
|
|
150
265
|
|
|
151
266
|
> **DO NOT INSTALL 0.5.4.** It fails to load in the Harness: the loader entry
|
|
@@ -186,6 +301,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
186
301
|
serves the plugin from the profile's `node_modules`; the new revision must be installed *and the
|
|
187
302
|
Harness restarted* before the control appears. The control is therefore in the artifact and not in the
|
|
188
303
|
running UI, and this entry says so rather than implying otherwise.
|
|
304
|
+
|
|
189
305
|
## [0.5.3] — 2026-10-11
|
|
190
306
|
|
|
191
307
|
### Added
|
|
@@ -215,6 +331,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
215
331
|
* Every socket in the new suite is loopback and crosses no NAT; that is stated in the suite header and
|
|
216
332
|
is not evidence that a real translation can be crossed.
|
|
217
333
|
* No protocol version bump: `envelope_version` stays `1.0` and a v0.4.x/v0.5.x peer is unaffected.
|
|
334
|
+
|
|
218
335
|
## [0.5.2] — 2026-10-10
|
|
219
336
|
|
|
220
337
|
### Fixed
|
|
@@ -248,6 +365,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
248
365
|
* This release exists because `deploy/` and `docs/` are inside the published package: after v0.5.1 the
|
|
249
366
|
tag and the tarball had drifted, and a release is the only honest way to close that.
|
|
250
367
|
* No runtime change to the eight tools.
|
|
368
|
+
|
|
251
369
|
## [0.5.1] — 2026-10-10
|
|
252
370
|
|
|
253
371
|
### Fixed
|
|
@@ -282,6 +400,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
282
400
|
supervisor leaves the agent alive with its punch socket still bound (`0.0.0.0:41235`).
|
|
283
401
|
* Also included: the test fix for `test/outbound-only.test.mjs`, whose port was being read from the
|
|
284
402
|
timestamp rather than from the field that states it (commit `57dbc5f`).
|
|
403
|
+
|
|
285
404
|
## [0.5.0] — 2026-10-10
|
|
286
405
|
|
|
287
406
|
The release that stops treating "the relay carried it" as the only answer for a machine behind a
|
|
@@ -761,7 +880,6 @@ written down as not measured.
|
|
|
761
880
|
this entry claim it is. The WAN measurement is one hop with one NAT, and it is
|
|
762
881
|
labelled as one hop with one NAT.
|
|
763
882
|
|
|
764
|
-
|
|
765
883
|
## [0.3.9] — 2026-10-09
|
|
766
884
|
|
|
767
885
|
P2P hole punching, so the payload path no longer has to cross the relay.
|
package/CONTRIBUTING.md
CHANGED
|
@@ -49,6 +49,16 @@ throwaway profile under a throwaway `DSH_HOME` and boots it with the real `runPr
|
|
|
49
49
|
|
|
50
50
|
Each line is checkable against a diff.
|
|
51
51
|
|
|
52
|
+
0. **A release starts with `node scripts/preflight.mjs`, and a green run is the precondition for a
|
|
53
|
+
version bump.** `scripts/preflight.mjs` is the only sanctioned way to start a release: it runs the
|
|
54
|
+
lint, the mojibake scan, the suite declared in `package.json#scripts.test`, the pack plus the
|
|
55
|
+
assertions against the **packed tarball**, and the claim checks in `test/claims.test.mjs`, in that
|
|
56
|
+
order, and prints `PASS`/`FAIL`/`UNVERIFIABLE` with a reason for each. It exits non-zero on any
|
|
57
|
+
failure, and also when a check produces no result at all — "the gate ran" is itself checked. Do not
|
|
58
|
+
bump a version, tag, or publish on a red or unread run; `UNVERIFIABLE` is not a pass either, so read
|
|
59
|
+
the reason it prints. The three releases of 2026-10-11 (v0.5.6, v0.5.7, v0.5.8) each fixed a defect
|
|
60
|
+
the previous one introduced, every one of them found *after* the tag was pushed by a gate nobody had
|
|
61
|
+
run. This line exists so that does not happen again.
|
|
52
62
|
1. **Zero runtime dependencies.** `dependencies === {}` in `package.json`. Node built-ins only.
|
|
53
63
|
2. **Argv execution, never a shell string.** A command is an array of arguments passed to
|
|
54
64
|
`spawn`/`execFile` with no shell. No `exec`, no `shell: true`, no string interpolation into a
|
|
@@ -121,3 +131,7 @@ Therefore:
|
|
|
121
131
|
|
|
122
132
|
Do not open a public issue for a vulnerability. See [SECURITY.md](SECURITY.md) for the private
|
|
123
133
|
channel and what to include.
|
|
134
|
+
|
|
135
|
+
## The compatibility gate
|
|
136
|
+
|
|
137
|
+
`.github/workflows/compat.yml` is the CI half of rule 7, on every pull request, every push to `main`, every `v*` tag and a weekly schedule: it packs the tree, installs the tarball into a scratch profile with the host's own `dsh plugin add`, and asserts the deterministic artifact — the plugin's row composed by the real Loader, all eight `w2m_*` tools registered, no failed fiber anywhere in the composed tree, the client half activated through the real module system — plus a bare import with no host present and an install/uninstall that returns a profile to its previous composition; **a red compat run blocks a release** exactly as a red preflight does, and its `UNVERIFIABLE` outcomes are never a pass — read the reason it prints.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.9",
|
|
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",
|
|
@@ -99,13 +99,13 @@
|
|
|
99
99
|
}
|
|
100
100
|
},
|
|
101
101
|
"scripts": {
|
|
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.
|
|
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.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",
|
|
103
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",
|
|
104
104
|
"test:relay": "node --test test/relay.test.mjs",
|
|
105
105
|
"test:agent": "node --test test/agent.test.mjs",
|
|
106
106
|
"test:tools": "node --test test/tools.test.mjs",
|
|
107
107
|
"test:crossnetwork": "node --test --test-force-exit test/crossnetwork.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.
|
|
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.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",
|
|
109
109
|
"pack": "node scripts/pack.mjs",
|
|
110
110
|
"lint": "eslint .",
|
|
111
111
|
"lint:fix": "eslint . --fix",
|
|
@@ -86,6 +86,49 @@ function sameName(a, b) {
|
|
|
86
86
|
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The stable codes this matcher refuses with.
|
|
91
|
+
*
|
|
92
|
+
* `COMMAND_NOT_ALLOWED` is deliberately the **same literal** the agent writes into a result
|
|
93
|
+
* envelope's `refusal_reason` (`REFUSAL.COMMAND_NOT_ALLOWED` in `src/agent/agent.mjs`) and the same
|
|
94
|
+
* one the plugin's dispatch pre-flight reports: the whole reason this matcher is shared is that the
|
|
95
|
+
* two ends must not be able to disagree about *why* a command was refused, and two spellings of one
|
|
96
|
+
* refusal would be a disagreement of exactly that kind.
|
|
97
|
+
*
|
|
98
|
+
* `COMMAND_ARGV_EMPTY` is the matcher's own precondition. It is a separate code because it is a
|
|
99
|
+
* separate fault — a caller that passed nothing has a bug, not a policy problem — and collapsing it
|
|
100
|
+
* into `COMMAND_NOT_ALLOWED` would send the reader to the allow-list to fix a caller.
|
|
101
|
+
*/
|
|
102
|
+
export const MATCH_REFUSAL = Object.freeze({
|
|
103
|
+
ARGV_EMPTY: 'COMMAND_ARGV_EMPTY',
|
|
104
|
+
NOT_ALLOWED: 'COMMAND_NOT_ALLOWED',
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Why this argv is not covered by this list, and what would change it.
|
|
109
|
+
*
|
|
110
|
+
* The entries are echoed because they are the *only* thing that would make the same call succeed,
|
|
111
|
+
* and a caller reading this deserves them without a second call. They are configuration, never
|
|
112
|
+
* credentials: the parse path refuses a secret-looking key before a list ever reaches here.
|
|
113
|
+
*
|
|
114
|
+
* @param {string[]} argv
|
|
115
|
+
* @param {string[][]} prefixes
|
|
116
|
+
* @returns {string}
|
|
117
|
+
*/
|
|
118
|
+
function allowListMissDetail(argv, prefixes) {
|
|
119
|
+
if (prefixes.length === 0) {
|
|
120
|
+
return (
|
|
121
|
+
'the configured allow-list is empty, so every command is refused; add an entry naming what ' +
|
|
122
|
+
`this machine may run - an entry of \`${argv[0]}\` would allow \`${argv.join(' ')}\``
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const entries = prefixes.map((prefix) => prefix.join(' ')).join(', ');
|
|
126
|
+
return (
|
|
127
|
+
`no configured entry is a prefix of this argv; the allow-list entries are ${entries}. Add one ` +
|
|
128
|
+
`that covers \`${argv.join(' ')}\`, or run a command the list already covers`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
89
132
|
/**
|
|
90
133
|
* Default-deny prefix match.
|
|
91
134
|
*
|
|
@@ -93,13 +136,27 @@ function sameName(a, b) {
|
|
|
93
136
|
* `['node','--test','--reporter=tap']`; `['node','-e','...']` and a bare
|
|
94
137
|
* `['node']` are refused.
|
|
95
138
|
*
|
|
139
|
+
* A refusal is a **named** one: `reason_code` is a stable, greppable code and `detail` says what
|
|
140
|
+
* would change it. `allowed` and `prefix` keep the shape this function has always returned, because
|
|
141
|
+
* both callers read exactly those two fields and the codes are additive.
|
|
142
|
+
*
|
|
96
143
|
* @param {string[]} argv
|
|
97
144
|
* @param {string[][]} prefixes
|
|
98
|
-
* @returns {{allowed: boolean, prefix: string[]|null}}
|
|
145
|
+
* @returns {{allowed: boolean, prefix: string[]|null, reason_code: string|null, detail: string|null}}
|
|
99
146
|
*/
|
|
100
147
|
export function matchAllowedCommand(argv, prefixes) {
|
|
101
|
-
|
|
102
|
-
|
|
148
|
+
const entries = Array.isArray(prefixes) ? prefixes : [];
|
|
149
|
+
if (!Array.isArray(argv) || argv.length === 0) {
|
|
150
|
+
return {
|
|
151
|
+
allowed: false,
|
|
152
|
+
prefix: null,
|
|
153
|
+
reason_code: MATCH_REFUSAL.ARGV_EMPTY,
|
|
154
|
+
detail:
|
|
155
|
+
'there is no argv to compare, and this matcher is default-deny; pass a non-empty array of ' +
|
|
156
|
+
'strings whose first element is the executable',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
for (const prefix of entries) {
|
|
103
160
|
if (!Array.isArray(prefix) || prefix.length === 0) continue;
|
|
104
161
|
if (argv.length < prefix.length) continue;
|
|
105
162
|
if (!sameName(argv[0], prefix[0]) && !sameName(execName(argv[0]), prefix[0])) continue;
|
|
@@ -110,7 +167,12 @@ export function matchAllowedCommand(argv, prefixes) {
|
|
|
110
167
|
break;
|
|
111
168
|
}
|
|
112
169
|
}
|
|
113
|
-
if (ok) return { allowed: true, prefix };
|
|
170
|
+
if (ok) return { allowed: true, prefix, reason_code: null, detail: null };
|
|
114
171
|
}
|
|
115
|
-
return {
|
|
172
|
+
return {
|
|
173
|
+
allowed: false,
|
|
174
|
+
prefix: null,
|
|
175
|
+
reason_code: MATCH_REFUSAL.NOT_ALLOWED,
|
|
176
|
+
detail: allowListMissDetail(argv, entries.filter((prefix) => Array.isArray(prefix) && prefix.length > 0)),
|
|
177
|
+
};
|
|
116
178
|
}
|
package/src/agent/exec.mjs
CHANGED
|
@@ -31,6 +31,46 @@ export const WARN_OUTPUT_TRUNCATED = 'OUTPUT_TRUNCATED';
|
|
|
31
31
|
/** Warning code emitted when the executable could not be started at all. */
|
|
32
32
|
export const WARN_PATH_INVALID = 'PATH_INVALID';
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The stable codes this gate refuses with.
|
|
36
|
+
*
|
|
37
|
+
* These are the last check before `spawn`, and they are what makes one invariant legible: **no shell,
|
|
38
|
+
* ever**. A string command, an empty argv and a non-string element are all refused here, and until
|
|
39
|
+
* now each was a bare `TypeError` with no code — a caller could see *that* the execution gate said no
|
|
40
|
+
* and not *which* rule said it. `code` and `detail` are attached rather than replacing the type, so
|
|
41
|
+
* `assert.throws(..., TypeError)` keeps meaning what it meant and `parseAllowedCommands`' own
|
|
42
|
+
* `err.code = 'ALLOWED_COMMANDS_INVALID'` stays the house convention rather than an exception.
|
|
43
|
+
*/
|
|
44
|
+
export const EXEC_REFUSAL = Object.freeze({
|
|
45
|
+
/**
|
|
46
|
+
* `argv` was not a non-empty array. This is the "no shell string" rule: `runArgv('node --test')`
|
|
47
|
+
* is refused because that string would have to be split by *something* to become a process, and the
|
|
48
|
+
* something would be a shell.
|
|
49
|
+
*/
|
|
50
|
+
ARGV_NOT_ARRAY: 'EXEC_ARGV_NOT_ARRAY',
|
|
51
|
+
/** An element of `argv` was not a string; nothing here coerces, because coercion is a re-parse. */
|
|
52
|
+
ARGV_ENTRY_NOT_STRING: 'EXEC_ARGV_ENTRY_NOT_STRING',
|
|
53
|
+
/** `argv[0]` was the empty string, which names no executable. */
|
|
54
|
+
ARGV_EXECUTABLE_EMPTY: 'EXEC_ARGV_EXECUTABLE_EMPTY',
|
|
55
|
+
/** The process could not be started at all (ENOENT, EACCES, …); the OS code stays in `code`. */
|
|
56
|
+
SPAWN_FAILED: 'EXEC_SPAWN_FAILED',
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build the refusal `runArgv` throws: a real `TypeError`, carrying a stable code and a remedy.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} code One of {@link EXEC_REFUSAL}.
|
|
63
|
+
* @param {string} message What was refused and why it cannot be run.
|
|
64
|
+
* @param {string} detail What would make the same call acceptable.
|
|
65
|
+
* @returns {TypeError}
|
|
66
|
+
*/
|
|
67
|
+
function execRefusal(code, message, detail) {
|
|
68
|
+
const error = new TypeError(message);
|
|
69
|
+
error.code = code;
|
|
70
|
+
error.detail = detail;
|
|
71
|
+
return error;
|
|
72
|
+
}
|
|
73
|
+
|
|
34
74
|
/**
|
|
35
75
|
* Lowercase hex SHA-256 of a Buffer/string.
|
|
36
76
|
*
|
|
@@ -67,7 +107,9 @@ export const EMPTY_SHA256 = sha256Hex(Buffer.alloc(0));
|
|
|
67
107
|
* @property {number} duration_ms Wall-clock duration.
|
|
68
108
|
* @property {boolean} timed_out We killed it because `timeoutMs` elapsed.
|
|
69
109
|
* @property {boolean} cancelled We killed it because the signal aborted.
|
|
70
|
-
* @property {{code: string, message: string}|null} spawn_error
|
|
110
|
+
* @property {{code: string, reason_code: string, message: string}|null} spawn_error
|
|
111
|
+
* `code` is the OS error code (ENOENT, EACCES) or `SPAWN_FAILED`; `reason_code` is this gate's own
|
|
112
|
+
* stable code, so a reader can tell "the execution gate refused" from "the operating system did".
|
|
71
113
|
* @property {string[]} warnings
|
|
72
114
|
*/
|
|
73
115
|
|
|
@@ -143,16 +185,28 @@ function createCollector(maxOutputBytes) {
|
|
|
143
185
|
*/
|
|
144
186
|
export function runArgv(argv, options = {}) {
|
|
145
187
|
if (!Array.isArray(argv) || argv.length === 0) {
|
|
146
|
-
throw
|
|
188
|
+
throw execRefusal(
|
|
189
|
+
EXEC_REFUSAL.ARGV_NOT_ARRAY,
|
|
190
|
+
'runArgv: argv must be a non-empty array of strings',
|
|
191
|
+
'pass the command and its arguments as separate array elements, for example ["node","--test"] - a command line in one string is refused because splitting it would need a shell',
|
|
192
|
+
);
|
|
147
193
|
}
|
|
148
194
|
for (const part of argv) {
|
|
149
195
|
if (typeof part !== 'string') {
|
|
150
|
-
throw
|
|
196
|
+
throw execRefusal(
|
|
197
|
+
EXEC_REFUSAL.ARGV_ENTRY_NOT_STRING,
|
|
151
198
|
`runArgv: argv entries must be strings, got ${part === null ? 'null' : typeof part}`,
|
|
199
|
+
'stringify the argument at the caller, so this gate never has to re-parse a value it did not receive',
|
|
152
200
|
);
|
|
153
201
|
}
|
|
154
202
|
}
|
|
155
|
-
if (argv[0] === '')
|
|
203
|
+
if (argv[0] === '') {
|
|
204
|
+
throw execRefusal(
|
|
205
|
+
EXEC_REFUSAL.ARGV_EXECUTABLE_EMPTY,
|
|
206
|
+
'runArgv: argv[0] must not be empty',
|
|
207
|
+
'name the executable in argv[0], for example "node" or an absolute path to it',
|
|
208
|
+
);
|
|
209
|
+
}
|
|
156
210
|
|
|
157
211
|
const {
|
|
158
212
|
cwd = process.cwd(),
|
|
@@ -265,6 +319,7 @@ export function runArgv(argv, options = {}) {
|
|
|
265
319
|
} catch (error) {
|
|
266
320
|
finish(null, null, {
|
|
267
321
|
code: error?.code ?? 'SPAWN_FAILED',
|
|
322
|
+
reason_code: EXEC_REFUSAL.SPAWN_FAILED,
|
|
268
323
|
message: String(error?.message ?? error),
|
|
269
324
|
});
|
|
270
325
|
return;
|
|
@@ -276,6 +331,7 @@ export function runArgv(argv, options = {}) {
|
|
|
276
331
|
child.on('error', (error) => {
|
|
277
332
|
finish(null, null, {
|
|
278
333
|
code: error?.code ?? 'SPAWN_FAILED',
|
|
334
|
+
reason_code: EXEC_REFUSAL.SPAWN_FAILED,
|
|
279
335
|
message: String(error?.message ?? error),
|
|
280
336
|
});
|
|
281
337
|
});
|