ever-works-node 0.0.0-stage → 0.2.1

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/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "ever-works-node",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.1",
4
+ "description": "Ever Works Node - headless execution node (CLI + long-running service): fleet enrollment, heartbeat and capability reporting",
5
+ "author": {
6
+ "name": "Ever Co. LTD",
7
+ "email": "ever@ever.co",
8
+ "url": "https://ever.co"
9
+ },
10
+ "license": "AGPL-3.0",
11
+ "homepage": "https://ever.works",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/ever-works/ever-works.git",
15
+ "directory": "apps/node"
16
+ },
17
+ "bugs": {
18
+ "url": "https://github.com/ever-works/ever-works/issues"
19
+ },
20
+ "keywords": [
21
+ "ever-works",
22
+ "fleet",
23
+ "agent",
24
+ "node",
25
+ "runner",
26
+ "claude-code",
27
+ "codex"
28
+ ],
29
+ "bin": {
30
+ "ever-works-node": "cli.js"
31
+ },
32
+ "main": "./cli.js",
33
+ "files": [
34
+ "cli.js",
35
+ "packaging",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "engines": {
40
+ "node": ">=22.0.0"
41
+ },
42
+ "optionalDependencies": {
43
+ "@napi-rs/keyring": "^1.1.0",
44
+ "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ }
49
+ }
@@ -0,0 +1,276 @@
1
+ # Running the Ever Works node unattended
2
+
3
+ `ever-works-node start` is a foreground process. Everything in this
4
+ directory exists so it does not have to be — so an enrolled machine
5
+ survives a reboot, restarts after a crash, and can be drained without
6
+ throwing away work it is half-way through.
7
+
8
+ Three shapes, one binary:
9
+
10
+ | Host | Mechanism | Where |
11
+ | --------------- | ------------------------------------------------ | ----------------------------------------- |
12
+ | Linux (systemd) | template unit `ever-works-node@<user>.service` | `systemd/` |
13
+ | Windows | Windows service (NSSM) or Scheduled Task at boot | `windows/` |
14
+ | Container / k8s | image built from the node's Dockerfile | `../../../.deploy/docker/node/Dockerfile` |
15
+
16
+ Enrollment is **never** part of installation. It consumes a one-time
17
+ token from the platform's Fleet settings page and is an explicit,
18
+ interactive act:
19
+
20
+ ```bash
21
+ ever-works-node enroll --api-url https://api.ever.works --token <token>
22
+ ```
23
+
24
+ Install the service afterwards.
25
+
26
+ The scripts below live in this directory of the source tree **and** inside
27
+ the published npm package: after `npm install -g ever-works-node` they are
28
+ under `$(npm root -g)/ever-works-node/packaging/`.
29
+
30
+ ---
31
+
32
+ ## Linux — systemd
33
+
34
+ ```bash
35
+ sudo apps/node/packaging/systemd/install.sh alice
36
+ sudo -u alice ever-works-node enroll --api-url https://api.ever.works --token <token>
37
+ sudo systemctl enable --now ever-works-node@alice
38
+ journalctl -fu ever-works-node@alice
39
+ ```
40
+
41
+ The unit is a **template** whose instance name is the user the node runs
42
+ as. A fleet node executes that user's commands, so it runs as that user
43
+ rather than as root or a shared service account.
44
+
45
+ `TimeoutStopSec=900` is load-bearing: stopping the node stops the
46
+ heartbeat and then **waits** for in-flight jobs to report their verdicts.
47
+ If systemd `SIGKILL`s the drain half-way, those claims lapse and the
48
+ platform re-runs the same work on another machine. Raise it above your
49
+ longest expected job.
50
+
51
+ `systemctl reload` is wired to `ever-works-node pause` — a drain, not a
52
+ restart.
53
+
54
+ ## Windows
55
+
56
+ Enroll first, **as the account the node will run as**, then install from an
57
+ **elevated** PowerShell session:
58
+
59
+ ```powershell
60
+ # 1. as the fleet user (NOT elevated) - this consumes a one-time token
61
+ ever-works-node enroll --api-url https://api.ever.works --token <token>
62
+
63
+ # 2. as an administrator: rehearse, then install
64
+ $password = Read-Host -AsSecureString 'Password for the node account'
65
+ .\apps\node\packaging\windows\install-service.ps1 -Work -ServicePassword $password -DryRun
66
+ .\apps\node\packaging\windows\install-service.ps1 -Work -ServicePassword $password
67
+ ```
68
+
69
+ ### Who the node runs as
70
+
71
+ This is the load-bearing decision, and it is why `-ServicePassword` is not
72
+ optional. A fleet node executes the enrolling user's work with that user's
73
+ credentials:
74
+
75
+ - its config and heartbeat credential live in that user's
76
+ `%APPDATA%\ever-works-node`, written with inheritance stripped and exactly
77
+ one ACE for that user;
78
+ - its git credentials live in that user's Windows Credential Manager vault,
79
+ which the credential helper reads per-user;
80
+ - the Claude Code and Codex logins live in that user's profile.
81
+
82
+ `nssm install` calls `CreateService` with a NULL `lpServiceStartName`, which
83
+ the Win32 API defines as **LocalSystem** — an account that shares none of
84
+ those. A node installed that way starts, finds no config, reports "not
85
+ enrolled", and is restarted every 10 seconds forever.
86
+
87
+ So `-ServiceAccount` defaults to the account running the installer and is
88
+ applied on **every** run, including a re-run to change a pin. It accepts
89
+ `DOMAIN\User`, `MACHINE\User`, `.\User`, a bare local name, a UPN, and a gMSA
90
+ (`DOMAIN\name$`, which takes no password). The built-in service accounts are
91
+ refused, with the reason.
92
+
93
+ A gMSA is accepted but is an odd fit, and the preflight will say so: it is
94
+ provisioned rather than signed in to, so it has no profile, no `%APPDATA%`,
95
+ and nowhere for `ever-works-node enroll` to have put a credential. A fleet
96
+ node's whole job is to run the **enrolling user's** work with that user's git
97
+ and model-CLI logins.
98
+
99
+ ### How the password is handled
100
+
101
+ `-ServicePassword` is a **SecureString**, so the plaintext never reaches your
102
+ shell history or `powershell.exe`'s own command line — passing a literal is a
103
+ parameter-binding error, not a history entry. It is never echoed, never
104
+ written to a log file, and never rendered into a progress line or an
105
+ exception.
106
+
107
+ On the **service** path it never reaches a command line either. The obvious
108
+ implementation, `nssm set <name> ObjectName <account> <password>`, puts it on
109
+ a child process's command line, and that is written to persistent telemetry:
110
+ Security event 4688 wherever command-line auditing is enabled (the Microsoft
111
+ and CIS baselines enable it), and any EDR agent that captures
112
+ `ProcessCommandLine` — Defender for Endpoint uploads that to its cloud and
113
+ keeps it queryable. So the installer unwraps the SecureString into unmanaged
114
+ memory and hands `ChangeServiceConfigW` a pointer, zeroing the buffer
115
+ afterwards. No child process, no cmdlet parameter, and no managed `String`
116
+ (which is immutable and could not be zeroed).
117
+
118
+ That means the installer also has to do the half of the job NSSM was quietly
119
+ doing for us: `ChangeServiceConfig` does **not** grant the "Log on as a
120
+ service" right, and without it a correctly configured service fails to start
121
+ with error 1069. The installer grants it with `LsaAddAccountRights`.
122
+
123
+ On the **scheduled-task** path there is no such route: `Register-ScheduledTask`
124
+ takes the password as an ordinary cmdlet parameter and the Task Scheduler API
125
+ takes a `BSTR`, so a managed copy exists for the duration of the call and
126
+ PowerShell module logging (event 4103) would record the bound value where that
127
+ policy is enabled. One more reason the service is the recommended mechanism.
128
+
129
+ ### Preflight
130
+
131
+ The installer refuses, before registering anything, when the chosen account
132
+ could not actually run a node: no such account, no profile, no config at that
133
+ account's `%APPDATA%` path, a config whose ACL does not grant it, a
134
+ `-ClaudePath`/`-CodexPath` pin that does not resolve or is not launchable, or
135
+ a workspace root it cannot write (or that would land inside the system
136
+ profile). It warns — but proceeds — for the survivable cases, including a
137
+ pinned CLI with no login: the node would advertise `claude-code` and then fail
138
+ every job it was sent, which is worse than not advertising it at all.
139
+
140
+ It also warns when the account cannot write `%ProgramData%\ever-works-node` or
141
+ a log file already there, and then **fixes** it: files created in that folder
142
+ by a privileged identity inherit only `BUILTIN\Users: ReadAndExecute`, so a
143
+ `node.log` written by a LocalSystem-era install is read-only to the account
144
+ the fixed installer picks. Left alone, the service could not open its
145
+ `AppStdout` and the scheduled task would die inside `cmd.exe` before the node
146
+ ran — with nothing in either log. The installer grants the account `Modify` on
147
+ the directory and on any log left behind.
148
+
149
+ ### If a real run fails part-way
150
+
151
+ A fresh install that fails after `nssm install` — most likely at the identity
152
+ step, which is where a wrong account or password surfaces — is **undone**. The
153
+ service is removed rather than left registered under the LocalSystem default
154
+ this whole mechanism exists to avoid. A re-run over a service that already
155
+ existed is not removed; the message says so, and names `uninstall-service.ps1`.
156
+
157
+ ### Dry run
158
+
159
+ `-DryRun` (or the standard `-WhatIf`) runs the whole preflight and prints
160
+ every command that would run, changing nothing. It does not require elevation,
161
+ so it is the way to check five remote machines before touching any of them.
162
+
163
+ ### Other flags
164
+
165
+ `-ClaudePath` / `-CodexPath` pin the model CLIs for the service (a service
166
+ inherits the **machine** `PATH`, which normally lacks the per-user npm
167
+ directory) and `-WorkspaceRoot` sets the absolute, drive- or UNC-rooted
168
+ directory the per-Task worktrees live under; each becomes the matching `start`
169
+ flag. `-CliPath` points the service at a specific `cli.js` (default: the
170
+ globally installed package; from a source checkout use `apps\node\dist\cli.js`).
171
+
172
+ ### Mechanism
173
+
174
+ The script registers `node.exe` running that `cli.js` directly. npm's global
175
+ install also puts `ever-works-node.ps1` / `.cmd` shims on `PATH`, but a service
176
+ manager cannot launch the `.ps1`, and the `.cmd` puts `cmd.exe` between the
177
+ service and the node, where the console stop signal used for draining gets
178
+ swallowed. The command line is quoted once, by the script, and handed to NSSM
179
+ verbatim — Windows PowerShell 5.1 would otherwise re-quote pinned paths
180
+ containing spaces without escaping them.
181
+
182
+ The script prefers a real Windows service via [NSSM](https://nssm.cc).
183
+ This matters: Node.js cannot answer Service Control Manager messages, so
184
+ `sc.exe create` pointed straight at `node.exe` yields a service Windows
185
+ reports as "did not respond in a timely fashion" (error 1053). A wrapper
186
+ is what makes it a genuine service _and_ what gives the node a graceful
187
+ stop long enough to drain.
188
+
189
+ Without NSSM the script registers a **Scheduled Task** at boot and says so.
190
+ That runs unattended and survives reboots, but it will not appear in
191
+ `services.msc`. Both branches write `%ProgramData%\ever-works-node\node.log`
192
+ and `node.err.log`; the task branch reaches them through `cmd.exe`, which is
193
+ acceptable there only because `Stop-ScheduledTask` terminates the task
194
+ outright and delivers no console event for `cmd.exe` to swallow — that branch
195
+ never drained.
196
+
197
+ Without `-ServicePassword` the task falls back to an **S4U** logon, which is
198
+ minted without the account's password and therefore never unlocks its DPAPI
199
+ master key: Windows Credential Manager reads fail there exactly as they do
200
+ under LocalSystem. The preflight says so. (A gMSA is not S4U either — its
201
+ password is held by the domain controller, so it registers as a `Password`
202
+ logon with no value.)
203
+
204
+ Remove with `uninstall-service.ps1`. That leaves the node enrolled — use
205
+ `ever-works-node unenroll` to retire it on the platform too.
206
+
207
+ ### Tests
208
+
209
+ `test-install-service-logic.ps1` covers the decision logic (account
210
+ normalisation, the service configuration plan, the scheduled-task plan, the
211
+ ACL plan, the access-verdict and pin rules, the preflight verdicts) without
212
+ registering anything. It also exercises the `ChangeServiceConfigW` and
213
+ `LsaAddAccountRights` signatures against a deliberately invalid handle, which
214
+ proves the marshalling without touching a service or the local security
215
+ policy. `.github/workflows/node-packaging-windows.yml` runs it on a
216
+ `windows-2022` runner under Windows PowerShell 5.1 — the shell the fleet
217
+ workstations actually have — and exercises the installer's `-DryRun` and
218
+ `-WhatIf` paths. `apps/node/src/core/packaging/windows-service-contract.internal.spec.ts`
219
+ pins the same invariants from the Linux CI, so they hold even if that
220
+ workflow's path filter is edited.
221
+
222
+ ## Container
223
+
224
+ ```bash
225
+ docker build -f .deploy/docker/node/Dockerfile -t ever-works-node .
226
+ docker run -d --name ever-works-node \
227
+ -v ever-works-node-config:/var/lib/ever-works-node \
228
+ ever-works-node start --work
229
+ ```
230
+
231
+ Enroll into the same volume first:
232
+
233
+ ```bash
234
+ docker run --rm -it \
235
+ -v ever-works-node-config:/var/lib/ever-works-node \
236
+ ever-works-node enroll --api-url https://api.ever.works --token <token>
237
+ ```
238
+
239
+ Build with `--build-arg WITH_BROWSER=true` to install Chromium, so the
240
+ image can honour the `browser` capability it would otherwise not
241
+ advertise. It roughly triples the image size, which is why it is opt-in.
242
+
243
+ `tini` is PID 1 on purpose: PID 1 does not get default signal handling,
244
+ and without it the node's SIGTERM drain would never run.
245
+
246
+ ---
247
+
248
+ ## Credential storage
249
+
250
+ The heartbeat secret goes into the OS keychain (macOS Keychain, Windows
251
+ Credential Manager, Linux Secret Service) when one is available, and the
252
+ config file holds only a pointer. Where no keychain exists — headless
253
+ servers, containers — it falls back into the config file and **says so**,
254
+ loudly, on every load. The file is then restricted to its owner: `0600`
255
+ on POSIX, and an inheritance-stripped owner-only ACL on Windows.
256
+
257
+ Two environment variables change this:
258
+
259
+ | Variable | Effect |
260
+ | ------------------------------------ | ------------------------------------------------------------ |
261
+ | `EVER_WORKS_NODE_DISABLE_KEYCHAIN=1` | Force the file fallback (set in the container image). |
262
+ | `EVER_WORKS_NODE_CONFIG` | Absolute path of the config file, overriding the OS default. |
263
+
264
+ ## Draining
265
+
266
+ `pause` is a drain, not a kill:
267
+
268
+ ```bash
269
+ ever-works-node pause # stop leasing; in-flight jobs finish and report
270
+ ever-works-node resume # take work again
271
+ ```
272
+
273
+ It tells the platform (so the scheduler stops offering work) _and_
274
+ records the intent locally (so a restart comes back paused). A paused
275
+ node keeps heartbeating: a drained machine that vanishes from Fleet is
276
+ indistinguishable from a dead one.
@@ -0,0 +1,64 @@
1
+ # Template unit: the instance name IS the user the node runs as.
2
+ # systemctl enable --now ever-works-node@alice
3
+ # A fleet node executes the OWNER'S commands, so it runs as that owner
4
+ # rather than as root or as a shared service account.
5
+ [Unit]
6
+ Description=Ever Works execution node (running as %i)
7
+ Documentation=https://docs.ever.works
8
+ # Enrollment and heartbeat are outbound HTTPS; starting before the
9
+ # network is up just burns the first few backoff intervals.
10
+ After=network-online.target
11
+ Wants=network-online.target
12
+
13
+ [Service]
14
+ Type=simple
15
+ User=%i
16
+ Group=%i
17
+
18
+ # `--work` is what makes this machine capacity rather than inventory.
19
+ # Drop it to enroll a node for visibility only.
20
+ ExecStart=/usr/bin/env ever-works-node start --work
21
+ # `pause` drains: leasing stops immediately, in-flight jobs finish and
22
+ # report. Wired to ExecReload so `systemctl reload` is a drain, not a cut.
23
+ ExecReload=/usr/bin/env ever-works-node pause
24
+
25
+ # The node's own SIGTERM handler stops the heartbeat and then WAITS for
26
+ # in-flight jobs to report their verdicts. TimeoutStopSec must therefore
27
+ # be longer than the longest job you expect, or systemd SIGKILLs the
28
+ # drain half-way through and the platform re-runs the work elsewhere.
29
+ KillSignal=SIGTERM
30
+ TimeoutStopSec=900
31
+
32
+ Restart=on-failure
33
+ RestartSec=10s
34
+ # A node that crash-loops on a bad config should stop and be visible,
35
+ # not hammer the API forever.
36
+ StartLimitIntervalSec=300
37
+ StartLimitBurst=5
38
+
39
+ # Hardening. The node executes the OWNER'S commands by design, so this is
40
+ # a blast-radius limit, not a sandbox: it keeps a check from touching the
41
+ # rest of the system, it does not make an untrusted check safe.
42
+ NoNewPrivileges=true
43
+ PrivateTmp=true
44
+ ProtectSystem=full
45
+ ProtectHome=read-only
46
+ ProtectKernelTunables=true
47
+ ProtectControlGroups=true
48
+ RestrictSUIDSGID=true
49
+ # Config (credential) and workspaces are the only writable paths.
50
+ StateDirectory=ever-works-node
51
+ ReadWritePaths=/var/lib/ever-works-node
52
+
53
+ Environment=NODE_ENV=production
54
+ Environment=EVER_WORKS_NODE_CONFIG=/var/lib/ever-works-node/node-config.json
55
+ # Drop-in overrides go in
56
+ # /etc/systemd/system/ever-works-node@.service.d/override.conf
57
+ EnvironmentFile=-/etc/default/ever-works-node
58
+
59
+ StandardOutput=journal
60
+ StandardError=journal
61
+ SyslogIdentifier=ever-works-node
62
+
63
+ [Install]
64
+ WantedBy=multi-user.target
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Install the Ever Works node as a systemd service.
4
+ #
5
+ # sudo ./install.sh <user>
6
+ #
7
+ # Idempotent: re-running refreshes the unit and reloads systemd. It does
8
+ # NOT enroll the node — enrollment consumes a one-time token and is an
9
+ # explicit, interactive act:
10
+ #
11
+ # ever-works-node enroll --api-url https://api.ever.works --token <token>
12
+ #
13
+ # Run that as <user> first, then start the service.
14
+
15
+ set -euo pipefail
16
+
17
+ UNIT_NAME="ever-works-node@.service"
18
+ UNIT_SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/${UNIT_NAME}"
19
+ UNIT_DEST="/etc/systemd/system/${UNIT_NAME}"
20
+ STATE_DIR="/var/lib/ever-works-node"
21
+
22
+ RUN_AS="${1:-}"
23
+ if [ -z "${RUN_AS}" ]; then
24
+ echo "usage: $0 <user>" >&2
25
+ echo " the node runs as that user and executes ITS commands" >&2
26
+ exit 2
27
+ fi
28
+
29
+ if [ "$(id -u)" -ne 0 ]; then
30
+ echo "error: must run as root (installing a system unit)" >&2
31
+ exit 1
32
+ fi
33
+
34
+ if ! id "${RUN_AS}" >/dev/null 2>&1; then
35
+ echo "error: user '${RUN_AS}' does not exist" >&2
36
+ exit 1
37
+ fi
38
+
39
+ if ! command -v systemctl >/dev/null 2>&1; then
40
+ echo "error: systemd is not available on this host" >&2
41
+ echo " use the container image (.deploy/docker/node/Dockerfile) instead" >&2
42
+ exit 1
43
+ fi
44
+
45
+ if ! command -v ever-works-node >/dev/null 2>&1; then
46
+ # Refuse rather than install a unit that will crash-loop on start.
47
+ echo "error: 'ever-works-node' is not on PATH" >&2
48
+ echo " install it first, e.g.: npm install -g ever-works-node" >&2
49
+ exit 1
50
+ fi
51
+
52
+ install -d -m 0700 -o "${RUN_AS}" -g "${RUN_AS}" "${STATE_DIR}"
53
+ install -m 0644 "${UNIT_SRC}" "${UNIT_DEST}"
54
+
55
+ systemctl daemon-reload
56
+
57
+ echo "Installed ${UNIT_DEST}"
58
+ echo
59
+ echo "Next:"
60
+ echo " 1. enroll (as ${RUN_AS}):"
61
+ echo " sudo -u ${RUN_AS} ever-works-node enroll --api-url <url> --token <token>"
62
+ echo " 2. start:"
63
+ echo " systemctl enable --now ever-works-node@${RUN_AS}"
64
+ echo
65
+ echo "Operating:"
66
+ echo " drain (finish in-flight work, take no more): ever-works-node pause"
67
+ echo " take work again: ever-works-node resume"
68
+ echo " follow logs: journalctl -fu ever-works-node@${RUN_AS}"