@rackbops/ac-agent 2.0.0-alpha.47 → 2.0.0-alpha.49

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 (3) hide show
  1. package/README.md +120 -6
  2. package/dist/ac-agent.mjs +768 -65
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -106,10 +106,58 @@ against the new bundle, `install` stops the old process and starts it again thro
106
106
  `stop`/`start` machinery the `ac-agent stop`/`ac-agent start` commands use, then confirms the new
107
107
  process is actually answering (a health check, retried for a few seconds — the OS command that
108
108
  launches a process can report success before that process has actually started listening) before
109
- printing `restarted: now serving <version>`. If the restart fails outright, or the health check
110
- never comes back healthy, the install is still reported as a success — the printed line is
111
- `restart failed: <reason>; run ac-agent stop && ac-agent start`, and that manual sequence is the
112
- fallback. A fresh install, with nothing running yet, prints neither line.
109
+ printing `restarted: now serving <version>`. Only a health answer reporting the **new** version
110
+ counts (#659), so the old process still answering on the port is not mistaken for the new one.
111
+
112
+ Before copying the new bundle in over a running agent, `install` keeps the one it replaces as
113
+ `bin/ac-agent.prev.mjs` in the state dir (#659; one backup, overwritten by the next install). If the
114
+ new bundle never answers health at its own version, `install` rolls back:
115
+ 1. It stops the task/unit again. On Windows this ends `launch.js`'s retry loop, so it can't rerun the
116
+ broken bundle in the middle of the restore.
117
+ 2. It restores `ac-agent.prev.mjs` and starts it.
118
+ 3. It confirms the previous version answers, then prints
119
+ `rolled back: now serving <previous version> (<reason>)`. The previous version is read from the
120
+ running agent's own health answer just before it is stopped. If that read got no version (no
121
+ `cert.pem` to read it with, no answer, an error answer, or one without a version), any healthy
122
+ answer is accepted, and the line says
123
+ `now serving the previous bundle`.
124
+
125
+ Whenever a rollback is possible, the restart confirmation waits up to a minute instead of a few
126
+ seconds, because a false failure would undo a good release (the Windows cold start has never been
127
+ timed). This applies to a plain install too, such as Tooling's `Update-AcAgent.ps1`. A new bundle that
128
+ never comes up therefore takes about two minutes to report (a minute waiting for it, up to another
129
+ confirming the rollback; a hung connection adds to each attempt). If the install fails before the
130
+ restart (registering the task/unit, the certificate) and a backup was made, the running agent's bundle
131
+ is copied back onto the disk; the error is reported as before.
132
+
133
+ The install then **fails** (exit 1), because it did not take. If the rollback fails too, it prints
134
+ `rollback failed: ...`, leaves both bundles in place, and also exits 1; `ac-agent start` is the
135
+ fallback.
136
+
137
+ When there was no bundle to go back to, a restart that fails outright still reports the install as a
138
+ success, with `restart failed: <reason>; run ac-agent stop && ac-agent start`, and that manual
139
+ sequence is the fallback. A fresh install, with nothing running yet, prints neither line.
140
+
141
+ `install --update` is what the agent's own update route launches; it isn't meant to be run by hand.
142
+ It differs from a plain install in two ways:
143
+ - Its restart confirmation (when an agent is running to restart) is always the minute-long one,
144
+ even with no backup to roll back to.
145
+ - It records each step in `update.json` in the state dir:
146
+ `{status: "installing" | "ok" | "rolled-back" | "failed", from, target, at, message?}`, where
147
+ `from` is null when the previous version could not be read.
148
+ - Once `installing` is written, the outcome is recorded as `ok`, `rolled-back` or `failed`. That
149
+ covers a failure before the restart, and no agent running afterwards.
150
+ - A failure before `installing` is written (reading the config, the backup copy) leaves the
151
+ previous record as it was.
152
+ - It writes a temp file and renames it over, so a reader never sees a partial record.
153
+ - A write that fails (for example a locked file) prints a `warning: could not record update
154
+ status ...` line and never changes the install's outcome. The record then still shows the last
155
+ write that landed, which may be `installing`, so a reader should treat an old `installing` as
156
+ unknown.
157
+
158
+ A plain install never writes `update.json`. `ac-agent version --json` prints
159
+ `{"version", "apiVersion"}`, which is how a staged bundle's agent API version is read without starting
160
+ it.
113
161
 
114
162
  ### The firewall step
115
163
 
@@ -223,7 +271,8 @@ when this session's own AppData writes to the resolved config/state dirs are vir
223
271
  packaged app (see **Run install from a normal shell** above), which also prints a `warning: this
224
272
  session's AppData is virtualized (<packageFamily>); the logon task cannot see <stateDir> -- see
225
273
  README, Install` line to stderr. `serve` logs the same warning line once at startup, in the same
226
- circumstance, without refusing to start.
274
+ circumstance, without refusing to start. `update` (#659) is the last `install --update`'s record from
275
+ `update.json`, or `null` when there is none or it is unreadable.
227
276
 
228
277
  ## Pair
229
278
 
@@ -275,7 +324,7 @@ or `systemctl --user stop/start`) without touching the registration itself.
275
324
  | | Windows | Linux |
276
325
  |---|---|---|
277
326
  | Config (`agent.toml`) | `%APPDATA%\ac-agent\` | `~/.config/ac-agent/` |
278
- | State (cert, key, token hash, grants, log, pid, the installed bundle copy) | `%LOCALAPPDATA%\ac-agent\` | `~/.local/state/ac-agent/` |
327
+ | State (cert, key, token hash, grants, log, pid, the installed bundle copy and its one backup, `update.json`) | `%LOCALAPPDATA%\ac-agent\` | `~/.local/state/ac-agent/` |
279
328
 
280
329
  Both are overridable with `AC_AGENT_CONFIG_DIR` / `AC_AGENT_STATE_DIR` — **absolute paths only**; a
281
330
  relative override throws, naming the value. `install` resolves these once, in its own process, and
@@ -762,6 +811,71 @@ free text (a `broken` row's last non-empty output line, `unauthenticated`'s
762
811
  `gh`'s `--version` answering just under 15 s and then `auth status` timing out at 15 s too --
763
812
  under the console's own 100 s edge-request budget behind Cloudflare Access.
764
813
 
814
+ ## Update: `POST /v1/update`, `GET /v1/update`
815
+
816
+ The console can update a paired agent to a newer release (#659). Like `PUT /v1/grants`, both routes
817
+ are bearer-gated, have no capability, and refuse a caller carrying `X-AC-Plugin` with 403: installing
818
+ software is never something a grant could hand to a plugin.
819
+
820
+ `POST /v1/update {"version": "<x.y.z[-pre]>"}`:
821
+
822
+ 1. **Refuses before any download:**
823
+ - 400 for a body without such a version.
824
+ - 409 `self-update is not supported on this platform` (anything but Windows and Linux).
825
+ - 409 `already running <v>`.
826
+ - 409 `a downgrade is not supported` (semver precedence).
827
+ - 409 `update in progress` while another request is still verifying in this process, or while
828
+ `update.json` reads `installing` and is under 10 minutes old.
829
+ - 409 `jobs running` while any `exec/claude` job runs. This is checked again after verification,
830
+ just before the hand-off.
831
+ 2. **Resolves exactly that version on the public npm registry** (`https://registry.npmjs.org`, a
832
+ constant, never configurable):
833
+ - The metadata must name `@rackbops/ac-agent` and that version.
834
+ - Its tarball URL must be the registry's own canonical one for it.
835
+ - Its integrity must be one sha512.
836
+ 3. **Downloads the tarball** (60 s timeout) and refuses it over 20 MB or unless its sha512 matches.
837
+ A declared `content-length` over the cap is refused before reading. Without one, the body is read
838
+ in full and then checked, so only the timeout bounds that read.
839
+ 4. **Reads only `package/dist/ac-agent.mjs` and `package/package.json` out of it,** in memory,
840
+ refusing an archive that unpacks past 100 MB. No path from the archive is ever written.
841
+ `package.json` must name the same package and version.
842
+ 5. **Stages the bundle** as `<stateDir>/update/<version>/ac-agent.mjs` and runs it with
843
+ `version --json`. It must report that version and this agent's API version (the host pinned the
844
+ API version at pairing).
845
+ 6. **Answers.** Any refusal in steps 2-5 is 422 `{ok:false, error}` with the staging directory
846
+ removed. Otherwise it writes `update.json` `installing` and answers 202 `{ok, from, target}`.
847
+ 7. **Hands off** to the staged bundle's own `install --update` (see **Install**) in a process that
848
+ outlives this one:
849
+ - On Windows, a detached child, which `Stop-ScheduledTask` doesn't reach.
850
+ - On Linux, a transient user unit (`systemd-run --user --collect`). The agent's own unit would kill
851
+ a plain child when the install stops it.
852
+ - Either way the hand-off gets the agent's config and state dirs, and on Linux the agent's `PATH`,
853
+ because the install bakes `PATH` into the unit it writes.
854
+
855
+ That install restarts the agent, rolls back if the new version doesn't come up, and writes the
856
+ final `update.json`. A hand-off that can't be launched is recorded as `failed`:
857
+ - on Windows, a spawn error;
858
+ - on Linux, `systemd-run` missing, exiting non-zero (no user bus, for example) or timing out after
859
+ 30 s. It returns once the transient unit has started.
860
+
861
+ If the launched install dies before writing anything, the record stays `installing` and reads as
862
+ `unknown` after 10 minutes. After that age a new request is accepted again, so an install genuinely
863
+ still running past 10 minutes could have its staging directory cleared under it. The install
864
+ normally takes a minute or two, so the window is generous, but not a guarantee.
865
+
866
+ A fault that is not a refusal (an unexpected error) answers 500, with the staging directory removed
867
+ and nothing recorded.
868
+
869
+ `GET /v1/update` answers:
870
+ - `{ok, status: "idle"}` with no record;
871
+ - the record from `update.json`;
872
+ - `{ok, status: "unknown", record}` for an `installing` record over 10 minutes old, or whose `at` is
873
+ unreadable. Record writes are
874
+ best-effort, so an install can finish without its last write landing.
875
+
876
+ Every refusal, the verified hand-off, and a failed hand-off are logged as
877
+ `update: <from> -> <target> <outcome>`.
878
+
765
879
  ## Security model
