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