@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 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.5] — 2026-10-11
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
- * **The v0.5.4 loader failure, with the cause the loader actually reported** rather than the hypothesis
12
- that stood in for it:
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
- cordis throws on reading a service property the fiber never declared, so the entry died and took all
20
- eight tools with it. **Every existing test was blind to this**, because they drive the plugin with a
21
- plain-object context where an undeclared read merely yields `undefined` — the fake was more forgiving
22
- than the runtime. Fixed with `ctx.inject(['connection'], …)`, which is also the only shape that can
23
- work: in the real composition the service is genuinely absent while `apply()` runs, and siblings are
24
- mounted concurrently.
25
- * **The client half was never composed either, for a second and independent reason:** the bundle row
26
- named the subpath `@twinsearth/w2m-dsh-plugin/tools` instead of the package, and a client package is
27
- identified by its Loader row specifier — a subpath resolves to no `package.json`, so `dsh.client` was
28
- never read and no boot-graph row was built. The row now names the package.
29
- * **A silent route rejection:** the control fetched `/w2m/target`, but fetch routes are validated
30
- against `/api/`, and the refusal failed only a child fiber, so nothing surfaced. Now
31
- `/api/w2m/target`, mirrored in the shipped bundle.
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
- ### Added
99
+ ### Fixed
34
100
 
35
- * **`test/loader-entry.test.mjs`** — the test that would have caught v0.5.4: it packs the tree, installs
36
- the tarball with the installation's own `dsh plugin add` into a throwaway profile under a throwaway
37
- `DSH_HOME`, and boots it with the real `runProfile` under the Harness runtime, asserting that the
38
- entry activates, all eight tools are registered, the boot-graph row is composed from the shipped
39
- bundle, and no failed entry names the package. **Proved in both directions**: 6/6 pass on the fixed
40
- tree and 6/6 fail against the preserved v0.5.4 tarball, failing on precisely the loader error quoted
41
- above. It skips loudly, naming the probes it tried, when no installation is found.
42
- * Cheap always-on guards in `test/client-bundle.test.mjs` that need no Harness: the platform field, the
43
- client export, the row must be the bare package name, and the bundle's route must equal the host's.
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
- * **An upgrade from v0.5.4 requires one edit outside this package:** `cordis.patch.yml` in the profile
48
- must name the package (`@twinsearth/w2m-dsh-plugin`), not the `/tools` subpath. With the corrected
49
- bundle row, a mismatch makes the loader skip the whole patch — silently reverting `rabbitUrl`,
50
- `operatorToken`, `stateDir`, `projectDir`, `machineName`, `p2pMode` and `allowedCommands` to their
51
- shipped defaults. Measured both ways with `--dump-config`.
52
- * **0.5.4 remains DO NOT INSTALL.** This release exists so that a rebuilt artifact is distinguishable
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.7",
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.mjstest/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs",
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.mjstest/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs",
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
- if (!Array.isArray(argv) || argv.length === 0) return { allowed: false, prefix: null };
102
- for (const prefix of prefixes ?? []) {
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 { allowed: false, prefix: null };
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
  }
@@ -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 new TypeError('runArgv: argv must be a non-empty array of strings');
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 new TypeError(
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] === '') throw new TypeError('runArgv: argv[0] must not be empty');
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
  });