ever-works-node 0.0.0-stage → 0.2.2

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.2",
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,295 @@
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
+ Logs go to the journal (`StandardOutput=journal`), which journald already
55
+ rotates and bounds (`SystemMaxUse=` in `journald.conf`), so the unit needs
56
+ nothing of its own.
57
+
58
+ ## Windows
59
+
60
+ Enroll first, **as the account the node will run as**, then install from an
61
+ **elevated** PowerShell session:
62
+
63
+ ```powershell
64
+ # 1. as the fleet user (NOT elevated) - this consumes a one-time token
65
+ ever-works-node enroll --api-url https://api.ever.works --token <token>
66
+
67
+ # 2. as an administrator: rehearse, then install
68
+ $password = Read-Host -AsSecureString 'Password for the node account'
69
+ .\apps\node\packaging\windows\install-service.ps1 -Work -ServicePassword $password -DryRun
70
+ .\apps\node\packaging\windows\install-service.ps1 -Work -ServicePassword $password
71
+ ```
72
+
73
+ ### Who the node runs as
74
+
75
+ This is the load-bearing decision, and it is why `-ServicePassword` is not
76
+ optional. A fleet node executes the enrolling user's work with that user's
77
+ credentials:
78
+
79
+ - its config and heartbeat credential live in that user's
80
+ `%APPDATA%\ever-works-node`, written with inheritance stripped and exactly
81
+ one ACE for that user;
82
+ - its git credentials live in that user's Windows Credential Manager vault,
83
+ which the credential helper reads per-user;
84
+ - the Claude Code and Codex logins live in that user's profile.
85
+
86
+ `nssm install` calls `CreateService` with a NULL `lpServiceStartName`, which
87
+ the Win32 API defines as **LocalSystem** — an account that shares none of
88
+ those. A node installed that way starts, finds no config, reports "not
89
+ enrolled", and is restarted every 10 seconds forever.
90
+
91
+ So `-ServiceAccount` defaults to the account running the installer and is
92
+ applied on **every** run, including a re-run to change a pin. It accepts
93
+ `DOMAIN\User`, `MACHINE\User`, `.\User`, a bare local name, a UPN, and a gMSA
94
+ (`DOMAIN\name$`, which takes no password). The built-in service accounts are
95
+ refused, with the reason.
96
+
97
+ A gMSA is accepted but is an odd fit, and the preflight will say so: it is
98
+ provisioned rather than signed in to, so it has no profile, no `%APPDATA%`,
99
+ and nowhere for `ever-works-node enroll` to have put a credential. A fleet
100
+ node's whole job is to run the **enrolling user's** work with that user's git
101
+ and model-CLI logins.
102
+
103
+ ### How the password is handled
104
+
105
+ `-ServicePassword` is a **SecureString**, so the plaintext never reaches your
106
+ shell history or `powershell.exe`'s own command line — passing a literal is a
107
+ parameter-binding error, not a history entry. It is never echoed, never
108
+ written to a log file, and never rendered into a progress line or an
109
+ exception.
110
+
111
+ On the **service** path it never reaches a command line either. The obvious
112
+ implementation, `nssm set <name> ObjectName <account> <password>`, puts it on
113
+ a child process's command line, and that is written to persistent telemetry:
114
+ Security event 4688 wherever command-line auditing is enabled (the Microsoft
115
+ and CIS baselines enable it), and any EDR agent that captures
116
+ `ProcessCommandLine` — Defender for Endpoint uploads that to its cloud and
117
+ keeps it queryable. So the installer unwraps the SecureString into unmanaged
118
+ memory and hands `ChangeServiceConfigW` a pointer, zeroing the buffer
119
+ afterwards. No child process, no cmdlet parameter, and no managed `String`
120
+ (which is immutable and could not be zeroed).
121
+
122
+ That means the installer also has to do the half of the job NSSM was quietly
123
+ doing for us: `ChangeServiceConfig` does **not** grant the "Log on as a
124
+ service" right, and without it a correctly configured service fails to start
125
+ with error 1069. The installer grants it with `LsaAddAccountRights`.
126
+
127
+ On the **scheduled-task** path there is no such route: `Register-ScheduledTask`
128
+ takes the password as an ordinary cmdlet parameter and the Task Scheduler API
129
+ takes a `BSTR`, so a managed copy exists for the duration of the call and
130
+ PowerShell module logging (event 4103) would record the bound value where that
131
+ policy is enabled. One more reason the service is the recommended mechanism.
132
+
133
+ ### Preflight
134
+
135
+ The installer refuses, before registering anything, when the chosen account
136
+ could not actually run a node: no such account, no profile, no config at that
137
+ account's `%APPDATA%` path, a config whose ACL does not grant it, a
138
+ `-ClaudePath`/`-CodexPath` pin that does not resolve or is not launchable, or
139
+ a workspace root it cannot write (or that would land inside the system
140
+ profile). It warns — but proceeds — for the survivable cases, including a
141
+ pinned CLI with no login: the node would advertise `claude-code` and then fail
142
+ every job it was sent, which is worse than not advertising it at all.
143
+
144
+ It also warns when the account cannot write `%ProgramData%\ever-works-node` or
145
+ a log file already there, and then **fixes** it: files created in that folder
146
+ by a privileged identity inherit only `BUILTIN\Users: ReadAndExecute`, so a
147
+ `node.log` written by a LocalSystem-era install is read-only to the account
148
+ the fixed installer picks. Left alone, the service could not open its
149
+ `AppStdout` and the scheduled task would die inside `cmd.exe` before the node
150
+ ran — with nothing in either log. The installer grants the account `Modify` on
151
+ the directory and on any log left behind.
152
+
153
+ ### If a real run fails part-way
154
+
155
+ A fresh install that fails after `nssm install` — most likely at the identity
156
+ step, which is where a wrong account or password surfaces — is **undone**. The
157
+ service is removed rather than left registered under the LocalSystem default
158
+ this whole mechanism exists to avoid. A re-run over a service that already
159
+ existed is not removed; the message says so, and names `uninstall-service.ps1`.
160
+
161
+ ### Dry run
162
+
163
+ `-DryRun` (or the standard `-WhatIf`) runs the whole preflight and prints
164
+ every command that would run, changing nothing. It does not require elevation,
165
+ so it is the way to check five remote machines before touching any of them.
166
+
167
+ ### Other flags
168
+
169
+ `-ClaudePath` / `-CodexPath` pin the model CLIs for the service (a service
170
+ inherits the **machine** `PATH`, which normally lacks the per-user npm
171
+ directory) and `-WorkspaceRoot` sets the absolute, drive- or UNC-rooted
172
+ directory the per-Task worktrees live under; each becomes the matching `start`
173
+ flag. `-CliPath` points the service at a specific `cli.js` (default: the
174
+ globally installed package; from a source checkout use `apps\node\dist\cli.js`).
175
+
176
+ ### Mechanism
177
+
178
+ The script registers `node.exe` running that `cli.js` directly. npm's global
179
+ install also puts `ever-works-node.ps1` / `.cmd` shims on `PATH`, but a service
180
+ manager cannot launch the `.ps1`, and the `.cmd` puts `cmd.exe` between the
181
+ service and the node, where the console stop signal used for draining gets
182
+ swallowed. The command line is quoted once, by the script, and handed to NSSM
183
+ verbatim — Windows PowerShell 5.1 would otherwise re-quote pinned paths
184
+ containing spaces without escaping them.
185
+
186
+ The script prefers a real Windows service via [NSSM](https://nssm.cc).
187
+ This matters: Node.js cannot answer Service Control Manager messages, so
188
+ `sc.exe create` pointed straight at `node.exe` yields a service Windows
189
+ reports as "did not respond in a timely fashion" (error 1053). A wrapper
190
+ is what makes it a genuine service _and_ what gives the node a graceful
191
+ stop long enough to drain.
192
+
193
+ Without NSSM the script registers a **Scheduled Task** at boot and says so.
194
+ That runs unattended and survives reboots, but it will not appear in
195
+ `services.msc`. Both branches write `%ProgramData%\ever-works-node\node.log`
196
+ and `node.err.log`; the task branch reaches them through `cmd.exe`, which is
197
+ acceptable there only because `Stop-ScheduledTask` terminates the task
198
+ outright and delivers no console event for `cmd.exe` to swallow — that branch
199
+ never drained.
200
+
201
+ Both logs **rotate past 10 MB** (one policy, `Get-NodeLogRotationPolicy`). The
202
+ service rotates them with NSSM's own `AppRotateFiles` / `AppRotateOnline` /
203
+ `AppRotateBytes` — at start and online while it runs. NSSM never deletes a
204
+ rotated file, so the service install also registers `<Name>-LogPrune`, a
205
+ small `SYSTEM` scheduled task that runs daily at 04:30 and at boot and keeps
206
+ the newest five rotated generations of each log (NSSM's `node-<timestamp>.log`
207
+ names and this installer's own); `uninstall-service.ps1` removes it with the
208
+ service. The task cannot rotate while the node runs (`cmd.exe` holds
209
+ the file open without delete sharing, and truncating under its write offset
210
+ would leave a hole), so it rotates when the task STARTS — at boot and on every
211
+ restart-on-failure — as a first action that runs to completion before the
212
+ node action, renaming a full log to `<name>.<yyyyMMddTHHmmss>` and keeping
213
+ the newest five. Re-run the installer to add rotation to a machine
214
+ installed before it existed.
215
+
216
+ Without `-ServicePassword` the task falls back to an **S4U** logon, which is
217
+ minted without the account's password and therefore never unlocks its DPAPI
218
+ master key: Windows Credential Manager reads fail there exactly as they do
219
+ under LocalSystem. The preflight says so. (A gMSA is not S4U either — its
220
+ password is held by the domain controller, so it registers as a `Password`
221
+ logon with no value.)
222
+
223
+ Remove with `uninstall-service.ps1`. That leaves the node enrolled — use
224
+ `ever-works-node unenroll` to retire it on the platform too.
225
+
226
+ ### Tests
227
+
228
+ `test-install-service-logic.ps1` covers the decision logic (account
229
+ normalisation, the service configuration plan, the scheduled-task plan, the
230
+ ACL plan, the access-verdict and pin rules, the preflight verdicts) without
231
+ registering anything. It also exercises the `ChangeServiceConfigW` and
232
+ `LsaAddAccountRights` signatures against a deliberately invalid handle, which
233
+ proves the marshalling without touching a service or the local security
234
+ policy. `.github/workflows/node-packaging-windows.yml` runs it on a
235
+ `windows-2022` runner under Windows PowerShell 5.1 — the shell the fleet
236
+ workstations actually have — and exercises the installer's `-DryRun` and
237
+ `-WhatIf` paths. `apps/node/src/core/packaging/windows-service-contract.internal.spec.ts`
238
+ pins the same invariants from the Linux CI, so they hold even if that
239
+ workflow's path filter is edited.
240
+
241
+ ## Container
242
+
243
+ ```bash
244
+ docker build -f .deploy/docker/node/Dockerfile -t ever-works-node .
245
+ docker run -d --name ever-works-node \
246
+ -v ever-works-node-config:/var/lib/ever-works-node \
247
+ ever-works-node start --work
248
+ ```
249
+
250
+ Enroll into the same volume first:
251
+
252
+ ```bash
253
+ docker run --rm -it \
254
+ -v ever-works-node-config:/var/lib/ever-works-node \
255
+ ever-works-node enroll --api-url https://api.ever.works --token <token>
256
+ ```
257
+
258
+ Build with `--build-arg WITH_BROWSER=true` to install Chromium, so the
259
+ image can honour the `browser` capability it would otherwise not
260
+ advertise. It roughly triples the image size, which is why it is opt-in.
261
+
262
+ `tini` is PID 1 on purpose: PID 1 does not get default signal handling,
263
+ and without it the node's SIGTERM drain would never run.
264
+
265
+ ---
266
+
267
+ ## Credential storage
268
+
269
+ The heartbeat secret goes into the OS keychain (macOS Keychain, Windows
270
+ Credential Manager, Linux Secret Service) when one is available, and the
271
+ config file holds only a pointer. Where no keychain exists — headless
272
+ servers, containers — it falls back into the config file and **says so**,
273
+ loudly, on every load. The file is then restricted to its owner: `0600`
274
+ on POSIX, and an inheritance-stripped owner-only ACL on Windows.
275
+
276
+ Two environment variables change this:
277
+
278
+ | Variable | Effect |
279
+ | ------------------------------------ | ------------------------------------------------------------ |
280
+ | `EVER_WORKS_NODE_DISABLE_KEYCHAIN=1` | Force the file fallback (set in the container image). |
281
+ | `EVER_WORKS_NODE_CONFIG` | Absolute path of the config file, overriding the OS default. |
282
+
283
+ ## Draining
284
+
285
+ `pause` is a drain, not a kill:
286
+
287
+ ```bash
288
+ ever-works-node pause # stop leasing; in-flight jobs finish and report
289
+ ever-works-node resume # take work again
290
+ ```
291
+
292
+ It tells the platform (so the scheduler stops offering work) _and_
293
+ records the intent locally (so a restart comes back paused). A paused
294
+ node keeps heartbeating: a drained machine that vanishes from Fleet is
295
+ 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}"