@rackbops/ac-agent 2.0.0-alpha.48 → 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.
package/README.md CHANGED
@@ -811,6 +811,71 @@ free text (a `broken` row's last non-empty output line, `unauthenticated`'s
811
811
  `gh`'s `--version` answering just under 15 s and then `auth status` timing out at 15 s too --
812
812
  under the console's own 100 s edge-request budget behind Cloudflare Access.
813
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
+
814
879
  ## Security model
815
880
 
816
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.48";
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;
@@ -22357,6 +22388,21 @@ async function uninstall(deps, options) {
22357
22388
  }
22358
22389
  }
22359
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
+
22360
22406
  /**
22361
22407
  * Linux install (E4 §5 decision 7): `~/.config/systemd/user/ac-agent.service`, a `Type=simple`
22362
22408
  * unit restarting on failure, `PATH` baked at install time rather than read live at start (a
@@ -23141,7 +23187,7 @@ async function realServe(overrides = {}) {
23141
23187
  const crypto = await import('node:crypto');
23142
23188
  const { resolveDirs } = await Promise.resolve().then(function () { return paths; });
23143
23189
  const { ensureCert, lanIPv4Candidates } = await Promise.resolve().then(function () { return cert; });
23144
- const { acquire, release } = await Promise.resolve().then(function () { return lock; });
23190
+ const { acquire, release: release$1 } = await Promise.resolve().then(function () { return lock; });
23145
23191
  const { openLog } = await Promise.resolve().then(function () { return log; });
23146
23192
  const { createRun, createSpawn } = await Promise.resolve().then(function () { return run$1; });
23147
23193
  const { createVerbRegistry } = await Promise.resolve().then(function () { return registry$2; });
@@ -23359,8 +23405,10 @@ async function realServe(overrides = {}) {
23359
23405
  run,
23360
23406
  });
23361
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();
23362
23410
  registerClaudeVerbs(registry$3, {
23363
- jobs: createJobRegistry(),
23411
+ jobs,
23364
23412
  resolveRepo: (name) => resolveRepo(loaded.config.reposRoot, name, repoFs),
23365
23413
  which: which$1,
23366
23414
  });
@@ -23379,6 +23427,54 @@ async function realServe(overrides = {}) {
23379
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"),
23380
23428
  });
23381
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
+ });
23382
23478
  const app = createApp({
23383
23479
  version: packageJson.version,
23384
23480
  apiVersion: AGENT_API_VERSION,
@@ -23399,6 +23495,7 @@ async function realServe(overrides = {}) {
23399
23495
  grants: grants$1,
23400
23496
  preflight: () => runPreflight(preflightDeps),
23401
23497
  claudeDesktop: claudeDesktopWatch.current,
23498
+ update: updater$1,
23402
23499
  });
23403
23500
  const running = await startServer({
23404
23501
  app,
@@ -23411,7 +23508,7 @@ async function realServe(overrides = {}) {
23411
23508
  const shutdown = () => {
23412
23509
  stopClaudeDesktopWatch();
23413
23510
  void running.close().then(() => {
23414
- release(dirs.stateDir, (p) => {
23511
+ release$1(dirs.stateDir, (p) => {
23415
23512
  try {
23416
23513
  fs.unlinkSync(p);
23417
23514
  }
@@ -64305,14 +64402,14 @@ function acquire(stateDir, deps) {
64305
64402
  }
64306
64403
  return { ok: false, reason: `already running (pid ${existingPid})` };
64307
64404
  }
64308
- function release(stateDir, unlink) {
64405
+ function release$1(stateDir, unlink) {
64309
64406
  unlink(path.join(stateDir, "agent.pid"));
64310
64407
  }
64311
64408
 
64312
64409
  var lock = /*#__PURE__*/Object.freeze({
64313
64410
  __proto__: null,
64314
64411
  acquire: acquire,
64315
- release: release
64412
+ release: release$1
64316
64413
  });
64317
64414
 
64318
64415
  /** `agent.log`, rotated to `agent.log.1` past 1 MiB on open -- checked once at `serve` startup,
@@ -75146,4 +75243,423 @@ var tunnel = /*#__PURE__*/Object.freeze({
75146
75243
  registerTunnelVerb: registerTunnelVerb
75147
75244
  });
75148
75245
 
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
+
75149
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.48",
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.",