@rackbops/ac-agent 2.0.0-alpha.60 → 2.0.0-alpha.62

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
@@ -267,9 +267,8 @@ curl -k https://127.0.0.1:8788/v1/health
267
267
  #### A server host that also runs the console (#826)
268
268
 
269
269
  The machine that runs the console in Docker can pair an agent too, so its own toolchain is graded in
270
- the Agents panel's Environment section like any other machine's; nucbox is set up this way (#826; its pairing was still pending on
271
- 2026-10-20). It installs
272
- the Linux way above, with these differences.
270
+ the Agents panel's Environment section like any other machine's; nucbox is set up this way and paired
271
+ (#826, 2026-10-20). It installs the Linux way above, with these differences.
273
272
 
274
273
  **Node 26 from NodeSource's apt repository.** The agent needs Node 26 or newer (`engines.node`, #827;
275
274
  see **Install** above), the fleet's one line, and Ubuntu's own `nodejs` is older (22 on Ubuntu 26.04).
@@ -342,9 +341,12 @@ either: it shows on any `behind` row whose tool the standard gives a package for
342
341
  (among `git`, `gh`, `node` and `claude`; a `docker` or `cloudflared` row gets one too, and the agent
343
342
  runs that upgrade at the next 05:00 America/New_York, #978). The grant is what lets
344
343
  a click succeed; without it the agent refuses with `agent.toolchain.upgrade is not granted to agents on
345
- <machine>`. That one granted verb installs a package the standard names: as root, through `sudo apt-get
344
+ <machine>`. That granted verb installs a package the standard names: as root, through `sudo apt-get
346
345
  install --only-upgrade` and the sudoers rule, or for Claude Code with its own `claude update` as the
347
- agent's user. No other verb is granted: no `agent.exec.*`, `agent.git.*` or `agent.scan.*`.
346
+ agent's user. No other verb is granted to `agents`: no `agent.exec.*`, `agent.git.*` or `agent.scan.*`.
347
+
348
+ nucbox additionally grants `agent.tunnel.lint` to the `admin` plugin, and nothing else (the owner's Grants
349
+ screenshot, 2026-10-20).
348
350
 
349
351
  The console no longer asks this machine for what it has not been granted (#843):
350
352
  - The host's job runner skips a machine that has not granted every capability a job declares: no
@@ -483,7 +485,9 @@ running agent. Both outcomes are written to `agent.log`, as `replaced stale lock
483
485
 
484
486
  `stop`, `uninstall` and an install's restart kill only the pid that pinned answer names. When nothing
485
487
  answers they kill nothing and remove the stale `agent.pid`, and they print `... nothing was killed` if
486
- that pid belongs to some other live process.
488
+ that pid belongs to some other live process. When the pinned answer has no `pid` (an agent older than
489
+ alpha.59), they refuse and name the port and version instead of killing nothing. An install also
490
+ refuses to start while the port still answers after the stop.
487
491
 
488
492
  ## Config and state directories
489
493
 
package/dist/ac-agent.mjs CHANGED
@@ -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.60";
50
+ var version = "2.0.0-alpha.62";
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 = {
@@ -17808,13 +17808,15 @@ function writeConfig(dir, config, deps) {
17808
17808
  * pinned by the state dir's own `cert.pem`, with its own pid in the body.
17809
17809
  *
17810
17810
  * A null answer means "no agent of this state dir answers", never "the pid is dead": a process that
17811
- * is starting up, or wedged, answers nothing either. Callers decide what null means for them.
17811
+ * is starting up, or wedged, answers nothing either. Callers decide what null means for them. A
17812
+ * non-null answer with `pid: null` means "an agent answers but cannot be named" (#1057): an agent
17813
+ * older than #704 (alpha.57/58) answers ok without a pid.
17812
17814
  */
17813
17815
  /** The `serve`-time probe budget. Short, because the Windows launcher retries for a minute and the
17814
17816
  * probe sits on the start path; `stop` and the service paths use `pinnedFetchHealth`'s 10 s. */
17815
17817
  const LOCK_PROBE_TIMEOUT_MS = 2000;
17816
- /** The pid the agent answering this state dir's port reports, or null when none answers. */
17817
- async function probeAgentPid(deps) {
17818
+ /** The agent answering this state dir's port, pinned by its own `cert.pem`, or null when none answers. */
17819
+ async function probeAgent(deps) {
17818
17820
  const certPem = deps.readFile(path.join(deps.stateDir, "cert.pem"));
17819
17821
  if (certPem === null)
17820
17822
  return null;
@@ -17826,10 +17828,18 @@ async function probeAgentPid(deps) {
17826
17828
  return null;
17827
17829
  }
17828
17830
  const health = await deps.fetchHealth(deps.port, certPem, fp, deps.bind);
17829
- if (health?.ok === true && Number.isInteger(health.pid) && health.pid > 1) {
17830
- return health.pid;
17831
- }
17832
- return null;
17831
+ if (health?.ok !== true)
17832
+ return null;
17833
+ const pid = Number.isInteger(health.pid) && health.pid > 1 ? health.pid : null;
17834
+ return {
17835
+ pid,
17836
+ version: typeof health.version === "string" ? health.version : null,
17837
+ port: deps.port,
17838
+ };
17839
+ }
17840
+ /** The pid the agent answering this state dir's port reports, or null when none answers or it reports none. */
17841
+ async function probeAgentPid(deps) {
17842
+ return (await probeAgent(deps))?.pid ?? null;
17833
17843
  }
17834
17844
 
17835
17845
  /**
@@ -67292,6 +67302,28 @@ async function confirmHealthy(deps, port, certPem, expectedFingerprint, expected
67292
67302
  }
67293
67303
  throw new Error(`new process did not answer a health check on port ${port}`);
67294
67304
  }
67305
+ /** An agent still answers this state dir's port and this install cannot stop it (#1057): starting a new one would only fail to bind. */
67306
+ class AgentNotStoppedError extends Error {
67307
+ }
67308
+ const STOP_CONFIRM_BUDGET = { attempts: 10, delayMs: 300 };
67309
+ /** Waits for the stopped agent to stop answering (#1057): a SIGTERM'd agent takes a moment to
67310
+ * release the port, so one immediate probe could refuse falsely. Pinned by this state dir's cert,
67311
+ * so a foreign process on the port is not detected. */
67312
+ async function confirmStopped(deps) {
67313
+ const budget = deps.stopConfirmBudget ?? STOP_CONFIRM_BUDGET;
67314
+ let last = null;
67315
+ for (let attempt = 0; attempt < budget.attempts; attempt++) {
67316
+ last = await deps.probeAgent();
67317
+ if (last === null)
67318
+ return;
67319
+ if (attempt < budget.attempts - 1) {
67320
+ await new Promise((resolve) => setTimeout(resolve, budget.delayMs));
67321
+ }
67322
+ }
67323
+ if (last === null)
67324
+ return;
67325
+ throw new AgentNotStoppedError(`port ${last.port} still answers version ${last.version ?? "unknown"}${last.pid !== null ? ` (pid ${last.pid})` : ""} after the stop; stop it by hand, then run ac-agent start`);
67326
+ }
67295
67327
  async function installService(deps) {
67296
67328
  if (deps.reposRootOverride !== undefined) {
67297
67329
  requireAbsolute(deps.reposRootOverride, "--repos-root");
@@ -67344,6 +67376,7 @@ async function installService(deps) {
67344
67376
  }
67345
67377
  const bundlePath = bundleTargetPath(deps.stateDir);
67346
67378
  const backupPath = bundleBackupPath(deps.stateDir);
67379
+ const hadBundle = deps.exists(bundlePath);
67347
67380
  // #659: an install over a running agent keeps the bundle it is replacing, and the version that
67348
67381
  // bundle is serving, so a new bundle that never comes up can be undone. Read before the copy: once
67349
67382
  // the copy lands, the file on disk is no longer what the running process loaded.
@@ -67439,12 +67472,20 @@ async function installService(deps) {
67439
67472
  let restartError = null;
67440
67473
  try {
67441
67474
  await stopService(deps);
67475
+ await confirmStopped(deps);
67442
67476
  await deps.platform.start();
67443
67477
  await confirmHealthy(deps, port, certPem, fp, deps.version, budget, loaded.config.bind);
67444
67478
  }
67445
67479
  catch (err) {
67446
67480
  restartError = err;
67447
67481
  }
67482
+ if (restartError instanceof AgentNotStoppedError) {
67483
+ // #1057: nothing was started, so there is nothing to roll back (and `rollBack`'s own stop would
67484
+ // refuse the same way). The new bundle stays registered; the operator's `ac-agent start` after
67485
+ // stopping the old process by hand brings it up.
67486
+ record("failed", restartError.message);
67487
+ throw restartError;
67488
+ }
67448
67489
  if (restartError === null) {
67449
67490
  // Recorded only after the restart sequence: a failed record write must never look like a
67450
67491
  // failed restart (#659 review round 1).
@@ -67477,7 +67518,12 @@ async function installService(deps) {
67477
67518
  // Nothing was running to restart (the agent died between the pre-check and now, or a fresh install).
67478
67519
  // Under --update the agent that launched this install was running, so this is a failure the record
67479
67520
  // must say, not leave at `installing` (#659 review round 2).
67480
- record("failed", "no agent was running after the install; run ac-agent start");
67521
+ const noAgent = "no agent was running after the install; run ac-agent start";
67522
+ // #1057: an install over an existing bundle says why it did not restart. A first-ever install stays
67523
+ // quiet, and so does Linux, where `platform.install`'s `enable --now` is already starting the unit.
67524
+ if (hadBundle && deps.osPlatform !== "linux")
67525
+ deps.print(noAgent);
67526
+ record("failed", noAgent);
67481
67527
  }
67482
67528
  function nowIso(deps) {
67483
67529
  return (deps.now ?? (() => new Date()))().toISOString();
@@ -67643,7 +67689,8 @@ function readAgentPid(deps) {
67643
67689
  * `killProcess`'s doc for why this doesn't rely on the service manager's stop to reach the agent.
67644
67690
  * With no lock file it still probes and kills an answering agent (#1052). When the pinned health probe answers, its pid is killed (with
67645
67691
  * the mismatch printed if the lock named another); when nothing answers, nothing is killed, and
67646
- * that is printed if the lock's pid belongs to a live process. */
67692
+ * that is printed if the lock's pid belongs to a live process. A pidless answer (an agent older
67693
+ * than #704) throws `AgentNotStoppedError` instead of killing nothing silently (#1057). */
67647
67694
  async function killRunningAgent(deps) {
67648
67695
  const pid = readAgentPid(deps);
67649
67696
  // Probed even with no usable lock file (#1052): an agent whose `agent.pid` was lost or replaced by a
@@ -67655,10 +67702,13 @@ async function killRunningAgent(deps) {
67655
67702
  }
67656
67703
  return;
67657
67704
  }
67658
- if (pid !== null && answering !== pid) {
67659
- deps.print(`agent.pid names pid ${pid}, but the agent answering on its port is pid ${answering}; stopping pid ${answering}`);
67705
+ if (answering.pid === null) {
67706
+ throw new AgentNotStoppedError(`an agent answers on port ${answering.port} (version ${answering.version ?? "unknown"}) but does not report its pid; stop it by hand, then retry`);
67660
67707
  }
67661
- deps.killProcess(answering);
67708
+ if (pid !== null && answering.pid !== pid) {
67709
+ deps.print(`agent.pid names pid ${pid}, but the agent answering on its port is pid ${answering.pid}; stopping pid ${answering.pid}`);
67710
+ }
67711
+ deps.killProcess(answering.pid);
67662
67712
  }
67663
67713
  /** Whether the agent is currently serving: a live pid in `agent.pid`, probed with `deps.isAlive`
67664
67714
  * (the same `process.kill(pid, 0)` semantics `lock.ts`'s `acquire()` uses, rather than trusting the
@@ -67670,8 +67720,8 @@ function isAgentRunning(deps) {
67670
67720
  const pid = readAgentPid(deps);
67671
67721
  return pid !== null && deps.isAlive(pid);
67672
67722
  }
67673
- /** Whether an agent is serving, by either signal: a live pid in `agent.pid`, or an agent of this state
67674
- * dir answering its port (#1052). The pid file alone is not enough: a starter that lost the port bind
67723
+ /** Whether an agent is serving, by either signal: a live pid in `agent.pid`, or any pinned ok answer
67724
+ * on this state dir's port, with or without a pid (#1052, #1057). The pid file alone is not enough: a starter that lost the port bind
67675
67725
  * leaves its own dead pid in it while the old agent keeps serving, and an install that trusts the file
67676
67726
  * then skips the stop/start and reports "no agent was running" with the old bundle still live. */
67677
67727
  async function isAgentServing(deps) {
@@ -67705,7 +67755,9 @@ async function stopService(deps, options = {}) {
67705
67755
  * command stops and disables the unit together, in one call, rather than as a separate step. Either
67706
67756
  * way this is a harmless redundant stop against an already-stopped service, not a second attempt
67707
67757
  * worth engineering around. A failing stop here (the unit or task is not loaded) is tolerated so the
67708
- * rest of the teardown still runs (#710); the `stop` verb reports it (#709). */
67758
+ * rest of the teardown still runs (#710); the `stop` verb reports it (#709). A pidless answering
67759
+ * agent makes `killRunningAgent` throw, so uninstall rejects before any teardown (#1057): deleting
67760
+ * `cert.pem` would remove the only way to ever identify that agent. */
67709
67761
  async function uninstall(deps, options) {
67710
67762
  await stopService(deps, { tolerateStopFailure: true });
67711
67763
  await deps.platform.uninstall();
@@ -67723,6 +67775,7 @@ async function uninstall(deps, options) {
67723
67775
 
67724
67776
  var index$2 = /*#__PURE__*/Object.freeze({
67725
67777
  __proto__: null,
67778
+ AgentNotStoppedError: AgentNotStoppedError,
67726
67779
  detectVirtualization: detectVirtualization,
67727
67780
  installService: installService,
67728
67781
  isAgentRunning: isAgentRunning,
@@ -68337,6 +68390,20 @@ function processIsAlive(pid) {
68337
68390
  return false;
68338
68391
  }
68339
68392
  }
68393
+ /** `ServiceDeps.killProcess` (#1057): an already-gone pid (ESRCH) is fine; anything else (EPERM on Windows) is printed, never swallowed. */
68394
+ function killProcessFn(kill, warn) {
68395
+ return (pid) => {
68396
+ try {
68397
+ kill(pid);
68398
+ }
68399
+ catch (err) {
68400
+ const code = err?.code;
68401
+ if (code === "ESRCH")
68402
+ return;
68403
+ warn(`could not stop pid ${pid}: ${code ?? (err instanceof Error ? err.message : String(err))}`);
68404
+ }
68405
+ };
68406
+ }
68340
68407
  /** One child-process runner shape for both platforms: Windows pipes a script to powershell.exe's
68341
68408
  * stdin, Linux runs `cmd args` directly. Constructed once per CLI invocation, reused by every
68342
68409
  * PlatformOps method (install/uninstall/state/stop/start) instead of re-implemented per call. */
@@ -68561,14 +68628,7 @@ async function buildServiceDeps(overrides = {}) {
68561
68628
  removeDir: (p) => {
68562
68629
  fs.rmSync(p, { recursive: true, force: true });
68563
68630
  },
68564
- killProcess: (pid) => {
68565
- try {
68566
- process.kill(pid);
68567
- }
68568
- catch {
68569
- // already gone
68570
- }
68571
- },
68631
+ killProcess: killProcessFn((pid) => process.kill(pid), (line) => process.stdout.write(`${line}\n`)),
68572
68632
  isAlive: processIsAlive,
68573
68633
  version: packageJson.version,
68574
68634
  hostname: os.hostname,
@@ -68593,7 +68653,7 @@ async function buildServiceDeps(overrides = {}) {
68593
68653
  fetchHealth: pinnedFetchHealth,
68594
68654
  // #704: dials the port the running agent was started on (`agent.toml` as loaded before any install
68595
68655
  // rewrites it), with the 10 s pin budget; `stop` and the restart paths can wait longer than `serve`.
68596
- probeAgent: () => probeAgentPid({
68656
+ probeAgent: () => probeAgent({
68597
68657
  stateDir: dirs.stateDir,
68598
68658
  port: loaded.config.port,
68599
68659
  bind: loaded.config.bind,
@@ -81638,4 +81698,4 @@ var updater = /*#__PURE__*/Object.freeze({
81638
81698
  selfUpdateSupported: selfUpdateSupported
81639
81699
  });
81640
81700
 
81641
- export { AGENT_API_VERSION, NODE_FLOOR, NO_CWD_EXE_SEARCH_VAR, applySpawnGuard, buildRepoFs, buildWhichFs, certWarningFor, checkEnvironment, dispatch, healthBaseUrl, healthResultFrom, isEntryPoint, main, makeRunners, pinnedFetchHealth, readFileFn, realExists, runningJobCount, toolchainUpgradeDeps };
81701
+ export { AGENT_API_VERSION, NODE_FLOOR, NO_CWD_EXE_SEARCH_VAR, applySpawnGuard, buildRepoFs, buildWhichFs, certWarningFor, checkEnvironment, dispatch, healthBaseUrl, healthResultFrom, isEntryPoint, killProcessFn, main, makeRunners, pinnedFetchHealth, readFileFn, realExists, runningJobCount, toolchainUpgradeDeps };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rackbops/ac-agent",
3
- "version": "2.0.0-alpha.60",
3
+ "version": "2.0.0-alpha.62",
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.",