@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.6

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +1288 -0
  2. package/LICENSE +21 -0
  3. package/PROTOCOL-v0.1.2.md +165 -0
  4. package/PROTOCOL-v0.3.0.md +454 -0
  5. package/PROTOCOL-v0.3.9.md +207 -0
  6. package/PROTOCOL-v0.4.0.md +255 -0
  7. package/PROTOCOL.md +339 -0
  8. package/README.md +665 -2
  9. package/bin/w2m-localside.mjs +303 -0
  10. package/bin/w2m-rabbit.mjs +396 -0
  11. package/bin/w2m-stun.mjs +231 -0
  12. package/cordis.patch.yml +94 -0
  13. package/deploy/caddy/Caddyfile.example +118 -0
  14. package/deploy/docker/Dockerfile +144 -0
  15. package/deploy/docker/README.md +338 -0
  16. package/deploy/docker/docker-compose.yml +179 -0
  17. package/deploy/nginx/w2m.conf.example +196 -0
  18. package/deploy/shared-server/README.md +118 -0
  19. package/deploy/shared-server/install.sh +255 -0
  20. package/deploy/systemd/w2m-rabbit.service +150 -0
  21. package/deploy/systemd/w2m-stun.service +69 -0
  22. package/deploy/windows/README.md +115 -0
  23. package/deploy/windows/install-service.ps1 +195 -0
  24. package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
  25. package/deploy/windows/winsw/w2m-localside.xml +50 -0
  26. package/docs/DEPLOY.md +902 -0
  27. package/docs/LINT-AUDIT.md +492 -0
  28. package/docs/PIPELINE-DESIGN.md +95 -0
  29. package/docs/TROUBLESHOOTING.md +560 -0
  30. package/docs/UI-CARDS.md +81 -0
  31. package/lib/index.js +13 -0
  32. package/lib/tools.js +11 -0
  33. package/package.json +133 -4
  34. package/src/agent/agent.mjs +3317 -0
  35. package/src/agent/allowed-commands.mjs +116 -0
  36. package/src/agent/caps.mjs +437 -0
  37. package/src/agent/exec.mjs +356 -0
  38. package/src/agent/git.mjs +616 -0
  39. package/src/agent/identity.mjs +253 -0
  40. package/src/agent/p2p-node.mjs +1243 -0
  41. package/src/agent/p2p.mjs +605 -0
  42. package/src/agent/resolve.mjs +148 -0
  43. package/src/agent/spool.mjs +252 -0
  44. package/src/agent/stun.mjs +397 -0
  45. package/src/agent/url.mjs +127 -0
  46. package/src/plugin/auto-update.mjs +574 -0
  47. package/src/plugin/schedule.mjs +452 -0
  48. package/src/plugin/shared-config.mjs +264 -0
  49. package/src/plugin/tools.mjs +3891 -0
  50. package/src/plugin/update-install.mjs +764 -0
  51. package/src/plugin/update-source.mjs +686 -0
  52. package/src/relay/metrics.mjs +284 -0
  53. package/src/relay/peers.mjs +180 -0
  54. package/src/relay/persistence.mjs +207 -0
  55. package/src/relay/report.mjs +591 -0
  56. package/src/relay/server.mjs +1187 -0
  57. package/src/relay/state.mjs +2004 -0
  58. package/src/relay/stun-server.mjs +733 -0
  59. package/src/signing.mjs +359 -0
  60. package/src/util/cli.mjs +70 -0
