@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 +10 -6
- package/dist/ac-agent.mjs +86 -26
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
17817
|
-
async function
|
|
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
|
|
17830
|
-
return
|
|
17831
|
-
|
|
17832
|
-
return
|
|
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
|
-
|
|
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
|
|
67659
|
-
|
|
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
|
-
|
|
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
|
|
67674
|
-
*
|
|
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: () =>
|
|
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.
|
|
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.",
|