@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 +65 -0
- package/dist/ac-agent.mjs +523 -7
- package/package.json +1 -1
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.
|
|
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
|
|
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.
|
|
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.",
|