@@ -0,0 +1,94 @@
1
+ # This file is the bundle patch declared by package.json's `dsh.bundle.patch`.
2
+ # It mounts the plugin explicitly at `./tools` rather than relying on a
3
+ # "root export is the plugin" convention, so reading this file tells you which
4
+ # module of the package becomes the plugin.
5
+ #
6
+ # ---------------------------------------------------------------------------
7
+ # Why this is an `insert:` list and not a bare `- id: w2m`
8
+ # ---------------------------------------------------------------------------
9
+ # A bare `- id: w2m` entry is a *non-insert* patch: it replaces fields on an
10
+ # entry that an earlier layer already created. No earlier layer creates `w2m`,
11
+ # so the Loader reports
12
+ #
13
+ # dsh: [@twinsearth/w2m-dsh-plugin] patch: entry "w2m" not found
14
+ #
15
+ # and skips it. Measured with DSH 0.2.0-rc.2 (`--dump-config-schema`, $defs.patch):
16
+ #
17
+ # "An insert appends entries, optionally inside the group identified by id.
18
+ # Other patches replace supplied fields; ... Unknown targets and non-insert
19
+ # patches without a nonempty id are warned and skipped."
20
+ #
21
+ # The consequence of the old shape was silent: `dsh plugin add` succeeded, the
22
+ # profile listed the bundle, DSH started cleanly -- and **no `w2m` row was ever
23
+ # composed**, so none of the eight tools existed. `--dump-config` showed 183 rows
24
+ # and zero of them named this package.
25
+ #
26
+ # ---------------------------------------------------------------------------
27
+ # Configuring the relay URL, and the direct path (v0.4.0)
28
+ # ---------------------------------------------------------------------------
29
+ # The relay URL has a default as of v0.4.0: the shared W2M server, which hosts the
30
+ # relay, a STUN server and a reverse proxy on one address. Leaving `rabbitUrl` empty
31
+ # therefore means "use the shared server", not "guess" -- and `w2m_status` reports
32
+ # `rabbit_source: "shared-default"` with a note naming the address, so a machine
33
+ # talking to somebody else's host can never be a surprise.
34
+ #
35
+ # The order is `rabbitUrl` -> `W2M_RABBIT_URL` -> the shared server, and an explicit
36
+ # value always wins. Set one of the first two to run against your own relay:
37
+ #
38
+ # - id: w2m
39
+ # config:
40
+ # rabbitUrl: https://w2m.example.com # may include a sub-path
41
+ # operatorToken: !!js process.env.W2M_OPERATOR_TOKEN
42
+ # stateDir: !!js (process.env.DSH_HOME + '/xclient')
43
+ #
44
+ # `operatorToken` is the credential that authorises *sending* instructions. It is
45
+ # separate from the per-machine device token on purpose: a machine that can receive
46
+ # work should not automatically be able to command the others. The relay writes it to
47
+ # <state>/operator-token.txt on first start and prints it once.
48
+ #
49
+ # `p2pMode` decides how a dispatched offer travels, and it is the release's headline
50
+ # switch, so every value is validated at load and a typo is refused rather than
51
+ # defaulted:
52
+ #
53
+ # auto (default) punch the direct path and push the offer over it; the relay's
54
+ # own offer on the SSE stream stays as the fallback, so a failed punch is
55
+ # invisible to the caller except in `direct_pushes` and each machine's
56
+ # `transport`. This is what `W2M_P2P_MODE` unset gives you.
57
+ # direct the same push, but a failed punch is not hidden: the executor refuses an
58
+ # offer that did not arrive over the direct path
59
+ # (`refusal_reason: P2P_UNAVAILABLE`), so a fleet that must use the fast
60
+ # path fails loudly instead of quietly falling back.
61
+ # relay no UDP socket is bound at all and nothing is dialled -- exactly v0.3.9's
62
+ # behaviour. Use it to take the direct path out of the picture entirely
63
+ # while diagnosing something else.
64
+ #
65
+ # - id: w2m
66
+ # config:
67
+ # p2pMode: relay # or W2M_P2P_MODE=relay
68
+ #
69
+ # `stunServers` is the list used to discover the address a peer can reach this machine
70
+ # on. Unset means the shared server first, then three public fallbacks -- the shared
71
+ # server is asked first on purpose, because a machine that can reach the relay can
72
+ # reach that STUN server, so the address it reports is measured over the same path the
73
+ # punch will use. Naming servers replaces that list, so give the shared one explicitly
74
+ # if you still want the relay's own path measured:
75
+ #
76
+ # - id: w2m
77
+ # config:
78
+ # stunServers: ['202.182.123.154:3478', 'stun.example.com:3478']
79
+ # # or W2M_STUN_SERVERS=a:3478,b:3478
80
+ #
81
+ # A loopback punch (both machines on one host) exercises the code path and says nothing
82
+ # about NAT traversal; `w2m_status` reports the measured NAT mapping so the difference
83
+ # is visible rather than assumed.
84
+ - insert:
85
+ - id: w2m
86
+ name: '@twinsearth/w2m-dsh-plugin/tools'
87
+ config:
88
+ rabbitUrl: ''
89
+ operatorToken: ''
90
+ autoStartAgent: false
91
+ machineName: ''
92
+ allowedCommands: []
93
+ p2pMode: auto
94
+ stunServers: []
@@ -0,0 +1,118 @@
1
+ # ============================================================================
2
+ # W2M relay behind Caddy -- example configuration
3
+ # ============================================================================
4
+ #
5
+ # This file is a TEMPLATE. Replace every <placeholder> before using it.
6
+ #
7
+ # Validated how: caddy is not installed on the authoring machine, so this file
8
+ # was NOT checked with `caddy validate`. Run that on the server before
9
+ # reloading -- docs/DEPLOY.md gives the exact commands.
10
+ #
11
+ # ---------------------------------------------------------------------------
12
+ # WHY CADDY NEEDS ONE EXPLICIT SETTING FOR SSE
13
+ # ---------------------------------------------------------------------------
14
+ # Caddy 2 enables automatic HTTPS (issuance + renewal) with no extra
15
+ # configuration, which is its main advantage over nginx here. It also buffers
16
+ # responses by default, like nginx -- but unlike nginx it does NOT honour the
17
+ # relay's `X-Accel-Buffering: no` header, because that header is an
18
+ # nginx-specific convention.
19
+ #
20
+ # So the setting that matters is:
21
+ #
22
+ # flush_interval -1
23
+ #
24
+ # Without it the browser connects to /v1/stream, receives no events, and the
25
+ # UI looks "connected but dead" -- the same failure `proxy_buffering off`
26
+ # prevents under nginx.
27
+ #
28
+ # Caddy handles HTTP/1.1 and HTTP/2 correctly by itself, so the other two
29
+ # nginx-side SSE requirements have no Caddy equivalent.
30
+ #
31
+ # ---------------------------------------------------------------------------
32
+ # SHAPE 1: served at the root of a dedicated hostname (recommended).
33
+ # Replace <your-domain> with a name whose A/AAAA record points at this server.
34
+ # Relay flags: no --base-path, no --tls-cert/--tls-key (Caddy terminates TLS).
35
+ # ============================================================================
36
+
37
+ <your-domain> {
38
+ # ---- the event stream: this route alone needs flush_interval ----------
39
+ @stream path /v1/stream
40
+ handle @stream {
41
+ reverse_proxy 127.0.0.1:8787 {
42
+ # Disable response buffering: frames must reach the client as
43
+ # they are written. This is the Caddy equivalent of nginx's
44
+ # `proxy_buffering off`, and it is the single most important
45
+ # line in this file.
46
+ flush_interval -1
47
+
48
+ # The stream is long-lived and quiet between the relay's 15s
49
+ # keepalive comments, so let it stay open indefinitely.
50
+ transport http {
51
+ read_timeout 0
52
+ write_timeout 0
53
+ }
54
+ }
55
+ }
56
+
57
+ # ---- health check, unauthenticated on purpose -------------------------
58
+ handle /healthz {
59
+ reverse_proxy 127.0.0.1:8787
60
+ }
61
+
62
+ # ---- everything else --------------------------------------------------
63
+ handle {
64
+ reverse_proxy 127.0.0.1:8787 {
65
+ flush_interval -1
66
+ }
67
+ }
68
+
69
+ # ---- logging ----------------------------------------------------------
70
+ # Off by default in Caddy. Turn it on while bringing a deployment up: it is
71
+ # the fastest way to see whether a client reached /v1/stream, and whether
72
+ # Caddy itself (not the relay) answered 401/403/404.
73
+ log {
74
+ output file /var/log/caddy/w2m-access.log {
75
+ roll_size 10MiB
76
+ roll_keep 5
77
+ }
78
+ format json
79
+ }
80
+ }
81
+
82
+ # ============================================================================
83
+ # SHAPE 2: served under a sub-path, e.g. https://<your-domain>/w2m/
84
+ # ============================================================================
85
+ # Uncomment and adapt when the relay must share a hostname with something else.
86
+ #
87
+ # Start the relay with `--base-path /w2m` and forward the prefix UNCHANGED.
88
+ # Caddy's `handle_path` strips the prefix -- using it here is the classic
89
+ # mistake and produces 404 on every /v1/* call. `handle` preserves the path.
90
+ #
91
+ # Client `rabbitUrl` must carry the same prefix:
92
+ # https://<your-domain>/w2m
93
+ # ============================================================================
94
+
95
+ # <your-domain> {
96
+ # @stream path /w2m/v1/stream
97
+ # handle @stream {
98
+ # reverse_proxy 127.0.0.1:8787 {
99
+ # flush_interval -1
100
+ # transport http {
101
+ # read_timeout 0
102
+ # write_timeout 0
103
+ # }
104
+ # }
105
+ # }
106
+ #
107
+ # # The relay answers /healthz both with and without the prefix, so a probe
108
+ # # on either path keeps working; this keeps the bare one reachable.
109
+ # handle /healthz {
110
+ # reverse_proxy 127.0.0.1:8787
111
+ # }
112
+ #
113
+ # handle /w2m/* {
114
+ # reverse_proxy 127.0.0.1:8787 {
115
+ # flush_interval -1
116
+ # }
117
+ # }
118
+ # }
@@ -0,0 +1,144 @@
1
+ # ============================================================================
2
+ # W2M relay (Rabbit) — multi-architecture container image
3
+ # ============================================================================
4
+ #
5
+ # Build from the repository root (the COPY paths are relative to the context):
6
+ #
7
+ # docker build -f deploy/docker/Dockerfile -t w2m-relay:0.3.4 .
8
+ #
9
+ # Multi-arch, without needing the target hardware:
10
+ #
11
+ # docker buildx build --platform linux/amd64,linux/arm64 \
12
+ # -f deploy/docker/Dockerfile -t w2m-relay:0.3.4 .
13
+ #
14
+ # ---------------------------------------------------------------------------
15
+ # ZERO RUNTIME DEPENDENCIES — this is the reason the image is so small
16
+ # ---------------------------------------------------------------------------
17
+ # The relay declares `"dependencies": {}` and genuinely has none. Its complete
18
+ # import closure is 7 files / ~175 KiB and resolves to exactly seven node:
19
+ # builtins:
20
+ #
21
+ # bin/w2m-rabbit.mjs -> src/relay/server.mjs, src/util/cli.mjs
22
+ # src/relay/server.mjs -> state.mjs, persistence.mjs, report.mjs, ../signing.mjs
23
+ # src/relay/{state,report,persistence}.mjs -> state.mjs
24
+ # builtins: node:crypto node:fs node:http node:https node:os node:path node:util
25
+ #
26
+ # Therefore **no package manager runs in this image** — there is no `npm
27
+ # install`, no `npm ci`, no lockfile to go stale and no registry access at
28
+ # build time. If a future change needs an npm package, that would break a core
29
+ # project promise and should be raised, not added here.
30
+ #
31
+ # `lib/` is deliberately absent: it is the DSH *plugin* entry
32
+ # (`package.json` "main": "lib/index.js") and the relay CLI never imports it.
33
+ # `cordis.patch.yml` is likewise plugin-host configuration, not relay runtime.
34
+ #
35
+ # ---------------------------------------------------------------------------
36
+ # PINNING
37
+ # ---------------------------------------------------------------------------
38
+ # The base tag is pinned to an exact patch release, not `22` or `latest`: a
39
+ # floating tag makes two builds of the same commit produce different images.
40
+ #
41
+ # `node:22.23.3-bookworm-slim` is the current 22.x LTS line (v22.23.3 released
42
+ # 2026-09-23, verified against https://nodejs.org/dist/index.json). The *tag's
43
+ # existence in the registry* could not be checked from the authoring machine —
44
+ # see deploy/docker/README.md, "What was verified".
45
+ #
46
+ # For byte-for-byte reproducibility, pin the digest instead. Resolve it with:
47
+ # docker buildx imagetools inspect node:22.23.3-bookworm-slim
48
+ # and replace the FROM line with `node:22.23.3-bookworm-slim@sha256:<digest>`.
49
+ # Note a digest names ONE platform, so a multi-arch build needs the per-arch
50
+ # digests from the manifest list.
51
+ # ============================================================================
52
+
53
+ FROM node:22.23.3-bookworm-slim
54
+
55
+ # Fixed uid/gid so a bind-mounted state directory has predictable ownership on
56
+ # the host (a random uid would make `ls -l` on the host meaningless).
57
+ ARG W2M_UID=10001
58
+ ARG W2M_GID=10001
59
+
60
+ # `production` keeps any transitive tooling quiet and is honest about intent:
61
+ # nothing in this image is a dev dependency.
62
+ ENV NODE_ENV=production
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Runtime payload — exactly the import closure above, nothing else.
66
+ # ---------------------------------------------------------------------------
67
+ WORKDIR /app
68
+
69
+ # `--chown` takes the numeric uid/gid, which is portable (BuildKit and the
70
+ # classic builder both accept it) and avoids a separate `RUN chown -R /app`
71
+ # layer that would duplicate every file's bytes.
72
+ COPY --chown=10001:10001 bin/w2m-rabbit.mjs /app/bin/w2m-rabbit.mjs
73
+ COPY --chown=10001:10001 src/relay/ /app/src/relay/
74
+ COPY --chown=10001:10001 src/util/ /app/src/util/
75
+ COPY --chown=10001:10001 src/signing.mjs /app/src/signing.mjs
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Unprivileged user
79
+ # ---------------------------------------------------------------------------
80
+ # The uid/gid above are pinned by ARG, so keep the numbers in the COPY lines
81
+ # equal to the defaults here. (Docker cannot interpolate an ARG inside --chown,
82
+ # so if you change W2M_UID/W2M_GID you must change these four lines too.)
83
+ RUN groupadd --gid "${W2M_GID}" w2m \
84
+ && useradd --uid "${W2M_UID}" --gid "${W2M_GID}" --no-create-home --shell /usr/sbin/nologin w2m \
85
+ && mkdir -p /var/lib/w2m \
86
+ && chown "${W2M_UID}:${W2M_GID}" /var/lib/w2m
87
+
88
+ USER w2m
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # State
92
+ # ---------------------------------------------------------------------------
93
+ # devices.json (device tokens), ledger.jsonl (task ledger) and
94
+ # operator-token.txt (0600) live here. Losing this volume means every paired
95
+ # machine must pair again, so it is a VOLUME rather than image content.
96
+ #
97
+ # /var/lib/w2m is also the relay's $HOME, which is where it falls back to
98
+ # `$HOME/.dsh/xclient/rabbit` when `--state` is not given — so the volume covers
99
+ # the default and the explicit path alike.
100
+ ENV HOME=/var/lib/w2m
101
+ VOLUME ["/var/lib/w2m"]
102
+
103
+ EXPOSE 8787
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Health check
107
+ # ---------------------------------------------------------------------------
108
+ # GET /healthz needs NO credential, which is exactly why it is the right probe
109
+ # here: the container must be able to report itself healthy without holding the
110
+ # operator token (that token authorises SENDING work and must not leak into
111
+ # image configuration).
112
+ #
113
+ # `node -e` is used instead of curl/wget so the image needs no extra packages —
114
+ # keeping the "install nothing" property intact through the health check too.
115
+ #
116
+ # The probe is deliberately hard-coded to 8787. The container's listen port is
117
+ # fixed at 8787 and the *host* port is what varies (see docker-compose.yml), so
118
+ # this probe never needs to change. If you override the command's --port, you
119
+ # must override this HEALTHCHECK to match.
120
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
121
+ CMD ["node", "-e", "fetch('http://127.0.0.1:8787/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # How it starts
125
+ # ---------------------------------------------------------------------------
126
+ # `--host 0.0.0.0` is correct HERE and only here. Inside a container the loopback
127
+ # interface is unreachable from outside its network namespace, so a loopback
128
+ # bind would make the published port useless. Exposure is controlled by the port
129
+ # *mapping* instead — docker-compose.yml binds 127.0.0.1 on the host by default,
130
+ # so nothing is published to the outside world until the operator says so.
131
+ #
132
+ # TLS terminates in front of this container (nginx/Caddy on the host, or a
133
+ # tunnel), which is why the default invocation carries no --tls-cert/--tls-key:
134
+ # the relay behind a proxy speaks plain HTTP over a loopback-only VPS hop.
135
+ # `--trust-proxy` is NOT in the default command on purpose — turn it on only
136
+ # when a proxy you control really is in front, because with it on the relay
137
+ # believes X-Forwarded-For and a directly-reachable client could forge its
138
+ # address past the pairing rate limit.
139
+ #
140
+ # Add flags per deployment shape, e.g. behind nginx/Caddy:
141
+ # docker run ... w2m-relay:0.3.4 --host 0.0.0.0 --port 8787 \
142
+ # --state /var/lib/w2m --trust-proxy
143
+ ENTRYPOINT ["node", "/app/bin/w2m-rabbit.mjs"]
144
+ CMD ["--host", "0.0.0.0", "--port", "8787", "--state", "/var/lib/w2m"]