@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,150 @@
1
+ # ============================================================================
2
+ # w2m-rabbit.service -- systemd unit for the W2M relay
3
+ # ============================================================================
4
+ #
5
+ # This file is a TEMPLATE. Replace the @PLACEHOLDERS@ before installing.
6
+ #
7
+ # Install (Linux server, systemd):
8
+ # sudo useradd --system --home /var/lib/w2m --shell /usr/sbin/nologin w2m
9
+ # sudo install -d -o w2m -g w2m -m 0750 /var/lib/w2m
10
+ # sudo install -d -o w2m -g w2m -m 0750 /etc/w2m
11
+ # sudo install -m 0644 deploy/systemd/w2m-rabbit.service \
12
+ # /etc/systemd/system/w2m-rabbit.service
13
+ # sudo systemctl daemon-reload
14
+ # sudo systemctl enable --now w2m-rabbit
15
+ # journalctl -u w2m-rabbit -f # watch the pairing code / operator token
16
+ #
17
+ # Validated how: systemd is not available on the authoring machine, so this
18
+ # file was NOT checked with `systemd-analyze verify`. It follows the unit
19
+ # directives' documented semantics; docs/DEPLOY.md names the exact command to
20
+ # run before trusting it.
21
+ #
22
+ # ---------------------------------------------------------------------------
23
+ # WHAT THIS UNIT DELIBERATELY DOES
24
+ # ---------------------------------------------------------------------------
25
+ # * Binds 127.0.0.1, never 0.0.0.0. In every supported shape something else
26
+ # terminates TLS (nginx/Caddy/tunnel) or the network is already encrypted
27
+ # (Tailscale/WireGuard), so the relay must not be reachable directly.
28
+ # * Keeps state in /var/lib/w2m via StateDirectory=, so devices and the task
29
+ # ledger survive restarts. Without this, every restart forces every machine
30
+ # to pair again -- see docs/TROUBLESHOOTING.md, "everything needs re-pairing".
31
+ # * Writes nothing to the user's home and needs no writable directory except
32
+ # its state dir and /run for the pairing-code file.
33
+ # * Restarts on failure but not in a tight loop (RestartSec + StartLimit*).
34
+ # ============================================================================
35
+
36
+ [Unit]
37
+ Description=W2M relay (Rabbit) - task relay for cross-machine execution
38
+ Documentation=https://github.com/TwinsEarth/dsh-windows2macos/blob/main/docs/DEPLOY.md
39
+ # Start after the network is up; the relay itself does not need DNS, but
40
+ # ordering here keeps journal timestamps meaningful on a cold boot.
41
+ After=network-online.target
42
+ Wants=network-online.target
43
+ # If the relay sits behind a reverse proxy on the same host, uncomment so the
44
+ # proxy outlives a relay restart without logging connection errors:
45
+ # After=nginx.service
46
+
47
+ [Service]
48
+ Type=simple
49
+ User=w2m
50
+ Group=w2m
51
+
52
+ # --- state -----------------------------------------------------------------
53
+ # systemd creates /var/lib/w2m owned by User/Group.
54
+ # `%S` is the *state root* (/var/lib), so `--state %S/w2m` is /var/lib/w2m --
55
+ # the same directory StateDirectory= names. Using the full `%S/w2m` path rather
56
+ # than $STATE_DIRECTORY keeps the flag self-explanatory in `ps` output.
57
+ StateDirectory=w2m
58
+ StateDirectoryMode=0750
59
+ Environment=DSH_HOME=/var/lib/w2m
60
+ # Files created by the relay (operator-token.txt is written 0600, devices.json
61
+ # and ledger.jsonl hold device tokens) must not be world-readable.
62
+ UMask=0077
63
+
64
+ # --- relay flags -----------------------------------------------------------
65
+ # Keep the relay on loopback and let the proxy/tunnel terminate TLS.
66
+ #
67
+ # Shape A (Tailscale): change the bind address to the tailnet address and read
68
+ # the "Shape A" section of docs/DEPLOY.md instead; the relay must be
69
+ # reachable from the tailnet, and 127.0.0.1 is not.
70
+ #
71
+ # Shape B (VPS + TLS): the relay stays here on loopback; nginx/Caddy owns 443.
72
+ # If you prefer the relay to terminate TLS itself, replace the two flags
73
+ # below with --tls-cert / --tls-key and give it a --port clients can reach,
74
+ # then delete the reverse proxy. Pick one, never both.
75
+ #
76
+ # Shape C (tunnel): the tunnel client runs as its own unit (see docs) and
77
+ # dials 127.0.0.1:8787, so this line does not change. If the tunnel publishes
78
+ # the relay under a prefix, add --base-path here AND keep the two in sync.
79
+ ExecStart=@RELAY_BIN@ --host 127.0.0.1 --port 8787 --state %S/w2m
80
+ # Extra flags (uncomment what the deployment shape needs):
81
+ # --base-path /w2m # only when the tunnel/proxy forwards the prefix
82
+ # --trust-proxy # only behind a proxy you control
83
+ # --pair-rate-limit 5 # default; raise only on a trusted network
84
+ # --operator-token-file %S/w2m/operator-token.txt # if the CLI supports it
85
+ #
86
+ # Multi-line form when the flag list grows (systemd joins lines ending in \):
87
+ # ExecStart=@RELAY_BIN@ \
88
+ # --host 127.0.0.1 \
89
+ # --port 8787 \
90
+ # --state %S/w2m \
91
+ # --base-path /w2m \
92
+ # --trust-proxy
93
+
94
+ # --- secrets ---------------------------------------------------------------
95
+ # Do NOT put the operator token in this file: the unit is world-readable and
96
+ # the token authorises SENDING work to every paired machine.
97
+ # Preferred: let the relay generate one and read /var/lib/w2m/operator-token.txt.
98
+ # If you must inject your own, keep it in an EnvironmentFile with mode 0600
99
+ # owned by root, and let systemd read it:
100
+ # EnvironmentFile=-/etc/w2m/relay.env # W2M_OPERATOR_TOKEN=...
101
+ # (the leading '-' means "start anyway if the file is missing")
102
+ #
103
+ # NOTE: the relay prints the operator token and the pairing code to stdout, so
104
+ # they end up in the journal. For a public deployment, restrict journal access
105
+ # (sudo usermod -aG systemd-journal <user>) or send them elsewhere.
106
+
107
+ # --- restart policy --------------------------------------------------------
108
+ Restart=on-failure
109
+ RestartSec=3
110
+ # Give up only after repeated fast failures rather than flapping forever.
111
+ StartLimitIntervalSec=60
112
+ StartLimitBurst=5
113
+ # The relay handles SIGTERM by closing the server and exiting 0; give it time
114
+ # to let clients see a clean disconnect.
115
+ TimeoutStopSec=15
116
+ KillSignal=SIGTERM
117
+
118
+ # --- hardening -------------------------------------------------------------
119
+ # The relay needs only: loopback sockets, its state dir, and enough of the
120
+ # filesystem to load its own JS. Everything else is denied.
121
+ NoNewPrivileges=yes
122
+ PrivateTmp=yes
123
+ ProtectSystem=strict
124
+ ProtectHome=yes
125
+ ProtectKernelTunables=yes
126
+ ProtectKernelModules=yes
127
+ ProtectControlGroups=yes
128
+ RestrictSUIDSGID=yes
129
+ RestrictNamespaces=yes
130
+ LockPersonality=yes
131
+ MemoryDenyWriteExecute=no
132
+ # Loopback + (for shape A) the tailnet interface. AF_UNIX is needed by the
133
+ # Node runtime for internal sockets on some builds; AF_INET/AF_INET6 carry the
134
+ # relay itself.
135
+ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
136
+ SystemCallFilter=@system-service
137
+ SystemCallErrorNumber=EPERM
138
+ # ProtectSystem=strict makes everything read-only except paths named here.
139
+ ReadWritePaths=/var/lib/w2m
140
+
141
+ # --- logs ------------------------------------------------------------------
142
+ StandardOutput=journal
143
+ StandardError=journal
144
+ SyslogIdentifier=w2m-rabbit
145
+ # Uncomment to make the startup banner (pairing code, operator token) land in a
146
+ # file an operator can read without journal access. Keep that file 0600.
147
+ # StandardOutput=append:/var/lib/w2m/relay.log
148
+
149
+ [Install]
150
+ WantedBy=multi-user.target
@@ -0,0 +1,69 @@
1
+ # ============================================================================
2
+ # w2m-stun.service -- systemd unit for the W2M STUN responder
3
+ # ============================================================================
4
+ #
5
+ # This file is a TEMPLATE. Replace the @PLACEHOLDERS@ before installing.
6
+ #
7
+ # Install (Linux server, systemd):
8
+ # sudo install -m 0644 deploy/systemd/w2m-stun.service \
9
+ # /etc/systemd/system/w2m-stun.service
10
+ # sudo systemctl daemon-reload
11
+ # sudo systemctl enable --now w2m-stun
12
+ # journalctl -u w2m-stun -f
13
+ #
14
+ # Anything that has to reach it needs UDP :3478 open -- `ufw allow 3478/udp`.
15
+ # There is nothing to open for TCP: STUN is UDP only in this implementation.
16
+ #
17
+ # ---------------------------------------------------------------------------
18
+ # WHAT THIS UNIT DELIBERATELY DOES
19
+ # ---------------------------------------------------------------------------
20
+ # * Binds 0.0.0.0. A STUN server that only listened on loopback would discover
21
+ # nothing: the whole answer it gives is "the address and port the NAT gave
22
+ # you", which is only meaningful when the query arrives from outside.
23
+ # * Holds no state and writes nothing. It is the one W2M service that can be
24
+ # restarted at any time without a single client noticing anything except a
25
+ # lost query, so it gets no StateDirectory and no writable path at all.
26
+ # * Runs as the same unprivileged `w2m` user as the relay, because 3478 is
27
+ # above 1024 and no capability is needed.
28
+ # * Restarts on failure but not in a tight loop (RestartSec + StartLimit*).
29
+ #
30
+ # NOT verified: `systemd-analyze verify` was not run (systemd is unavailable on
31
+ # the authoring machine). The unit that shipped is the one in this repository's
32
+ # own deployment script, and docs/DEPLOY.md names the command to check it.
33
+ # ============================================================================
34
+
35
+ [Unit]
36
+ Description=W2M STUN responder - reflexive address discovery for P2P hole punching
37
+ Documentation=https://github.com/TwinsEarth/dsh-windows2macos/blob/main/docs/DEPLOY.md
38
+ After=network-online.target
39
+ Wants=network-online.target
40
+
41
+ [Service]
42
+ Type=simple
43
+ User=w2m
44
+ Group=w2m
45
+
46
+ # --- the listener ----------------------------------------------------------
47
+ # 3478 is the IANA-registered STUN port. Machines are pointed at it by
48
+ # `stunServers`, whose default already lists the shared server first.
49
+ ExecStart=/usr/local/bin/node /opt/w2m/app/bin/w2m-stun.mjs --host 0.0.0.0 --port 3478 --log-level info
50
+
51
+ Restart=always
52
+ RestartSec=2
53
+ StartLimitIntervalSec=60
54
+ StartLimitBurst=5
55
+
56
+ # --- hardening -------------------------------------------------------------
57
+ # No filesystem access is needed at all: no state, no log file (journal only).
58
+ NoNewPrivileges=true
59
+ PrivateTmp=true
60
+ ProtectSystem=strict
61
+ ProtectHome=true
62
+ ReadWritePaths=
63
+ ProtectKernelTunables=true
64
+ ProtectControlGroups=true
65
+ RestrictAddressFamilies=AF_INET AF_INET6
66
+ RestrictNamespaces=true
67
+
68
+ [Install]
69
+ WantedBy=multi-user.target
@@ -0,0 +1,115 @@
1
+ # Windows: running the agent as a service
2
+
3
+ The agent must run without a console, start with the machine, and survive sign-out.
4
+ Three shapes do that on Windows, and they differ in one thing that matters: **whether
5
+ something third-party ends up hosting the process.**
6
+
7
+ | | Service-mode task (recommended) | Real SCM service (WinSW / NSSM) | Interactive task |
8
+ |---|---|---|---|
9
+ | Starts at boot | yes (S4U, session 0) | yes | no — at sign-in |
10
+ | Survives sign-out | yes | yes | no |
11
+ | Shows up in `services.msc`, `Restart-Service` | no (`Start-ScheduledTask`) | yes | no |
12
+ | Third-party binary required | **no** | **yes** — the service host | no |
13
+ | Needs elevation to install | yes (S4U ⇒ `SeBatchLogonRight`) | yes | **no** |
14
+ | Console-close death (`0xC000013A`) | avoided (session 0 has no console) | avoided | **hit** — measured twice |
15
+
16
+ ## 1. Service-mode task — `install-service.ps1`
17
+
18
+ ```powershell
19
+ # elevated PowerShell, from the repository root
20
+ powershell -ExecutionPolicy Bypass -File deploy\windows\install-service.ps1 `
21
+ -Project 'E:\path\to\your\project' `
22
+ -MachineName win-desktop `
23
+ -AllowedCommands 'node --test, git status --porcelain' `
24
+ -AgentArgs '--p2p-mode auto --p2p-port 41235'
25
+ ```
26
+
27
+ It registers **one** task that runs `w2m-agent-supervisor.mjs`, which owns the agent:
28
+
29
+ * `-AtStartup` **and** `-AtLogOn`, `LogonType S4U` → runs whether the user is signed in or not,
30
+ in session 0, with no console for anything to close;
31
+ * `MultipleInstances IgnoreNew` → two agents must never share one device identity;
32
+ * `RestartCount 999` / 1 min, `ExecutionTimeLimit 0`, `-StartWhenAvailable`.
33
+
34
+ Everything except the task's own command line goes into **`%USERPROFILE%\.dsh\w2m\agent.json`**,
35
+ which the installer writes and the supervisor reads; edit that file to change the allow-list, the
36
+ project, or the agent's flags, then `Restart-ScheduledTask`-equivalent
37
+ (`Stop-ScheduledTask` + `Start-ScheduledTask`). `-AllowedCommands` is **comma-separated plain
38
+ text** — not JSON, and not because JSON is wrong but because three separate layers eat its quotes
39
+ (see below).
40
+
41
+ Inspect and remove:
42
+
43
+ ```powershell
44
+ Get-ScheduledTask -TaskName 'W2M Localside agent' | Get-ScheduledTaskInfo
45
+ Get-Content "$env:USERPROFILE\.dsh\w2m\agent.json"
46
+ powershell -ExecutionPolicy Bypass -File deploy\windows\install-service.ps1 -Uninstall
47
+ ```
48
+
49
+ ### The three things that were measured, not assumed
50
+
51
+ 1. **Without elevation, S4U registration is refused** with `Access is denied` (S4U needs
52
+ `SeBatchLogonRight`; an `-AtStartup` trigger needs elevation too). The script says so instead of
53
+ half-installing. Pass `-Interactive` for the no-elevation shape — it works, but the process lives
54
+ in the sign-in session, so signing out stops it.
55
+ 2. **A JSON argument does not survive the trip.** Registered through a task,
56
+ `--allowed-commands "[\"node --version\"]"` came back stored as `"[node --version]"`: the escapes
57
+ gone, the agent reading `[node`, exiting 2 with `ALLOWED_COMMANDS_INVALID`. This project has now
58
+ hit that in `cmd`, PowerShell 5.1 and Task Scheduler — hence the config file, and hence
59
+ `-AllowedCommands` taking plain text.
60
+ 3. **PowerShell 5.1 writes a UTF-8 BOM** with `Set-Content -Encoding utf8`, and `JSON.parse`
61
+ refuses it: the supervisor exits 78 *before it opens its log*, so the failure leaves no trace
62
+ anywhere. The installer writes without a BOM, and the supervisor strips one if it is there
63
+ (Notepad writes them too, and this file is meant to be hand-edited).
64
+
65
+ ## 2. Real SCM service — WinSW
66
+
67
+ Only if you need the entry in `services.msc` (monitoring, `Restart-Service`, a
68
+ configuration-management system that knows about services). This puts a third-party
69
+ binary on the machine as the service host, which is a decision the project will not make
70
+ for you:
71
+
72
+ ```powershell
73
+ # 1. get the wrapper (verify the release hash yourself; this is the one third-party piece)
74
+ # https://github.com/winsw/winsw/releases
75
+ # 2. put w2m-localside.exe and w2m-localside.xml (below) in C:\ProgramData\w2m\
76
+ # 3. install it -- elevated
77
+ C:\ProgramData\w2m\w2m-localside.exe install
78
+ C:\ProgramData\w2m\w2m-localside.exe start
79
+ ```
80
+
81
+ `deploy/windows/winsw/w2m-localside.xml` is the template; edit the two paths in it. The
82
+ supervisor still wraps the agent, so a crash loop is visible in the log rather than
83
+ turning into a service that flaps.
84
+
85
+ ## 3. The one thing that is not a deployment detail
86
+
87
+ Whichever shape you pick, the machine must be **paired** and the punch port must match its
88
+ firewall rule:
89
+
90
+ ```powershell
91
+ # the identity this agent uses (create it by pairing once, with a code from the relay)
92
+ Get-Content "$env:USERPROFILE\.dsh\xclient\device.json"
93
+ # if you pinned --p2p-port, open exactly that port once -- elevated
94
+ New-NetFirewallRule -DisplayName 'W2M punch (node UDP)' -Direction Inbound -Protocol UDP `
95
+ -Program 'C:\Program Files\nodejs\node.exe' -LocalPort 41235 -Action Allow
96
+ ```
97
+
98
+ A pinned port is worth it for a service: an ephemeral one changes on every restart, and a
99
+ stale rule looks exactly like "the punch just does not work" — every task quietly falls
100
+ back to the relay with `offer_path: "relay"` in the ledger.
101
+
102
+ ## Verify it, rather than trusting it
103
+
104
+ ```powershell
105
+ # the task is running, and in which session
106
+ Get-ScheduledTask -TaskName 'W2M Localside agent' | Select-Object State
107
+ Get-Process node | Where-Object { $_.SessionId -eq 0 } | Select-Object Id, SessionId, StartTime
108
+
109
+ # the agent's own narration: "stream ready" is the line that means the fleet can reach it
110
+ Get-Content "$env:USERPROFILE\.dsh\w2m\agent.log" -Tail 20
111
+
112
+ # and one real dispatch from any machine with the plugin installed
113
+ # w2m_devices -> this machine must be listed as online
114
+ # w2m_run ["git","rev-parse","HEAD"] -> w2m_wait must show transport: p2p (or relay, with a reason)
115
+ ```
@@ -0,0 +1,195 @@
1
+ # ============================================================================
2
+ # install-service.ps1 -- run the W2M Localside agent as a Windows service
3
+ # ============================================================================
4
+ #
5
+ # WHAT "SERVICE MODE" MEANS HERE, AND WHAT IT DOES NOT
6
+ #
7
+ # A Node process cannot be an SCM service by itself: the Service Control Manager
8
+ # requires the process to answer StartServiceCtrlDispatcher, which needs native
9
+ # bindings this project does not have (and will not add -- zero runtime
10
+ # dependencies is a stated property, not an accident). So there are two honest
11
+ # shapes, and this script implements the first:
12
+ #
13
+ # 1. SERVICE-MODE TASK (this script) -- a scheduled task with LogonType S4U:
14
+ # starts at boot, runs in session 0 with no console, survives sign-out, and
15
+ # is managed with Start-ScheduledTask/Stop-ScheduledTask. No third-party
16
+ # binary. Requires **elevation** (S4U needs SeBatchLogonRight, and the
17
+ # registration is refused with "Access is denied" without it -- measured).
18
+ #
19
+ # 2. REAL SCM SERVICE via WinSW or NSSM -- a genuine entry in services.msc and
20
+ # for `Restart-Service`. It needs a third-party service host binary, which
21
+ # is a dependency decision rather than a deployment detail; see README.md
22
+ # in this directory for the template and the caveat.
23
+ #
24
+ # WHY THE AGENT RUNS UNDER A SUPERVISOR
25
+ #
26
+ # Measured twice: an agent started directly can be gone ~40 s later with
27
+ # 0xC000013A (STATUS_CONTROL_C_EXIT -- its console went away) and no log line,
28
+ # because whatever was logging died with it. The task therefore runs
29
+ # w2m-agent-supervisor.mjs, which respawns the agent with a capped backoff and
30
+ # logs one line per run.
31
+ #
32
+ # USAGE
33
+ #
34
+ # # elevated PowerShell, from the repository root:
35
+ # powershell -ExecutionPolicy Bypass -File deploy\windows\install-service.ps1 `
36
+ # -Project 'E:\path\to\your\project' `
37
+ # -AllowedCommands '["node --test","git status --porcelain"]'
38
+ #
39
+ # # inspect / remove
40
+ # Get-ScheduledTask -TaskName 'W2M Localside agent' | Get-ScheduledTaskInfo
41
+ # powershell -ExecutionPolicy Bypass -File deploy\windows\install-service.ps1 -Uninstall
42
+ #
43
+ # Everything after the parameters goes to the agent, so `-AgentArgs
44
+ # '--p2p-port','41235'` pins the punch socket (and then one inbound firewall rule
45
+ # is enough, instead of a rule per restart).
46
+ # ============================================================================
47
+ [CmdletBinding()]
48
+ param(
49
+ [string]$TaskName = 'W2M Localside agent',
50
+ [string]$Rabbit = 'http://202.182.123.154:8787',
51
+ [string]$Project = (Get-Location).Path,
52
+ [string]$MachineName = $env:COMPUTERNAME,
53
+ [string]$AllowedCommands = 'node --version, node --test, git status --porcelain, git rev-parse',
54
+ [string]$StateDir = "$env:USERPROFILE\.dsh\w2m\localside",
55
+ [string]$LogPath = "$env:USERPROFILE\.dsh\w2m\agent.log",
56
+ [string]$Bin = '',
57
+ [string]$AgentArgs = '',
58
+ [int]$RestartBackoffMs = 60000,
59
+ [switch]$Interactive,
60
+ [switch]$Uninstall,
61
+ [switch]$WhatIfOnly
62
+ )
63
+
64
+ $ErrorActionPreference = 'Stop'
65
+ $repo = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) # ...\deploy\windows -> repo root
66
+ $supervisor = Join-Path $PSScriptRoot 'w2m-agent-supervisor.mjs'
67
+ $node = (Get-Command node -ErrorAction SilentlyContinue).Source
68
+ if (-not $node) { throw 'node is not on PATH; install Node 20.19+ first' }
69
+ if (-not (Test-Path $supervisor)) { throw "supervisor missing: $supervisor" }
70
+
71
+ if ($Uninstall) {
72
+ if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) {
73
+ Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
74
+ Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
75
+ Write-Host "removed scheduled task: $TaskName"
76
+ } else {
77
+ Write-Host "nothing to remove: $TaskName does not exist"
78
+ }
79
+ return
80
+ }
81
+
82
+ if (-not $Bin) {
83
+ $Bin = Join-Path $env:USERPROFILE '.dsh\profiles\desktop\node_modules\@twinsearth\w2m-dsh-plugin\bin\w2m-localside.mjs'
84
+ }
85
+
86
+ # THE ALLOW-LIST GOES INTO A FILE, NOT ONTO A COMMAND LINE.
87
+ #
88
+ # Measured three times on this project: `cmd`, PowerShell 5.1 and finally Task Scheduler each
89
+ # mangled a JSON argument. Registered through a task, `--allowed-commands "[\"node --version\"]"`
90
+ # came back stored as `"[node --version]"` -- the escapes gone, the agent reading `[node` and
91
+ # exiting 2 with ALLOWED_COMMANDS_INVALID. A config file has no quoting rules, and it is one more
92
+ # thing this installer can write for whoever has to debug the service later.
93
+ $configPath = Join-Path (Split-Path -Parent $StateDir) 'agent.json'
94
+ $agentArgs = @($AgentArgs -split '[,\s]+' | Where-Object { $_ -ne '' })
95
+ # Comma/semicolon/newline separated, NOT JSON. Measured: `powershell -File` strips the quotes out of
96
+ # a JSON argument (`["node --version"]` arrives as `[node --version]`), which is one mangling more
97
+ # than this installer needs to survive. Nothing in an allow-list entry contains a comma.
98
+ $allowList = @($AllowedCommands -split '[,;\r\n]+' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
99
+ if ($allowList.Count -eq 0) { throw '-AllowedCommands must name at least one command prefix' }
100
+ $config = [ordered]@{
101
+ bin = $Bin
102
+ rabbit = $Rabbit
103
+ project = $Project
104
+ name = $MachineName
105
+ state = $StateDir
106
+ log = $LogPath
107
+ allowedCommands = $allowList
108
+ agentArgs = $agentArgs
109
+ backoffMs = $RestartBackoffMs
110
+ }
111
+
112
+ $arguments = @("`"$supervisor`"", '--config', "`"$configPath`"")
113
+
114
+ Write-Host 'service-mode task plan'
115
+ Write-Host " task : $TaskName"
116
+ Write-Host " runs as : $env:USERDOMAIN\$env:USERNAME ($(if ($Interactive) { 'interactive: signed in only' } else { 'S4U: whether signed in or not, session 0' }))"
117
+ Write-Host " command : `"$node`" $($arguments -join ' ')"
118
+ Write-Host " config : $configPath"
119
+ Write-Host " allow-list : $($config.allowedCommands -join ' | ')"
120
+ Write-Host " log : $LogPath"
121
+ if ($WhatIfOnly) {
122
+ Write-Host 'config that would be written:'
123
+ $config | ConvertTo-Json -Depth 4
124
+ return
125
+ }
126
+ New-Item -ItemType Directory -Force -Path (Split-Path -Parent $configPath) | Out-Null
127
+ # Write it WITHOUT a BOM. `Set-Content -Encoding utf8` on PowerShell 5.1 prepends one, `JSON.parse`
128
+ # refuses it, and the supervisor then exits 78 before it has opened its log -- a failure with no
129
+ # trace anywhere. (The supervisor also strips a BOM now; both ends, because the file is meant to be
130
+ # hand-editable and Notepad writes BOMs too.)
131
+ $json = $config | ConvertTo-Json -Depth 4
132
+ [System.IO.File]::WriteAllText($configPath, $json, (New-Object System.Text.UTF8Encoding($false)))
133
+ Write-Host "wrote $configPath"
134
+
135
+ $action = New-ScheduledTaskAction -Execute $node -Argument ($arguments -join ' ') -WorkingDirectory $PSScriptRoot
136
+ # AtStartup only makes sense for the session-0 (S4U) shape, which also runs whether or not anyone is
137
+ # signed in. Registering an AtStartup trigger on an interactive task is refused without elevation --
138
+ # measured, and the reason this list is conditional rather than always two entries.
139
+ $triggers = if ($Interactive) {
140
+ @(New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME")
141
+ } else {
142
+ @(
143
+ (New-ScheduledTaskTrigger -AtStartup),
144
+ (New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME")
145
+ )
146
+ }
147
+ $settings = New-ScheduledTaskSettingsSet `
148
+ -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
149
+ -MultipleInstances IgnoreNew `
150
+ -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
151
+ -ExecutionTimeLimit (New-TimeSpan -Seconds 0) `
152
+ -StartWhenAvailable -Hidden
153
+ $principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" `
154
+ -LogonType $(if ($Interactive) { 'Interactive' } else { 'S4U' }) -RunLevel Limited
155
+
156
+ if ($Interactive) {
157
+ # The no-elevation shape. It works and it is what this project shipped first, but the process
158
+ # lives in the sign-in session: closing that session's console is what produced 0xC000013A, which
159
+ # is the death the supervisor now at least records.
160
+ Write-Warning 'Interactive mode: the agent runs only while this user is signed in, and a console close can kill it (the supervisor will restart it when the session returns).'
161
+ }
162
+
163
+ try {
164
+ Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $triggers -Settings $settings -Principal $principal -Force | Out-Null
165
+ } catch {
166
+ Write-Error @"
167
+ could not register the task: $($_.Exception.Message)
168
+
169
+ For the default (session-0, S4U) shape that refusal is the expected one when this
170
+ script is not elevated: S4U needs SeBatchLogonRight, and an AtStartup trigger needs
171
+ elevation too -- the registration is denied with "Access is denied". Re-run the same
172
+ command from an **elevated** PowerShell.
173
+
174
+ If you cannot elevate, ask for the interactive shape instead (one trigger, AtLogOn):
175
+
176
+ powershell -ExecutionPolicy Bypass -File deploy\windows\install-service.ps1 -Interactive `
177
+ -Project '$Project' -AllowedCommands '$AllowedCommands'
178
+
179
+ It works, and it is what this project shipped first -- but the process lives in the
180
+ sign-in session, so signing out stops it and a console close can kill it.
181
+ "@
182
+ return
183
+ }
184
+
185
+ Start-ScheduledTask -TaskName $TaskName
186
+ Start-Sleep -Seconds 10
187
+ $task = Get-ScheduledTask -TaskName $TaskName
188
+ $info = $task | Get-ScheduledTaskInfo
189
+ Write-Host "state=$($task.State) lastRun=$($info.LastRunTime) lastResult=$($info.LastTaskResult)"
190
+ Write-Host "session of the agent process (0 = service session):"
191
+ Get-Process node -ErrorAction SilentlyContinue |
192
+ Where-Object { $_.StartTime -gt (Get-Date).AddMinutes(-2) } |
193
+ Select-Object Id, SessionId, StartTime | Format-Table -AutoSize
194
+ Write-Host "log tail:"
195
+ Get-Content $LogPath -Tail 5 -ErrorAction SilentlyContinue