766
880
 
767
881
  - **HTTPS only**, with a self-signed certificate generated once at install (RSA 2048, SHA-256,
package/dist/ac-agent.mjs CHANGED
@@ -17,7 +17,7 @@ import require$$0$1 from 'node:events';
17
17
  import require$$0$4 from 'node:diagnostics_channel';
18
18
  import require$$0$5 from 'node:buffer';
19
19
  import require$$4 from 'node:tls';
20
- import require$$0$6 from 'node:zlib';
20
+ import require$$0$6, { gunzipSync } from 'node:zlib';
21
21
  import require$$5$1 from 'node:perf_hooks';
22
22
  import require$$8 from 'node:util/types';
23
23
  import require$$2$1 from 'node:worker_threads';
@@ -47,7 +47,7 @@ function _mergeNamespaces(n, m) {
47
47
  }
48
48
 
49
49
  var name = "@rackbops/ac-agent";
50
- var version = "2.0.0-alpha.47";
50
+ var version = "2.0.0-alpha.49";
51
51
  var type = "module";
52
52
  var description = "The per-machine host agent (installed with npx @rackbops/ac-agent): an HTTPS health server plus install/status/uninstall for a Windows logon task or a systemd user unit.";
53
53
  var bin = {
@@ -5441,6 +5441,37 @@ function createApp(deps) {
5441
5441
  deps.logEvent(`preflight: ${okCount} ok, ${notOkCount} not ok (${perRow}) searchPath=${report.searchPath}`);
5442
5442
  return c.json({ ok: true, ...report });
5443
5443
  });
5444
+ // #659: the console's update control plane -- bearer-gated by the middleware above, no capability,
5445
+ // refuses a plugin-shaped caller exactly like PUT /v1/grants: installing software is never a verb a
5446
+ // grant could hand to a plugin.
5447
+ const updater = deps.update;
5448
+ if (updater !== undefined) {
5449
+ app.post("/v1/update", async (c) => {
5450
+ if (c.req.header("X-AC-Plugin") !== undefined) {
5451
+ return c.json({ ok: false, error: "updates are started by the console, not by a plugin" }, 403);
5452
+ }
5453
+ let body;
5454
+ try {
5455
+ body = await c.req.json();
5456
+ }
5457
+ catch {
5458
+ body = undefined;
5459
+ }
5460
+ const answer = await updater.request(body);
5461
+ const after = answer.after;
5462
+ if (after !== undefined) {
5463
+ // After the answer is on its way: the install this starts stops this process within seconds.
5464
+ setTimeout(() => void after(), 0);
5465
+ }
5466
+ return c.json(answer.body, answer.status);
5467
+ });
5468
+ app.get("/v1/update", (c) => {
5469
+ if (c.req.header("X-AC-Plugin") !== undefined) {
5470
+ return c.json({ ok: false, error: "update status is the console's, not a plugin's" }, 403);
5471
+ }
5472
+ return c.json(updater.current());
5473
+ });
5474
+ }
5444
5475
  mountVerbs(app, deps.verbs, { grants: deps.grants, machine: deps.machine });
5445
5476
  app.notFound((c) => c.json({ error: "no such verb" }, 404));
5446
5477
  return app;
@@ -21945,35 +21976,66 @@ function detectVirtualization(deps) {
21945
21976
  function bundleTargetPath(stateDir) {
21946
21977
  return path.join(stateDir, "bin", "ac-agent.mjs");
21947
21978
  }
21948
- // #277 review round 1 (correctness lens): `deps.platform.start()`/`.stop()` are backed by
21949
- // `runPowerShell`/`runCommand` (cli.ts), which resolve unconditionally on the child process's
21950
- // `close` event and never inspect its exit code -- a `Start-ScheduledTask`/`systemctl start` that
21951
- // runs but fails (bad permissions, a corrupted registration, ...) would otherwise be
21952
- // indistinguishable here from one that actually worked, so `installService` could print `restarted:
21953
- // now serving <version>` while nothing is actually listening. `confirmHealthy` below closes that gap
21954
- // by requiring a real, successful `/v1/health` response -- not just "the launch command exited" --
21955
- // before the restart is reported as a success. The new process is several process-creation hops away
21956
- // on Windows (Start-ScheduledTask -> wscript.exe -> JScript -> node), so this retries briefly rather
21957
- // than judging on a single immediate check.
21958
- //
21959
- // #277 review round 2: this budget (worst case: 7 delays x 400ms = 2.8s, plus the fetch attempts
21960
- // themselves) is a deliberately generous, but UNVERIFIED-ON-WINDOWS, guess -- live verification for
21961
- // this fix only covered Linux (nitro, over ssh); no live Windows box was installed against (Melody's
21962
- // install refuses under MSIX AppData virtualization regardless, #263; MILE/SHIORI are roshne's own
21963
- // boxes, out of scope here). A real Start-ScheduledTask -> wscript.exe -> JScript -> node cold start
21964
- // that took longer than this window would report a false "restart failed" over an agent that's
21965
- // actually fine a moment later -- a safe failure direction (never a false "restarted"), but still
21966
- // worth a live Windows timing check before trusting this number long-term.
21967
- const HEALTH_CHECK_ATTEMPTS = 8;
21968
- const HEALTH_CHECK_DELAY_MS = 400;
21969
- async function confirmHealthy(deps, port, certPem, expectedFingerprint) {
21970
- for (let attempt = 0; attempt < HEALTH_CHECK_ATTEMPTS; attempt++) {
21979
+ /** The one previous bundle `install` keeps while it restarts onto a new one (#659): restored when
21980
+ * the new bundle never answers health at its own version. Overwritten by the next install. */
21981
+ function bundleBackupPath(stateDir) {
21982
+ return path.join(stateDir, "bin", "ac-agent.prev.mjs");
21983
+ }
21984
+ function updateRecordPath(stateDir) {
21985
+ return path.join(stateDir, "update.json");
21986
+ }
21987
+ /** Best-effort: the record reports an install's outcome, it never decides it. A write that fails
21988
+ * (on Windows, `renameSync` over a file a reader holds open can throw EPERM/EBUSY; a full disk) is
21989
+ * printed and swallowed -- left to throw inside the restart sequence, it would roll back a release
21990
+ * that came up healthy (#659 review round 1). */
21991
+ function writeUpdateRecord(deps, record) {
21992
+ const target = updateRecordPath(deps.stateDir);
21993
+ const tmp = `${target}.tmp`;
21994
+ try {
21995
+ deps.writeFile(tmp, `${JSON.stringify(record, null, 2)}\n`);
21996
+ deps.renameFile(tmp, target);
21997
+ }
21998
+ catch (err) {
21999
+ deps.print(`warning: could not record update status ${record.status} in ${target}: ${err instanceof Error ? err.message : String(err)}`);
22000
+ }
22001
+ }
22002
+ /** `update.json` parsed, or null when absent or unreadable -- `status` reports it as-is. */
22003
+ function readUpdateRecord(deps) {
22004
+ const text = deps.readFile(updateRecordPath(deps.stateDir));
22005
+ if (text === null)
22006
+ return null;
22007
+ try {
22008
+ const value = JSON.parse(text);
22009
+ return typeof value === "object" && value !== null && typeof value.status === "string"
22010
+ ? value
22011
+ : null;
22012
+ }
22013
+ catch {
22014
+ return null;
22015
+ }
22016
+ }
22017
+ const HEALTH_BUDGET = { attempts: 8, delayMs: 400 };
22018
+ /** `install --update` (#659): a false failure here rolls back a good release, so the update path
22019
+ * waits up to a minute -- the Windows cold start (Start-ScheduledTask -> wscript -> node) has never
22020
+ * been timed live, see above. */
22021
+ const UPDATE_HEALTH_BUDGET = { attempts: 60, delayMs: 1000 };
22022
+ /** Waits for a health answer that is `ok` and, when `expectedVersion` is given, reports exactly that
22023
+ * version (#659): the old process still answering on the port is not the new bundle coming up. */
22024
+ async function confirmHealthy(deps, port, certPem, expectedFingerprint, expectedVersion, budget) {
22025
+ let lastVersion;
22026
+ for (let attempt = 0; attempt < budget.attempts; attempt++) {
21971
22027
  const health = await deps.fetchHealth(port, certPem, expectedFingerprint);
21972
- if (health?.ok)
21973
- return;
21974
- if (attempt < HEALTH_CHECK_ATTEMPTS - 1) {
21975
- await new Promise((resolve) => setTimeout(resolve, HEALTH_CHECK_DELAY_MS));
22028
+ if (health?.ok) {
22029
+ if (expectedVersion === null || health.version === expectedVersion)
22030
+ return;
22031
+ lastVersion = health.version;
21976
22032
  }
22033
+ if (attempt < budget.attempts - 1) {
22034
+ await new Promise((resolve) => setTimeout(resolve, budget.delayMs));
22035
+ }
22036
+ }
22037
+ if (lastVersion !== undefined) {
22038
+ throw new Error(`port ${port} still answers version ${lastVersion}, not ${expectedVersion}`);
21977
22039
  }
21978
22040
  throw new Error(`new process did not answer a health check on port ${port}`);
21979
22041
  }
@@ -22028,28 +22090,72 @@ async function installService(deps) {
22028
22090
  deps.print("warning: AC_AGENT_REPOS_ROOT overrides repos_root; the saved value will take effect when it is unset");
22029
22091
  }
22030
22092
  const bundlePath = bundleTargetPath(deps.stateDir);
22031
- deps.copyFile(deps.bundleSourcePath, bundlePath);
22032
- const { certPem } = await ensureCert({
22033
- stateDir: deps.stateDir,
22034
- hostname: deps.hostname(),
22035
- ips: deps.ips,
22036
- readFile: deps.readFile,
22037
- writeFile: (p, content, mode) => deps.writeFile(p, content, mode),
22038
- });
22039
- const fp = fingerprint(certPem);
22040
- // #263: refuse before registering a task/unit nothing can ever see, rather than leave a broken
22041
- // registration behind for `status`/`start` to fail against later. Must run after the state-dir
22042
- // writes above (so a genuinely virtualized session's own writes are what the probe finds) and
22043
- // before `deps.platform.install` (which is what actually registers the task/unit on Windows).
22044
- const virtualization = detectVirtualization(deps);
22045
- if (virtualization.virtualized) {
22046
- throw new Error(installRefusalMessage(virtualization.packageFamily, virtualization.vfsPath));
22093
+ const backupPath = bundleBackupPath(deps.stateDir);
22094
+ // #659: an install over a running agent keeps the bundle it is replacing, and the version that
22095
+ // bundle is serving, so a new bundle that never comes up can be undone. Read before the copy: once
22096
+ // the copy lands, the file on disk is no longer what the running process loaded.
22097
+ const wasRunning = isAgentRunning(deps);
22098
+ let backedUp = false;
22099
+ let previousVersion = null;
22100
+ if (wasRunning && deps.exists(bundlePath)) {
22101
+ previousVersion = await runningVersion(deps, port);
22102
+ deps.copyFile(bundlePath, backupPath);
22103
+ backedUp = true;
22104
+ }
22105
+ const record = (status, message) => {
22106
+ if (!deps.updateMode)
22107
+ return;
22108
+ writeUpdateRecord(deps, {
22109
+ status,
22110
+ from: previousVersion,
22111
+ target: deps.version,
22112
+ at: nowIso(deps),
22113
+ ...(message !== undefined ? { message } : {}),
22114
+ });
22115
+ };
22116
+ record("installing");
22117
+ let certPem;
22118
+ let fp;
22119
+ let result;
22120
+ try {
22121
+ deps.copyFile(deps.bundleSourcePath, bundlePath);
22122
+ certPem = (await ensureCert({
22123
+ stateDir: deps.stateDir,
22124
+ hostname: deps.hostname(),
22125
+ ips: deps.ips,
22126
+ readFile: deps.readFile,
22127
+ writeFile: (p, content, mode) => deps.writeFile(p, content, mode),
22128
+ })).certPem;
22129
+ fp = fingerprint(certPem);
22130
+ // #263: refuse before registering a task/unit nothing can ever see, rather than leave a broken
22131
+ // registration behind for `status`/`start` to fail against later. Must run after the state-dir
22132
+ // writes above (so a genuinely virtualized session's own writes are what the probe finds) and
22133
+ // before `deps.platform.install` (which is what actually registers the task/unit on Windows).
22134
+ const virtualization = detectVirtualization(deps);
22135
+ if (virtualization.virtualized) {
22136
+ throw new Error(installRefusalMessage(virtualization.packageFamily, virtualization.vfsPath));
22137
+ }
22138
+ result = await deps.platform.install({
22139
+ nodePath: deps.nodePath,
22140
+ bundlePath,
22141
+ user: deps.user,
22142
+ });
22143
+ }
22144
+ catch (err) {
22145
+ // #659 review round 1: a failure before the restart leaves the old process running, so put its
22146
+ // bundle back on disk (the next crash restart must load what was serving, not the half-installed
22147
+ // new one) and record the outcome -- `installing` must never be the last word.
22148
+ if (backedUp) {
22149
+ try {
22150
+ deps.copyFile(backupPath, bundlePath);
22151
+ }
22152
+ catch {
22153
+ // the backup is still in place for a manual restore; the original error is what matters
22154
+ }
22155
+ }
22156
+ record("failed", err instanceof Error ? err.message : String(err));
22157
+ throw err;
22047
22158
  }
22048
- const result = await deps.platform.install({
22049
- nodePath: deps.nodePath,
22050
- bundlePath,
22051
- user: deps.user,
22052
- });
22053
22159
  deps.print(`config: ${path.join(deps.configDir, "agent.toml")} (${loaded.source})`);
22054
22160
  deps.print(`state: ${deps.stateDir}`);
22055
22161
  deps.print(`fingerprint: ${fp}`);
@@ -22063,19 +22169,101 @@ async function installService(deps) {
22063
22169
  // path -- so `stopService`'s #238 ordering (task/unit stop before the orphaned-pid kill) is
22064
22170
  // exercised here unchanged, just followed by the same `platform.start()` the `start` verb calls.
22065
22171
  if (isAgentRunning(deps)) {
22172
+ // The long budget whenever a rollback is possible, not only under --update: a false failure there
22173
+ // stops a healthy new process and restores the old bundle. Tooling's Update-AcAgent.ps1 runs a
22174
+ // plain `install`, and the Windows cold start has never been timed (#659 review round 1). With
22175
+ // nothing to roll back to, a failure only prints, so the short budget stays.
22176
+ const budget = deps.updateMode || backedUp
22177
+ ? (deps.rollbackHealthBudget ?? UPDATE_HEALTH_BUDGET)
22178
+ : HEALTH_BUDGET;
22179
+ let restartError = null;
22066
22180
  try {
22067
22181
  await stopService(deps);
22068
22182
  await deps.platform.start();
22069
- await confirmHealthy(deps, port, certPem, fp);
22070
- deps.print(`restarted: now serving ${deps.version}`);
22183
+ await confirmHealthy(deps, port, certPem, fp, deps.version, budget);
22071
22184
  }
22072
22185
  catch (err) {
22073
- // The install itself already succeeded (the task/unit is registered against the new bundle);
22074
- // a restart failure must not be reported as an install failure -- the operator's fallback is
22075
- // the same manual stop/start #238 already documents.
22076
- deps.print(`restart failed: ${err instanceof Error ? err.message : String(err)}; run ac-agent stop && ac-agent start`);
22186
+ restartError = err;
22187
+ }
22188
+ if (restartError === null) {
22189
+ // Recorded only after the restart sequence: a failed record write must never look like a
22190
+ // failed restart (#659 review round 1).
22191
+ deps.print(`restarted: now serving ${deps.version}`);
22192
+ record("ok");
22193
+ return;
22194
+ }
22195
+ const reason = restartError instanceof Error ? restartError.message : String(restartError);
22196
+ if (!backedUp) {
22197
+ // Nothing to go back to (no bundle was in place): the install itself already succeeded (the
22198
+ // task/unit is registered against the new bundle), so a restart failure must not be reported
22199
+ // as an install failure -- the operator's fallback is the manual stop/start #238 documents.
22200
+ deps.print(`restart failed: ${reason}; run ac-agent stop && ac-agent start`);
22201
+ record("failed", reason);
22202
+ return;
22077
22203
  }
22204
+ await rollBack(deps, {
22205
+ port,
22206
+ certPem,
22207
+ fp,
22208
+ bundlePath,
22209
+ backupPath,
22210
+ previousVersion,
22211
+ budget,
22212
+ reason,
22213
+ });
22214
+ return;
22215
+ }
22216
+ // Nothing was running to restart (the agent died between the pre-check and now, or a fresh install).
22217
+ // Under --update the agent that launched this install was running, so this is a failure the record
22218
+ // must say, not leave at `installing` (#659 review round 2).
22219
+ record("failed", "no agent was running after the install; run ac-agent start");
22220
+ }
22221
+ function nowIso(deps) {
22222
+ return (deps.now ?? (() => new Date()))().toISOString();
22223
+ }
22224
+ /** The version the running agent reports before it is stopped (#659), read with the cert already in
22225
+ * the state dir -- null when there is no cert yet or the agent does not answer. */
22226
+ async function runningVersion(deps, port) {
22227
+ const certPem = deps.readFile(path.join(deps.stateDir, "cert.pem"));
22228
+ if (certPem === null)
22229
+ return null;
22230
+ const health = await deps.fetchHealth(port, certPem, fingerprint(certPem));
22231
+ return health?.ok && typeof health.version === "string" ? health.version : null;
22232
+ }
22233
+ /** #659: the new bundle never answered health at its own version, so restore the one it replaced.
22234
+ * Stops first, in #238's order (the task/unit, then the pid) -- on Windows that ends `launch.js`'s
22235
+ * retry loop, which would otherwise rerun the broken bundle a minute later, mid-restore. Throws
22236
+ * either way, so `install` exits 1: the install did not take. */
22237
+ async function rollBack(deps, ctx) {
22238
+ const record = (status, message) => {
22239
+ if (!deps.updateMode)
22240
+ return;
22241
+ writeUpdateRecord(deps, {
22242
+ status,
22243
+ from: ctx.previousVersion,
22244
+ target: deps.version,
22245
+ at: nowIso(deps),
22246
+ message,
22247
+ });
22248
+ };
22249
+ try {
22250
+ await stopService(deps);
22251
+ deps.copyFile(ctx.backupPath, ctx.bundlePath);
22252
+ await deps.platform.start();
22253
+ await confirmHealthy(deps, ctx.port, ctx.certPem, ctx.fp, ctx.previousVersion, ctx.budget);
22078
22254
  }
22255
+ catch (err) {
22256
+ const why = err instanceof Error ? err.message : String(err);
22257
+ deps.print(`rollback failed: ${why}; the new bundle failed first (${ctx.reason}). ` +
22258
+ `${ctx.backupPath} holds the previous bundle: copy it over ${ctx.bundlePath} if it is not ` +
22259
+ "already there, then run ac-agent start");
22260
+ record("failed", `${ctx.reason}; rollback failed: ${why}`);
22261
+ throw new Error(`install did not take and the rollback failed: ${why}`);
22262
+ }
22263
+ const serving = ctx.previousVersion ?? "the previous bundle";
22264
+ deps.print(`rolled back: now serving ${serving} (${ctx.reason})`);
22265
+ record("rolled-back", ctx.reason);
22266
+ throw new Error(`install did not take: ${deps.version} never came up; rolled back to ${serving}`);
22079
22267
  }
22080
22268
  async function status(deps, now = () => new Date()) {
22081
22269
  const loaded = loadConfig(deps.configDir, {
@@ -22113,6 +22301,7 @@ async function status(deps, now = () => new Date()) {
22113
22301
  grants,
22114
22302
  virtualized: virtualization.virtualized,
22115
22303
  lanAddress: deps.ips[0] ?? null,
22304
+ update: readUpdateRecord(deps),
22116
22305
  };
22117
22306
  }
22118
22307
  /** Generates a one-time pairing code (#48): refuses when already paired unless `force` (which is how
@@ -22199,6 +22388,21 @@ async function uninstall(deps, options) {
22199
22388
  }
22200
22389
  }
22201
22390
 
22391
+ var index$3 = /*#__PURE__*/Object.freeze({
22392
+ __proto__: null,
22393
+ detectVirtualization: detectVirtualization,
22394
+ installService: installService,
22395
+ isAgentRunning: isAgentRunning,
22396
+ killRunningAgent: killRunningAgent,
22397
+ pair: pair,
22398
+ readUpdateRecord: readUpdateRecord,
22399
+ status: status,
22400
+ stopService: stopService,
22401
+ uninstall: uninstall,
22402
+ updateRecordPath: updateRecordPath,
22403
+ writeUpdateRecord: writeUpdateRecord
22404
+ });
22405
+
22202
22406
  /**
22203
22407
  * Linux install (E4 §5 decision 7): `~/.config/systemd/user/ac-agent.service`, a `Type=simple`
22204
22408
  * unit restarting on failure, `PATH` baked at install time rather than read live at start (a
@@ -22609,6 +22813,8 @@ async function dispatch(argv, io) {
22609
22813
  port: { type: "string" },
22610
22814
  name: { type: "string" },
22611
22815
  "repos-root": { type: "string" },
22816
+ // #659: set only by the agent's own update route -- see ServiceDeps.updateMode.
22817
+ update: { type: "boolean", default: false },
22612
22818
  },
22613
22819
  allowPositionals: false,
22614
22820
  });
@@ -22619,6 +22825,7 @@ async function dispatch(argv, io) {
22619
22825
  port: values.port !== undefined ? Number.parseInt(values.port, 10) : undefined,
22620
22826
  name: values.name,
22621
22827
  reposRoot,
22828
+ update: values.update ?? false,
22622
22829
  });
22623
22830
  return 0;
22624
22831
  }
@@ -22661,9 +22868,19 @@ async function dispatch(argv, io) {
22661
22868
  case "publish-adopt":
22662
22869
  await io.publishAdopt();
22663
22870
  return 0;
22664
- case "version":
22665
- io.print(packageJson.version);
22871
+ case "version": {
22872
+ const { values } = parseArgs({
22873
+ args: rest,
22874
+ options: { json: { type: "boolean", default: false } },
22875
+ allowPositionals: false,
22876
+ });
22877
+ // #659: `--json` is how the update route reads a staged bundle's API version without
22878
+ // starting it.
22879
+ io.print(values.json
22880
+ ? JSON.stringify({ version: packageJson.version, apiVersion: AGENT_API_VERSION })
22881
+ : packageJson.version);
22666
22882
  return 0;
22883
+ }
22667
22884
  default:
22668
22885
  io.printError(USAGE);
22669
22886
  return 1;
@@ -22854,6 +23071,13 @@ async function buildPlatformOps(port, unitPath, launchScriptPath, configDir, sta
22854
23071
  function systemdUnitPath(homedir) {
22855
23072
  return path.posix.join(homedir, ".config", "systemd", "user", "ac-agent.service");
22856
23073
  }
23074
+ /** A `/v1/health` answer as `install`/`status` read it: the HTTP status, plus the reported version
23075
+ * when the body carries one (#659) -- that version is what tells a restarted new bundle from the old
23076
+ * process still answering on the port. */
23077
+ function healthResultFrom(ok, body) {
23078
+ const version = typeof body === "object" && body !== null ? body.version : undefined;
23079
+ return typeof version === "string" ? { ok, version } : { ok };
23080
+ }
22857
23081
  async function buildServiceDeps(overrides = {}) {
22858
23082
  const os = await import('node:os');
22859
23083
  const fs = await import('node:fs');
@@ -22886,6 +23110,7 @@ async function buildServiceDeps(overrides = {}) {
22886
23110
  },
22887
23111
  readFile,
22888
23112
  writeFile: (p, content, mode) => fs.writeFileSync(p, content, mode !== undefined ? { mode } : undefined),
23113
+ renameFile: (from, to) => fs.renameSync(from, to),
22889
23114
  removeFile: (p) => {
22890
23115
  try {
22891
23116
  fs.unlinkSync(p);
@@ -22912,6 +23137,7 @@ async function buildServiceDeps(overrides = {}) {
22912
23137
  machineNameOverride: overrides.name,
22913
23138
  portOverride: overrides.port,
22914
23139
  reposRootOverride: overrides.reposRoot,
23140
+ updateMode: overrides.update ?? false,
22915
23141
  env: process.env,
22916
23142
  osPlatform: process.platform,
22917
23143
  readdir: (p) => fs.readdirSync(p),
@@ -22939,7 +23165,14 @@ async function buildServiceDeps(overrides = {}) {
22939
23165
  const res = await undiciFetch(`https://127.0.0.1:${healthPort}/v1/health`, {
22940
23166
  dispatcher: agent,
22941
23167
  });
22942
- return { ok: res.ok };
23168
+ let body;
23169
+ try {
23170
+ body = await res.json();
23171
+ }
23172
+ catch {
23173
+ // an unparseable body still counts by its status alone, as before
23174
+ }
23175
+ return healthResultFrom(res.ok, body);
22943
23176
  }
22944
23177
  catch {
22945
23178
  return null;
@@ -22954,7 +23187,7 @@ async function realServe(overrides = {}) {
22954
23187
  const crypto = await import('node:crypto');
22955
23188
  const { resolveDirs } = await Promise.resolve().then(function () { return paths; });
22956
23189
  const { ensureCert, lanIPv4Candidates } = await Promise.resolve().then(function () { return cert; });
22957
- const { acquire, release } = await Promise.resolve().then(function () { return lock; });
23190
+ const { acquire, release: release$1 } = await Promise.resolve().then(function () { return lock; });
22958
23191
  const { openLog } = await Promise.resolve().then(function () { return log; });
22959
23192
  const { createRun, createSpawn } = await Promise.resolve().then(function () { return run$1; });
22960
23193
  const { createVerbRegistry } = await Promise.resolve().then(function () { return registry$2; });
@@ -23172,8 +23405,10 @@ async function realServe(overrides = {}) {
23172
23405
  run,
23173
23406
  });
23174
23407
  registerScanVerbs(registry$3, scans, { logEvent: (message) => log$1.write(message) });
23408
+ // Hoisted (#659): the update route refuses while any job is running.
23409
+ const jobs = createJobRegistry();
23175
23410
  registerClaudeVerbs(registry$3, {
23176
- jobs: createJobRegistry(),
23411
+ jobs,
23177
23412
  resolveRepo: (name) => resolveRepo(loaded.config.reposRoot, name, repoFs),
23178
23413
  which: which$1,
23179
23414
  });
@@ -23192,6 +23427,54 @@ async function realServe(overrides = {}) {
23192
23427
  onInconclusive: () => log$1.write("claude desktop: detection inconclusive (a command timed out, could not start, threw, or overran its deadline); the held answer, if any, is unchanged"),
23193
23428
  });
23194
23429
  const stopClaudeDesktopWatch = claudeDesktopWatch.start();
23430
+ // #659 PR 2: POST/GET /v1/update. The registry is the public npm one, never configurable; the
23431
+ // hand-off starts the verified staged bundle's own `install --update`, which restarts this agent.
23432
+ const { createUpdater, createHandOff, selfUpdateSupported } = await Promise.resolve().then(function () { return updater; });
23433
+ const { NPM_REGISTRY } = await Promise.resolve().then(function () { return release; });
23434
+ const { readUpdateRecord, writeUpdateRecord } = await Promise.resolve().then(function () { return index$3; });
23435
+ const recordDeps = {
23436
+ stateDir: dirs.stateDir,
23437
+ readFile,
23438
+ writeFile: (p, content) => fs.writeFileSync(p, content),
23439
+ renameFile: (from, to) => fs.renameSync(from, to),
23440
+ print: (line) => log$1.write(line),
23441
+ };
23442
+ const updater$1 = createUpdater({
23443
+ version: packageJson.version,
23444
+ apiVersion: AGENT_API_VERSION,
23445
+ stateDir: dirs.stateDir,
23446
+ configDir: dirs.configDir,
23447
+ registry: NPM_REGISTRY,
23448
+ fetch: (url, init) => fetch(url, init),
23449
+ readStagedVersion: async (staged) => {
23450
+ const result = await run(process.execPath, [staged, "version", "--json"], {
23451
+ timeoutMs: 15_000,
23452
+ });
23453
+ if (result.code !== 0)
23454
+ throw new Error(`exit ${result.code}: ${result.stderr.trim()}`);
23455
+ return result.stdout;
23456
+ },
23457
+ writeFile: (p, content) => fs.writeFileSync(p, content),
23458
+ mkdir: (p) => fs.mkdirSync(p, { recursive: true }),
23459
+ removeDir: (p) => fs.rmSync(p, { recursive: true, force: true }),
23460
+ readRecord: () => readUpdateRecord(recordDeps),
23461
+ writeRecord: (record) => writeUpdateRecord(recordDeps, record),
23462
+ handOff: createHandOff({
23463
+ platform: process.platform,
23464
+ execPath: process.execPath,
23465
+ configDir: dirs.configDir,
23466
+ stateDir: dirs.stateDir,
23467
+ path: process.env.PATH ?? "",
23468
+ now: () => new Date(),
23469
+ spawn: createSpawn(),
23470
+ run,
23471
+ env: process.env,
23472
+ }),
23473
+ selfUpdateSupported: selfUpdateSupported(process.platform),
23474
+ runningJobs: () => jobs.runningCount(),
23475
+ now: () => new Date(),
23476
+ logEvent: (message) => log$1.write(message),
23477
+ });
23195
23478
  const app = createApp({
23196
23479
  version: packageJson.version,
23197
23480
  apiVersion: AGENT_API_VERSION,
@@ -23212,6 +23495,7 @@ async function realServe(overrides = {}) {
23212
23495
  grants: grants$1,
23213
23496
  preflight: () => runPreflight(preflightDeps),
23214
23497
  claudeDesktop: claudeDesktopWatch.current,
23498
+ update: updater$1,
23215
23499
  });
23216
23500
  const running = await startServer({
23217
23501
  app,
@@ -23224,7 +23508,7 @@ async function realServe(overrides = {}) {
23224
23508
  const shutdown = () => {
23225
23509
  stopClaudeDesktopWatch();
23226
23510
  void running.close().then(() => {
23227
- release(dirs.stateDir, (p) => {
23511
+ release$1(dirs.stateDir, (p) => {
23228
23512
  try {
23229
23513
  fs.unlinkSync(p);
23230
23514
  }
@@ -64118,14 +64402,14 @@ function acquire(stateDir, deps) {
64118
64402
  }
64119
64403
  return { ok: false, reason: `already running (pid ${existingPid})` };
64120
64404
  }
64121
- function release(stateDir, unlink) {
64405
+ function release$1(stateDir, unlink) {
64122
64406
  unlink(path.join(stateDir, "agent.pid"));
64123
64407
  }
64124
64408
 
64125
64409
  var lock = /*#__PURE__*/Object.freeze({
64126
64410
  __proto__: null,
64127
64411
  acquire: acquire,
64128
- release: release
64412
+ release: release$1
64129
64413
  });
64130
64414
 
64131
64415
  /** `agent.log`, rotated to `agent.log.1` past 1 MiB on open -- checked once at `serve` startup,
@@ -74959,4 +75243,423 @@ var tunnel = /*#__PURE__*/Object.freeze({
74959
75243
  registerTunnelVerb: registerTunnelVerb
74960
75244
  });
74961
75245
 
74962
- export { AGENT_API_VERSION, buildRepoFs, buildWhichFs, dispatch, isEntryPoint, main, realExists };
75246
+ /**
75247
+ * #659 PR 2: where an update comes from and what is allowed. The agent installs only an exact
75248
+ * published version of `@rackbops/ac-agent` from the public npm registry -- never a URL, a tarball
75249
+ * or a registry the caller names. The registry base is a constant here; the only other value
75250
+ * `createUpdater` ever receives is a test's loopback stub, passed through its own wiring.
75251
+ */
75252
+ const NPM_REGISTRY = "https://registry.npmjs.org";
75253
+ const AGENT_PACKAGE = "@rackbops/ac-agent";
75254
+ /** `major.minor.patch` with an optional prerelease -- the only shape a version string may take
75255
+ * anywhere in the update path (and the host's own copy, #659 PR 3). */
75256
+ const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/;
75257
+ /** A refusal the route answers with its message: the update is not possible, and nothing was
75258
+ * installed. Anything else thrown is a fault. */
75259
+ class UpdateRefusal extends Error {
75260
+ }
75261
+ function tarballUrlFor(registry, version) {
75262
+ return `${registry}/${AGENT_PACKAGE}/-/ac-agent-${version}.tgz`;
75263
+ }
75264
+ const INTEGRITY = /^sha512-[A-Za-z0-9+/]+={0,2}$/;
75265
+ /** The registry's metadata for exactly `version`, checked: the right package and version, a tarball
75266
+ * at the registry's own canonical URL for it, and a single sha512 integrity. */
75267
+ async function resolveRelease(version, fetchImpl, registry, timeoutMs = 15_000) {
75268
+ if (!VERSION_PATTERN.test(version))
75269
+ throw new UpdateRefusal(`not a version: ${version}`);
75270
+ const url = `${registry}/${AGENT_PACKAGE.replace("/", "%2f")}/${version}`;
75271
+ let res;
75272
+ try {
75273
+ res = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) });
75274
+ }
75275
+ catch (err) {
75276
+ throw new UpdateRefusal(`registry unreachable: ${err instanceof Error ? err.message : String(err)}`);
75277
+ }
75278
+ if (res.status === 404)
75279
+ throw new UpdateRefusal(`${version} is not published`);
75280
+ if (!res.ok)
75281
+ throw new UpdateRefusal(`registry answered ${res.status} for ${version}`);
75282
+ const meta = (await res.json());
75283
+ if (meta.name !== AGENT_PACKAGE || meta.version !== version) {
75284
+ throw new UpdateRefusal(`registry metadata is not ${AGENT_PACKAGE}@${version}`);
75285
+ }
75286
+ const expectedTarball = tarballUrlFor(registry, version);
75287
+ if (meta.dist?.tarball !== expectedTarball) {
75288
+ throw new UpdateRefusal(`unexpected tarball URL for ${version}`);
75289
+ }
75290
+ const integrity = meta.dist?.integrity;
75291
+ if (typeof integrity !== "string" || !INTEGRITY.test(integrity)) {
75292
+ throw new UpdateRefusal(`no single sha512 integrity for ${version}`);
75293
+ }
75294
+ return { version, tarballUrl: expectedTarball, integrity };
75295
+ }
75296
+ /** Semver precedence for `VERSION_PATTERN` strings: numeric core, then a release above any of its
75297
+ * prereleases, then prerelease identifiers left to right (numeric < alphanumeric, numerics by
75298
+ * value). Negative when `a` is older. */
75299
+ function compareVersions(a, b) {
75300
+ const [coreA = "", preA] = splitVersion(a);
75301
+ const [coreB = "", preB] = splitVersion(b);
75302
+ const numsA = coreA.split(".").map(Number);
75303
+ const numsB = coreB.split(".").map(Number);
75304
+ for (let i = 0; i < 3; i++) {
75305
+ const diff = (numsA[i] ?? 0) - (numsB[i] ?? 0);
75306
+ if (diff !== 0)
75307
+ return diff;
75308
+ }
75309
+ if (preA === undefined || preB === undefined) {
75310
+ return preA === preB ? 0 : preA === undefined ? 1 : -1;
75311
+ }
75312
+ const idsA = preA.split(".");
75313
+ const idsB = preB.split(".");
75314
+ for (let i = 0; i < Math.max(idsA.length, idsB.length); i++) {
75315
+ const x = idsA[i];
75316
+ const y = idsB[i];
75317
+ if (x === undefined)
75318
+ return -1;
75319
+ if (y === undefined)
75320
+ return 1;
75321
+ const xNum = /^\d+$/.test(x);
75322
+ const yNum = /^\d+$/.test(y);
75323
+ if (xNum && yNum) {
75324
+ const diff = Number(x) - Number(y);
75325
+ if (diff !== 0)
75326
+ return diff;
75327
+ }
75328
+ else if (xNum !== yNum) {
75329
+ return xNum ? -1 : 1;
75330
+ }
75331
+ else if (x !== y) {
75332
+ return x < y ? -1 : 1;
75333
+ }
75334
+ }
75335
+ return 0;
75336
+ }
75337
+ function splitVersion(v) {
75338
+ const dash = v.indexOf("-");
75339
+ return dash === -1 ? [v, undefined] : [v.slice(0, dash), v.slice(dash + 1)];
75340
+ }
75341
+
75342
+ var release = /*#__PURE__*/Object.freeze({
75343
+ __proto__: null,
75344
+ AGENT_PACKAGE: AGENT_PACKAGE,
75345
+ NPM_REGISTRY: NPM_REGISTRY,
75346
+ UpdateRefusal: UpdateRefusal,
75347
+ VERSION_PATTERN: VERSION_PATTERN,
75348
+ compareVersions: compareVersions,
75349
+ resolveRelease: resolveRelease,
75350
+ tarballUrlFor: tarballUrlFor
75351
+ });
75352
+
75353
+ /** The published tarball is ~0.5 MB; anything near this is not our package. */
75354
+ const MAX_TARBALL_BYTES = 20 * 1024 * 1024;
75355
+ /** Downloads the release's tarball and checks its sha512 against the registry's `integrity` before
75356
+ * anything else reads it (#659). A mismatch is a refusal; nothing is written. */
75357
+ async function downloadVerified(release, fetchImpl, timeoutMs = 60_000) {
75358
+ let res;
75359
+ try {
75360
+ res = await fetchImpl(release.tarballUrl, { signal: AbortSignal.timeout(timeoutMs) });
75361
+ }
75362
+ catch (err) {
75363
+ throw new UpdateRefusal(`tarball download failed: ${err instanceof Error ? err.message : String(err)}`);
75364
+ }
75365
+ if (!res.ok)
75366
+ throw new UpdateRefusal(`tarball download answered ${res.status}`);
75367
+ const declared = Number(res.headers.get("content-length") ?? "0");
75368
+ if (declared > MAX_TARBALL_BYTES)
75369
+ throw new UpdateRefusal("tarball is too large");
75370
+ let bytes;
75371
+ try {
75372
+ bytes = Buffer.from(await res.arrayBuffer());
75373
+ }
75374
+ catch (err) {
75375
+ // The body read can outlast the timeout (AbortError) or drop: a refusal, not a fault (#659 PR 2
75376
+ // review round 2).
75377
+ throw new UpdateRefusal(`tarball download failed: ${err instanceof Error ? err.message : String(err)}`);
75378
+ }
75379
+ if (bytes.length > MAX_TARBALL_BYTES)
75380
+ throw new UpdateRefusal("tarball is too large");
75381
+ const expected = Buffer.from(release.integrity.slice("sha512-".length), "base64");
75382
+ const actual = createHash("sha512").update(bytes).digest();
75383
+ if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
75384
+ throw new UpdateRefusal(`tarball sha512 does not match the registry for ${release.version}`);
75385
+ }
75386
+ return bytes;
75387
+ }
75388
+ /** The unpacked package is ~2 MB; a gzip bomb stops here (#659 PR 2 review round 1). */
75389
+ const MAX_UNPACKED_BYTES = 100 * 1024 * 1024;
75390
+ const BUNDLE_ENTRY = "package/dist/ac-agent.mjs";
75391
+ const MANIFEST_ENTRY = "package/package.json";
75392
+ /** Reads the two entries the update needs out of a verified `.tgz` -- the bundle and
75393
+ * `package.json` -- by walking the ustar headers in memory. No path from the archive is ever
75394
+ * written anywhere: only these two exact names are taken, everything else is skipped. */
75395
+ function extractBundle(tgz, version, maxUnpackedBytes = MAX_UNPACKED_BYTES) {
75396
+ let tar;
75397
+ try {
75398
+ tar = gunzipSync(tgz, { maxOutputLength: maxUnpackedBytes });
75399
+ }
75400
+ catch (err) {
75401
+ if (err?.code === "ERR_BUFFER_TOO_LARGE") {
75402
+ throw new UpdateRefusal("tarball unpacks too large");
75403
+ }
75404
+ throw new UpdateRefusal("tarball is not gzip");
75405
+ }
75406
+ const found = new Map();
75407
+ let offset = 0;
75408
+ while (offset + 512 <= tar.length) {
75409
+ const header = tar.subarray(offset, offset + 512);
75410
+ if (header.every((byte) => byte === 0))
75411
+ break;
75412
+ const name = cString(header.subarray(0, 100));
75413
+ const prefix = cString(header.subarray(345, 500));
75414
+ const size = Number.parseInt(cString(header.subarray(124, 136)).trim() || "0", 8);
75415
+ const type = String.fromCharCode(header[156] ?? 0);
75416
+ if (!Number.isFinite(size) || size < 0)
75417
+ throw new UpdateRefusal("tarball header is malformed");
75418
+ const full = prefix ? `${prefix}/${name}` : name;
75419
+ const bodyStart = offset + 512;
75420
+ if ((type === "0" || type === "\0") && (full === BUNDLE_ENTRY || full === MANIFEST_ENTRY)) {
75421
+ found.set(full, Buffer.from(tar.subarray(bodyStart, bodyStart + size)));
75422
+ }
75423
+ offset = bodyStart + Math.ceil(size / 512) * 512;
75424
+ }
75425
+ const bundle = found.get(BUNDLE_ENTRY);
75426
+ const manifestBytes = found.get(MANIFEST_ENTRY);
75427
+ if (bundle === undefined || manifestBytes === undefined) {
75428
+ throw new UpdateRefusal("tarball has no agent bundle");
75429
+ }
75430
+ let manifest;
75431
+ try {
75432
+ manifest = JSON.parse(manifestBytes.toString("utf8"));
75433
+ }
75434
+ catch {
75435
+ throw new UpdateRefusal("tarball package.json is unreadable");
75436
+ }
75437
+ if (manifest.name !== AGENT_PACKAGE || manifest.version !== version) {
75438
+ throw new UpdateRefusal(`tarball is not ${AGENT_PACKAGE}@${version}`);
75439
+ }
75440
+ return bundle;
75441
+ }
75442
+ function cString(field) {
75443
+ const end = field.indexOf(0);
75444
+ return field.subarray(0, end === -1 ? field.length : end).toString("utf8");
75445
+ }
75446
+
75447
+ /**
75448
+ * #659 PR 2: the agent's side of an update. `POST /v1/update { version }` resolves that exact
75449
+ * version on npm, downloads and verifies it, stages the bundle, checks the staged bundle's own
75450
+ * agent API version, then hands off to the staged bundle's `install --update` in a process that
75451
+ * outlives this one (PR 1's install restarts the agent, rolls back on failure, and writes
75452
+ * `update.json`). This module never stops or restarts anything itself, and nothing in this process
75453
+ * waits for the install to finish (on Linux it waits only for `systemd-run` to start the unit).
75454
+ */
75455
+ /** An `installing` record older than this is treated as unknown, not in progress: record writes are
75456
+ * best-effort (#659 PR 1), so an install can finish without its last write landing. */
75457
+ const STALE_INSTALLING_MS = 10 * 60 * 1000;
75458
+ function refuse(status, error) {
75459
+ return { status, body: { ok: false, error } };
75460
+ }
75461
+ function isStaleInstalling(record, now) {
75462
+ const at = Date.parse(record.at);
75463
+ return (record.status === "installing" && (Number.isNaN(at) || now.getTime() - at > STALE_INSTALLING_MS));
75464
+ }
75465
+ function createUpdater(deps) {
75466
+ // One request at a time in this process (#659 PR 2 review round 1): the in-progress check below
75467
+ // reads update.json, which is only written once verification finishes -- up to ~90 s later -- so
75468
+ // two overlapping POSTs (a double click, a retry) would otherwise both pass it, clobber one staging
75469
+ // directory and start two installs. Set synchronously, before the first await.
75470
+ let inFlight = null;
75471
+ return {
75472
+ current() {
75473
+ const record = deps.readRecord();
75474
+ if (record === null)
75475
+ return { ok: true, status: "idle" };
75476
+ if (isStaleInstalling(record, deps.now()))
75477
+ return { ok: true, status: "unknown", record };
75478
+ return { ok: true, ...record };
75479
+ },
75480
+ async request(body) {
75481
+ const target = typeof body === "object" && body !== null
75482
+ ? body.version
75483
+ : undefined;
75484
+ const refuseLogged = (status, error) => {
75485
+ // `target` is the caller's, unvalidated on the 400 path: quoted and cut, so it can't forge
75486
+ // log lines (#659 PR 2 review round 2).
75487
+ // Never String(target): an object whose `toString` isn't callable throws there (round 3).
75488
+ const shown = typeof target === "string" && VERSION_PATTERN.test(target)
75489
+ ? target
75490
+ : JSON.stringify(target ?? null).slice(0, 80);
75491
+ deps.logEvent(`update: ${deps.version} -> ${shown} refused (${error})`);
75492
+ return refuse(status, error);
75493
+ };
75494
+ if (typeof target !== "string" || !VERSION_PATTERN.test(target)) {
75495
+ return refuseLogged(400, "body must be { version: <x.y.z[-pre]> }");
75496
+ }
75497
+ if (!deps.selfUpdateSupported) {
75498
+ return refuseLogged(409, "self-update is not supported on this platform");
75499
+ }
75500
+ if (target === deps.version)
75501
+ return refuseLogged(409, `already running ${target}`);
75502
+ if (compareVersions(target, deps.version) < 0) {
75503
+ return refuseLogged(409, `a downgrade is not supported (running ${deps.version})`);
75504
+ }
75505
+ if (inFlight !== null)
75506
+ return refuseLogged(409, `update in progress (${inFlight})`);
75507
+ if (deps.runningJobs() > 0) {
75508
+ return refuseLogged(409, "jobs running; try again when they finish");
75509
+ }
75510
+ const record = deps.readRecord();
75511
+ if (record !== null &&
75512
+ record.status === "installing" &&
75513
+ !isStaleInstalling(record, deps.now())) {
75514
+ return refuseLogged(409, `update in progress (${record.target})`);
75515
+ }
75516
+ inFlight = target;
75517
+ try {
75518
+ return await verifyAndStage(target, refuseLogged);
75519
+ }
75520
+ finally {
75521
+ inFlight = null;
75522
+ }
75523
+ },
75524
+ };
75525
+ async function verifyAndStage(target, refuseLogged) {
75526
+ // The whole update/ directory, not just this version's: an earlier staged bundle has either been
75527
+ // installed (copied into bin/) or refused, so nothing in it is ever needed again.
75528
+ const updateRoot = path.join(deps.stateDir, "update");
75529
+ const stageDir = path.join(updateRoot, target);
75530
+ const stagedPath = path.join(stageDir, "ac-agent.mjs");
75531
+ try {
75532
+ const release = await resolveRelease(target, deps.fetch, deps.registry);
75533
+ const tgz = await downloadVerified(release, deps.fetch);
75534
+ const bundle = extractBundle(tgz, target);
75535
+ deps.removeDir(updateRoot);
75536
+ deps.mkdir(stageDir);
75537
+ deps.writeFile(stagedPath, bundle);
75538
+ await checkStaged(deps, stagedPath, target);
75539
+ }
75540
+ catch (err) {
75541
+ deps.removeDir(stageDir);
75542
+ if (err instanceof UpdateRefusal)
75543
+ return refuseLogged(422, err.message);
75544
+ throw err;
75545
+ }
75546
+ // A job may have started during the download (#659 PR 2 review round 1): the install would
75547
+ // restart the agent under it.
75548
+ if (deps.runningJobs() > 0) {
75549
+ deps.removeDir(stageDir);
75550
+ return refuseLogged(409, "jobs running; try again when they finish");
75551
+ }
75552
+ deps.writeRecord({
75553
+ status: "installing",
75554
+ from: deps.version,
75555
+ target,
75556
+ at: deps.now().toISOString(),
75557
+ });
75558
+ deps.logEvent(`update: ${deps.version} -> ${target} verified, handing off to install`);
75559
+ return {
75560
+ status: 202,
75561
+ body: { ok: true, from: deps.version, target },
75562
+ after: async () => {
75563
+ try {
75564
+ await deps.handOff(stagedPath);
75565
+ }
75566
+ catch (err) {
75567
+ const message = `hand-off failed: ${err instanceof Error ? err.message : String(err)}`;
75568
+ deps.writeRecord({
75569
+ status: "failed",
75570
+ from: deps.version,
75571
+ target,
75572
+ at: deps.now().toISOString(),
75573
+ message,
75574
+ });
75575
+ deps.logEvent(`update: ${deps.version} -> ${target} ${message}`);
75576
+ }
75577
+ },
75578
+ };
75579
+ }
75580
+ }
75581
+ /** The staged bundle must report exactly the target version and this agent's API version -- the
75582
+ * host pinned the API version at pairing, and a mismatch is a 409 on every call after. */
75583
+ async function checkStaged(deps, stagedPath, target) {
75584
+ let reported;
75585
+ try {
75586
+ reported = JSON.parse(await deps.readStagedVersion(stagedPath));
75587
+ }
75588
+ catch (err) {
75589
+ throw new UpdateRefusal(`the staged bundle did not report its version: ${err instanceof Error ? err.message : String(err)}`);
75590
+ }
75591
+ if (reported.version !== target) {
75592
+ throw new UpdateRefusal(`the staged bundle reports ${String(reported.version)}, not ${target}`);
75593
+ }
75594
+ if (reported.apiVersion !== deps.apiVersion) {
75595
+ throw new UpdateRefusal(`the staged bundle speaks agent API ${String(reported.apiVersion)}, this agent ${deps.apiVersion}`);
75596
+ }
75597
+ }
75598
+ function selfUpdateSupported(platform) {
75599
+ return platform === "win32" || platform === "linux";
75600
+ }
75601
+ /** The command that runs the staged bundle's `install --update` so it survives this agent being
75602
+ * stopped. Windows: a detached child (`Stop-ScheduledTask` does not reach it, #238). Linux: its own
75603
+ * transient user unit -- the agent's unit uses systemd's default `KillMode=control-group`, which
75604
+ * would kill a plain child along with the agent. */
75605
+ function handOffCommand(opts, stagedPath) {
75606
+ const install = [stagedPath, "install", "--update"];
75607
+ if (opts.platform === "win32")
75608
+ return { cmd: opts.execPath, args: install };
75609
+ if (opts.platform === "linux") {
75610
+ return {
75611
+ cmd: "systemd-run",
75612
+ args: [
75613
+ "--user",
75614
+ "--collect",
75615
+ "--quiet",
75616
+ `--unit=ac-agent-update-${opts.now().getTime()}`,
75617
+ `--setenv=AC_AGENT_CONFIG_DIR=${opts.configDir}`,
75618
+ `--setenv=AC_AGENT_STATE_DIR=${opts.stateDir}`,
75619
+ `--setenv=PATH=${opts.path}`,
75620
+ opts.execPath,
75621
+ ...install,
75622
+ ],
75623
+ };
75624
+ }
75625
+ return null;
75626
+ }
75627
+ function createHandOff(opts) {
75628
+ return async (stagedPath) => {
75629
+ const command = handOffCommand(opts, stagedPath);
75630
+ if (command === null)
75631
+ throw new Error(`self-update is not supported on ${opts.platform}`);
75632
+ if (opts.platform === "linux") {
75633
+ // The environment the install needs travels as --setenv arguments; the unit never inherits
75634
+ // this process's.
75635
+ const result = await opts.run(command.cmd, command.args, { timeoutMs: 30_000 });
75636
+ if (result.code !== 0) {
75637
+ const why = result.timedOut ? "timed out" : `exited ${result.code}`;
75638
+ throw new Error(`systemd-run ${why}: ${result.stderr.trim().slice(0, 300)}`);
75639
+ }
75640
+ return;
75641
+ }
75642
+ await opts.spawn(command.cmd, command.args, {
75643
+ detached: true,
75644
+ stdio: "ignore",
75645
+ windowsHide: true,
75646
+ env: {
75647
+ ...opts.env,
75648
+ AC_AGENT_CONFIG_DIR: opts.configDir,
75649
+ AC_AGENT_STATE_DIR: opts.stateDir,
75650
+ },
75651
+ });
75652
+ };
75653
+ }
75654
+
75655
+ var updater = /*#__PURE__*/Object.freeze({
75656
+ __proto__: null,
75657
+ STALE_INSTALLING_MS: STALE_INSTALLING_MS,
75658
+ createHandOff: createHandOff,
75659
+ createUpdater: createUpdater,
75660
+ handOffCommand: handOffCommand,
75661
+ isStaleInstalling: isStaleInstalling,
75662
+ selfUpdateSupported: selfUpdateSupported
75663
+ });
75664
+
75665
+ export { AGENT_API_VERSION, buildRepoFs, buildWhichFs, dispatch, healthResultFrom, isEntryPoint, main, realExists };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rackbops/ac-agent",
3
- "version": "2.0.0-alpha.47",
3
+ "version": "2.0.0-alpha.49",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The per-machine host agent (installed with npx @rackbops/ac-agent): an HTTPS health server plus install/status/uninstall for a Windows logon task or a systemd user unit.",