@hivemagnus/magnus-agent 1.1.1 → 1.1.3

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/.env.example CHANGED
@@ -54,6 +54,8 @@ MAGNUS_AGENT_PRIVILEGED=true
54
54
  # Independent key for explicit command-receipt reconciliation. Leave unset unless
55
55
  # your control plane uses it. Linux /usr/bin/flock is required for commands.
56
56
  # COMMAND_RECONCILIATION_SECRET=
57
+ # Root-owned JSON list of operator-reviewed historical mutation records (read at startup).
58
+ # MAGNUS_AGENT_REVIEWED_HISTORICAL_RECORDS=/etc/magnus/reviewed-historical-records.json
57
59
  # Rotation windows: previous values accepted alongside the current ones.
58
60
  # NODE_MANAGER_SECRET_PREVIOUS=
59
61
  # RESULT_SIGNING_PREVIOUS_SECRET=
@@ -126,4 +128,5 @@ LOG_LEVEL=info
126
128
  # MAGNUS_AGENT_UPGRADE_REQUIRE_PROVENANCE=true
127
129
  # Offline nodes: install from hivemagnus-magnus-agent-<X>.tgz files in this directory.
128
130
  # MAGNUS_AGENT_UPGRADE_SOURCE_DIR=
131
+ # npm for self-upgrades (default: the npm that ships with the running Node.js, then npm on PATH).
129
132
  # MAGNUS_AGENT_NPM=npm
package/README.md CHANGED
@@ -138,11 +138,32 @@ After that, the server can upgrade the agent from the console's **Upgrade agents
138
138
 
139
139
  1. The agent downloads the exact version the server names and checks its npm `sha512` integrity. It
140
140
  also runs `npm audit signatures` (`MAGNUS_AGENT_UPGRADE_REQUIRE_PROVENANCE`).
141
- 2. It installs that version beside the running one, points `./current` at it and exits.
141
+ 2. It installs that version beside the running one and checks that it runs on this Node.js
142
+ (`engines`), works with the installed launcher, and reports the requested version. Then it points
143
+ `./current` at it and exits.
142
144
  3. The launcher starts the new version on trial.
143
145
  4. If the new version does not report an accepted heartbeat within
144
146
  `MAGNUS_AGENT_UPGRADE_CONFIRM_SECONDS` (default 180), or crashes twice, the launcher switches back
145
- to the previous version.
147
+ to the previous version. A new version that runs but cannot reach the control API gets up to three
148
+ windows, and the revert reason then says so.
149
+
150
+ Every step has a time limit, and the whole upgrade stops after 13 minutes (`SELF_UPGRADE_TIMEOUT`).
151
+ npm, its lifecycle scripts and the smoke test run without the agent's credentials. The agent uses
152
+ `MAGNUS_AGENT_NPM` when set, otherwise the npm that ships with the Node.js it runs on, then npm on
153
+ `PATH`. A retried
154
+ request with the same `requestId` returns the current state instead of an error.
155
+
156
+ The state file (`upgrade-state.json`) and the links are written durably (fsync and rename), and the
157
+ agent and the launcher lock the state file while they change it. `./last-good` points at the last
158
+ confirmed version. The launcher switches to it when `versions/<from>` is missing, and when the state
159
+ file is unreadable and `./current` keeps exiting. When a write fails (full or read-only disk), the
160
+ launcher logs it and still starts an agent instead of crash-looping.
161
+
162
+ A confirmed version installs its own `launcher/launcher.mjs` when that declares a higher
163
+ `LAUNCHER_PROTOCOL` and passes a self-check; the old file is kept as `launcher.mjs.previous`. In the
164
+ `--unprivileged` mode, `launcher/` stays owned by root, so the launcher is updated only by
165
+ `install-service`. A release that needs a newer launcher than the node runs is refused with
166
+ `SELF_UPGRADE_LAUNCHER`.
146
167
 
147
168
  The agent writes only inside its install directory and never uses sudo. Set
148
169
  `MAGNUS_AGENT_SELF_UPGRADE=false` to refuse upgrade requests on a node. Offline nodes can install from
@@ -195,6 +216,30 @@ The entries can only touch what the comma-separated allowlists name:
195
216
  `_TOKEN_FILES` and `_SIGNING_ENV_FILES`. Every list is empty by default, so
196
217
  renewal does nothing until you configure it.
197
218
 
219
+ ### Reviewed historical mutation records
220
+
221
+ A node with retained mutation records whose outcome is unknown can settle them only through a signed
222
+ historical quiescence receipt. The receipt counts only for records an operator has reviewed, listed
223
+ in a root-controlled JSON file (`MAGNUS_AGENT_REVIEWED_HISTORICAL_RECORDS`, default
224
+ `/etc/magnus/reviewed-historical-records.json`):
225
+
226
+ ```json
227
+ {
228
+ "version": 1,
229
+ "records": {
230
+ "0b6f1c2e-6d3a-4f6e-9c1d-2a7b8e9f0a1b": { "nodeFqdn": "node-1.example.com", "recordSha256": "<64 lowercase hex>" }
231
+ }
232
+ }
233
+ ```
234
+
235
+ The keys are the mutation record ids. `recordSha256` is the SHA-256 of the retained record file, as
236
+ in the signed observation. Entries for other nodes (`nodeFqdn` other than `HOST_FQDN`) are ignored, so
237
+ one file can serve a fleet. The file and its directory must be owned by root and not group- or
238
+ world-writable, and the file must not be a symlink or larger than 1 MiB. `0644` works for an
239
+ unprivileged agent too. The agent reads the file once at startup, so restart it after a change. A
240
+ missing file means no reviewed records. A file that fails these checks or the schema is refused as a
241
+ whole, and the agent logs the reason at startup.
242
+
198
243
  ### Hermes registry sync
199
244
 
200
245
  `deploy/magnus-hermes-sync.service` and its timer run `dist/hermes-sync.js`
@@ -13,6 +13,11 @@
13
13
  #
14
14
  # Use a NODE_MANAGER_PORT above 1023, and make the TLS key readable by
15
15
  # magnus-agent. Install this file as /etc/systemd/system/magnus-agent.service.
16
+ #
17
+ # These example units start the agent directly and cannot self-upgrade. For
18
+ # server-driven upgrades, run `sudo magnus-agent install-service` instead: it
19
+ # moves the install into versions/<X>, installs the upgrade launcher and writes
20
+ # a unit that starts it (the cron path below node_modules keeps working).
16
21
  [Unit]
17
22
  Description=Magnus Agent (unprivileged)
18
23
  After=network-online.target
@@ -10,6 +10,11 @@
10
10
  # policy and the agent's own allowlists. This is privileged mode
11
11
  # (MAGNUS_AGENT_PRIVILEGED=true, the default); to run without root, use
12
12
  # magnus-agent-unprivileged.service instead.
13
+ #
14
+ # These example units start the agent directly and cannot self-upgrade. For
15
+ # server-driven upgrades, run `sudo magnus-agent install-service` instead: it
16
+ # moves the install into versions/<X>, installs the upgrade launcher and writes
17
+ # a unit that starts it (the cron path below node_modules keeps working).
13
18
  [Unit]
14
19
  Description=Magnus Agent
15
20
  After=network-online.target
package/dist/agent.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ /** On an upgrade trial: retry fast, without backoff, so a short control API outage does not end the trial. */
2
+ export declare const TRIAL_RETRY_MS = 5000;
1
3
  export declare function getHeartbeatStatus(): {
2
4
  running: boolean;
3
5
  lastAcceptedAt: string | null;
package/dist/agent.js CHANGED
@@ -7,9 +7,12 @@ import { controlApiFetch } from './tls.js';
7
7
  import { startSpan, formatTraceparent } from './collectors/otel-collector.js';
8
8
  import { collectHermesState } from './executors/hermes-manager.js';
9
9
  import { existsSync } from 'node:fs';
10
- import { confirmTrial, selfUpgradeStatus } from './upgrade/self-upgrade.js';
10
+ import { confirmTrial, controlApiUnreachable, recordTrialAlive, selfUpgradeStatus, trialOf } from './upgrade/self-upgrade.js';
11
+ import { commandLedger } from './executors/command-executor.js';
11
12
  const HEARTBEAT_INTERVAL_MS = 30_000; // 30s
12
13
  const HEARTBEAT_BACKOFF_MAX_MS = 5 * 60_000; // 5 min max backoff
14
+ /** On an upgrade trial: retry fast, without backoff, so a short control API outage does not end the trial. */
15
+ export const TRIAL_RETRY_MS = 5_000;
13
16
  const HERMES_DISCOVERY_INTERVAL_MS = Number(process.env['HERMES_DISCOVERY_INTERVAL_MS'] ?? 3 * 60 * 60 * 1000);
14
17
  let heartbeatTimer = null;
15
18
  let heartbeatRunning = false;
@@ -72,6 +75,11 @@ async function collectHealthWithDeadline() {
72
75
  clearTimeout(timer);
73
76
  }
74
77
  }
78
+ /** Self-upgrade status for the heartbeat, with the sticky local enrollment marker. */
79
+ async function heartbeatSelfUpgrade() {
80
+ const enrollment = await commandLedger.managedEnrollment().catch(() => null);
81
+ return { ...selfUpgradeStatus(AGENT_VERSION), managedEnrollment: enrollment ? { nodeId: enrollment.nodeId, nodeFqdn: enrollment.nodeFqdn } : null };
82
+ }
75
83
  /** Stop the heartbeat loop. */
76
84
  export function stopHeartbeatLoop() {
77
85
  heartbeatRunning = false;
@@ -89,6 +97,7 @@ export function startHeartbeatLoop() {
89
97
  if (!heartbeatRunning)
90
98
  return;
91
99
  const span = startSpan('heartbeat.send', { kind: 'client' });
100
+ let posting = false;
92
101
  try {
93
102
  const health = await collectHealthWithDeadline();
94
103
  // A cached Hermes snapshot is sent while a stale one refreshes in the
@@ -97,6 +106,8 @@ export function startHeartbeatLoop() {
97
106
  if (!hermesSnapshot)
98
107
  await Promise.race([refresh, delay(HERMES_HEARTBEAT_WAIT_MS)]);
99
108
  const hermes = hermesSnapshot;
109
+ const selfUpgrade = await heartbeatSelfUpgrade();
110
+ posting = true;
100
111
  await ky.post(`${CONTROL_API_URL}/api/v1/heartbeat`, {
101
112
  json: {
102
113
  agentVersion: AGENT_VERSION,
@@ -114,7 +125,7 @@ export function startHeartbeatLoop() {
114
125
  },
115
126
  ...(hermes ? { hermes } : {}),
116
127
  nodeVersion: process.version,
117
- selfUpgrade: selfUpgradeStatus(AGENT_VERSION),
128
+ selfUpgrade,
118
129
  },
119
130
  headers: {
120
131
  'x-node-token': NODE_TOKEN,
@@ -150,8 +161,12 @@ export function startHeartbeatLoop() {
150
161
  if (consecutiveFailures === 1 || Math.log2(consecutiveFailures) % 1 === 0) {
151
162
  logger.warn(`Heartbeat failed to send`, { attempt: consecutiveFailures, error: message });
152
163
  }
164
+ // On trial: tell the launcher this version is alive, and retry at a fixed short interval.
165
+ const onTrial = trialOf(AGENT_VERSION) !== null;
166
+ if (onTrial && posting && controlApiUnreachable(err))
167
+ recordTrialAlive(AGENT_VERSION, message);
153
168
  // Back off retry interval: 30s → 60s → 120s … capped at 5min
154
- const backoffMs = Math.min(HEARTBEAT_INTERVAL_MS * Math.pow(2, consecutiveFailures - 1), HEARTBEAT_BACKOFF_MAX_MS);
169
+ const backoffMs = onTrial ? TRIAL_RETRY_MS : Math.min(HEARTBEAT_INTERVAL_MS * Math.pow(2, consecutiveFailures - 1), HEARTBEAT_BACKOFF_MAX_MS);
155
170
  heartbeatTimer = setTimeout(() => void send(), backoffMs);
156
171
  return;
157
172
  }
@@ -7,6 +7,7 @@ export const agentEnvCatalog = [
7
7
  { name: 'NODE_MANAGER_SECRET_PREVIOUS', required: false, secret: true, group: 'core', description: 'Previous node credential, accepted only during a rotation window.' },
8
8
  { name: 'RESULT_SIGNING_PREVIOUS_SECRET', required: false, secret: true, group: 'core', description: 'Previous result-signing key, accepted only during a rotation window.' },
9
9
  { name: 'COMMAND_RECONCILIATION_SECRET', required: false, secret: true, generate: 'hex32', group: 'core', description: 'Independent key that authorizes explicit command-receipt reconciliation. Leave unset unless your control plane uses it.' },
10
+ { name: 'MAGNUS_AGENT_REVIEWED_HISTORICAL_RECORDS', required: false, secret: false, default: '/etc/magnus/reviewed-historical-records.json', group: 'core', description: 'Root-owned JSON file of operator-reviewed historical mutation records ({"version":1,"records":{"<record id>":{"nodeFqdn","recordSha256"}}}). Read at startup; a missing file means none, a file that is not root-controlled or fails the schema is refused.' },
10
11
  { name: 'HOST_FQDN', required: false, secret: false, default: 'hostname --fqdn', group: 'core', description: 'Host identity reported to the Control API.' },
11
12
  { name: 'NODE_MANAGER_PORT', required: false, secret: false, default: String(DEFAULT_PORT), group: 'core', description: 'Listener port.' },
12
13
  { name: 'NODE_MANAGER_BIND_HOST', required: false, secret: false, default: '0.0.0.0 (TLS) / 127.0.0.1 (HTTP)', group: 'core', description: 'Listener address.' },
@@ -16,7 +17,7 @@ export const agentEnvCatalog = [
16
17
  { name: 'MAGNUS_AGENT_UPGRADE_CONFIRM_SECONDS', required: false, secret: false, default: '180', group: 'core', description: 'Seconds a newly installed version has to report an accepted heartbeat before the launcher switches back to the previous one (10-3600).' },
17
18
  { name: 'MAGNUS_AGENT_UPGRADE_REQUIRE_PROVENANCE', required: false, secret: false, default: 'true', group: 'core', description: 'Run `npm audit signatures` on a staged upgrade and refuse it unless every package has valid registry signatures. Set false only for locally built tarballs.' },
18
19
  { name: 'MAGNUS_AGENT_UPGRADE_SOURCE_DIR', required: false, secret: false, group: 'core', description: 'Install self-upgrades from tarballs in this directory (hivemagnus-magnus-agent-<X>.tgz) instead of the npm registry, for offline nodes. The integrity check still applies.' },
19
- { name: 'MAGNUS_AGENT_NPM', required: false, secret: false, default: 'npm', group: 'core', description: 'npm executable used for self-upgrades.' },
20
+ { name: 'MAGNUS_AGENT_NPM', required: false, secret: false, group: 'core', description: 'npm executable used for self-upgrades. Default: the npm that ships with the running Node.js, then npm on PATH.' },
20
21
  { name: 'MAGNUS_EXECUTION_SUPERVISOR_ENABLED', required: false, secret: false, default: 'false', group: 'core', description: 'Run commands under the durable execution supervisor.' },
21
22
  { name: 'CONTROL_API_CA_PATH', required: false, secret: false, group: 'tls', description: 'PEM CA bundle trusted for a private-CA Control API. Verification is never disabled.' },
22
23
  { name: 'TLS_CERT_PATH', required: false, secret: false, group: 'tls', description: 'Listener certificate (absolute or relative to AGENT_INSTALL_DIR). Required with TLS_KEY_PATH when NODE_ENV=production.' },
@@ -85,8 +85,8 @@ declare const configurationSchema: z.ZodObject<{
85
85
  token: z.ZodOptional<z.ZodString>;
86
86
  workspaceId: z.ZodDefault<z.ZodString>;
87
87
  defaultScope: z.ZodDefault<z.ZodEnum<{
88
- private: "private";
89
88
  shared: "shared";
89
+ private: "private";
90
90
  }>>;
91
91
  recallMode: z.ZodDefault<z.ZodEnum<{
92
92
  context: "context";
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { type ReviewedHistoricalRecords } from './reviewed-historical-records.js';
2
3
  export declare const historicalDrainSchema: z.ZodObject<{
3
4
  nodeId: z.ZodString;
4
5
  nodeFqdn: z.ZodString;
@@ -234,12 +235,11 @@ export interface HistoricalKeys {
234
235
  result: string;
235
236
  reconciliation: string;
236
237
  }
237
- export type ReviewedHistoricalRecords = Readonly<Record<string, {
238
- nodeFqdn: string;
239
- recordSha256: string;
240
- }>>;
238
+ export type { ReviewedHistoricalRecords };
241
239
  /** Records an operator has independently inspected and approved for historical
242
- * settlement. Empty by default: each entry needs its own reviewed source change. */
240
+ * settlement, from the root-controlled MAGNUS_AGENT_REVIEWED_HISTORICAL_RECORDS
241
+ * file, read once at startup. Empty when the file is absent or refused. */
242
+ export declare const reviewedHistoricalRecordsLoad: import("./reviewed-historical-records.js").ReviewedRecordsLoad;
243
243
  export declare const reviewedHistoricalRecords: ReviewedHistoricalRecords;
244
244
  export declare const historicalHash: (value: unknown) => string;
245
245
  export declare function validateHistoricalKeys(keys: HistoricalKeys): void;
@@ -250,5 +250,4 @@ export declare function verifyHistoricalObservation(raw: unknown, key: string):
250
250
  export declare function verifyHistoricalAssertion(raw: unknown, keys: HistoricalKeys): HistoricalAssertion;
251
251
  export declare function verifyHistoricalReceipt(raw: unknown, keys: HistoricalKeys): HistoricalReceipt;
252
252
  export declare function readHostBoot(): Promise<HostBoot>;
253
- export {};
254
253
  //# sourceMappingURL=historical-mutation.d.ts.map
@@ -2,6 +2,9 @@
2
2
  import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { z } from 'zod';
5
+ import { HOST_FQDN } from '../env.js';
6
+ import { logger } from '../utils/logger.js';
7
+ import { loadReviewedHistoricalRecords } from './reviewed-historical-records.js';
5
8
  const sha = z.string().regex(/^[a-f0-9]{64}$/);
6
9
  export const historicalDrainSchema = z.object({ nodeId: z.string().min(1).max(128),
7
10
  nodeFqdn: z.string().min(1).max(253), epoch: z.number().int().positive(), barrierId: z.string().guid() }).strict();
@@ -22,8 +25,18 @@ const receiptUnsigned = z.object({ version: z.literal(1), assertion: historicalA
22
25
  originalOutcome: z.literal('unknown') }).strict();
23
26
  export const historicalReceiptSchema = receiptUnsigned.extend({ signature: sha }).strict();
24
27
  /** Records an operator has independently inspected and approved for historical
25
- * settlement. Empty by default: each entry needs its own reviewed source change. */
26
- export const reviewedHistoricalRecords = Object.freeze({});
28
+ * settlement, from the root-controlled MAGNUS_AGENT_REVIEWED_HISTORICAL_RECORDS
29
+ * file, read once at startup. Empty when the file is absent or refused. */
30
+ export const reviewedHistoricalRecordsLoad = loadReviewedHistoricalRecords({
31
+ path: process.env['MAGNUS_AGENT_REVIEWED_HISTORICAL_RECORDS']?.trim() || undefined, hostname: HOST_FQDN
32
+ });
33
+ if (reviewedHistoricalRecordsLoad.problem) {
34
+ logger.warn(`Reviewed historical records refused; historical settlements are not honoured: ${reviewedHistoricalRecordsLoad.problem}`);
35
+ }
36
+ else if (reviewedHistoricalRecordsLoad.count > 0) {
37
+ logger.info(`Loaded ${reviewedHistoricalRecordsLoad.count} reviewed historical record(s) for ${HOST_FQDN} from ${reviewedHistoricalRecordsLoad.path}`);
38
+ }
39
+ export const reviewedHistoricalRecords = reviewedHistoricalRecordsLoad.records;
27
40
  export const historicalHash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
28
41
  export function validateHistoricalKeys(keys) {
29
42
  if (keys.result.length < 32 || keys.reconciliation.length < 32 || keys.result === keys.reconciliation)
@@ -0,0 +1,22 @@
1
+ export declare const DEFAULT_REVIEWED_HISTORICAL_RECORDS_PATH = "/etc/magnus/reviewed-historical-records.json";
2
+ export declare const REVIEWED_RECORDS_MAX_BYTES: number;
3
+ export type ReviewedHistoricalRecords = Readonly<Record<string, Readonly<{
4
+ nodeFqdn: string;
5
+ recordSha256: string;
6
+ }>>>;
7
+ export interface ReviewedRecordsLoad {
8
+ path: string;
9
+ records: ReviewedHistoricalRecords;
10
+ /** Entries for this node that were loaded. */
11
+ count: number;
12
+ /** Why the file was refused, or null (also when it is absent). */
13
+ problem: string | null;
14
+ }
15
+ export interface LoadOptions {
16
+ path?: string;
17
+ hostname: string;
18
+ /** Owner the file and its directory must have; 0 (root) except in tests. */
19
+ ownerUid?: number;
20
+ }
21
+ export declare function loadReviewedHistoricalRecords(options: LoadOptions): ReviewedRecordsLoad;
22
+ //# sourceMappingURL=reviewed-historical-records.d.ts.map
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Operator-reviewed historical mutation records, from a root-controlled file
3
+ * (MAGNUS_AGENT_REVIEWED_HISTORICAL_RECORDS, default
4
+ * /etc/magnus/reviewed-historical-records.json). Only records listed there can
5
+ * be settled by, or count as settled through, a signed historical receipt.
6
+ *
7
+ * Read once when the agent starts, so the set cannot change under a running
8
+ * drain; edit the file and restart the agent to change it. A missing file is
9
+ * the empty set. A file that root does not control (not owned by uid 0, group-
10
+ * or world-writable, a symlink, in such a directory) or that fails the schema
11
+ * is refused as a whole: the set is empty and the reason is logged.
12
+ *
13
+ * {
14
+ * "version": 1,
15
+ * "records": {
16
+ * "<record id (UUID)>": { "nodeFqdn": "node-1.example.com", "recordSha256": "<64 hex>" }
17
+ * }
18
+ * }
19
+ */
20
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from 'node:fs';
21
+ import { dirname, resolve } from 'node:path';
22
+ import { z } from 'zod';
23
+ export const DEFAULT_REVIEWED_HISTORICAL_RECORDS_PATH = '/etc/magnus/reviewed-historical-records.json';
24
+ export const REVIEWED_RECORDS_MAX_BYTES = 1024 * 1024;
25
+ const reviewedFileSchema = z.object({
26
+ version: z.literal(1),
27
+ records: z.record(z.string().guid(), z.object({
28
+ nodeFqdn: z.string().min(1).max(253),
29
+ recordSha256: z.string().regex(/^[a-f0-9]{64}$/),
30
+ }).strict()),
31
+ }).strict();
32
+ function controlled(info, what, ownerUid) {
33
+ if (info.isSymbolicLink())
34
+ return `${what} is a symlink`;
35
+ if (info.uid !== ownerUid)
36
+ return `${what} is owned by uid ${info.uid}, not ${ownerUid === 0 ? 'root' : ownerUid}`;
37
+ if (info.mode & 0o022)
38
+ return `${what} is group- or world-writable`;
39
+ return null;
40
+ }
41
+ const empty = Object.freeze({});
42
+ export function loadReviewedHistoricalRecords(options) {
43
+ const path = resolve(options.path || DEFAULT_REVIEWED_HISTORICAL_RECORDS_PATH);
44
+ const ownerUid = options.ownerUid ?? 0;
45
+ const refuse = (problem) => ({ path, records: empty, count: 0, problem });
46
+ let info;
47
+ try {
48
+ info = lstatSync(path);
49
+ }
50
+ catch (error) {
51
+ if (error.code === 'ENOENT')
52
+ return { path, records: empty, count: 0, problem: null };
53
+ return refuse(`cannot inspect ${path}: ${error.message}`);
54
+ }
55
+ const problem = controlled(info, path, ownerUid);
56
+ if (problem)
57
+ return refuse(problem);
58
+ if (!info.isFile())
59
+ return refuse(`${path} is not a regular file`);
60
+ if (info.size > REVIEWED_RECORDS_MAX_BYTES)
61
+ return refuse(`${path} is larger than ${REVIEWED_RECORDS_MAX_BYTES} bytes`);
62
+ let parent;
63
+ try {
64
+ parent = lstatSync(dirname(path));
65
+ }
66
+ catch (error) {
67
+ return refuse(`cannot inspect ${dirname(path)}: ${error.message}`);
68
+ }
69
+ const parentProblem = controlled(parent, dirname(path), ownerUid);
70
+ if (parentProblem)
71
+ return refuse(parentProblem);
72
+ if (!parent.isDirectory())
73
+ return refuse(`${dirname(path)} is not a directory`);
74
+ let text;
75
+ try {
76
+ // O_NOFOLLOW and the same inode as inspected: the file cannot be swapped in between.
77
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
78
+ try {
79
+ const opened = fstatSync(fd);
80
+ if (opened.ino !== info.ino || opened.dev !== info.dev || opened.size > REVIEWED_RECORDS_MAX_BYTES)
81
+ return refuse(`${path} changed while it was read`);
82
+ const buffer = Buffer.alloc(opened.size);
83
+ let offset = 0;
84
+ while (offset < buffer.length) {
85
+ const read = readSync(fd, buffer, offset, buffer.length - offset, offset);
86
+ if (read === 0)
87
+ break;
88
+ offset += read;
89
+ }
90
+ text = buffer.subarray(0, offset).toString('utf8');
91
+ }
92
+ finally {
93
+ closeSync(fd);
94
+ }
95
+ }
96
+ catch (error) {
97
+ return refuse(`cannot read ${path}: ${error.message}`);
98
+ }
99
+ let parsed;
100
+ try {
101
+ const result = reviewedFileSchema.safeParse(JSON.parse(text));
102
+ if (!result.success)
103
+ return refuse(`${path} does not match the schema: ${result.error.issues.slice(0, 3).map(issue => `${issue.path.join('.') || '(root)'}: ${issue.message}`).join('; ')}`);
104
+ parsed = result.data;
105
+ }
106
+ catch (error) {
107
+ return refuse(`${path} is not valid JSON: ${error.message}`);
108
+ }
109
+ // Entries for other nodes are ignored, so one file can serve a whole fleet.
110
+ const records = {};
111
+ for (const [id, entry] of Object.entries(parsed.records)) {
112
+ if (entry.nodeFqdn === options.hostname)
113
+ records[id] = Object.freeze({ nodeFqdn: entry.nodeFqdn, recordSha256: entry.recordSha256 });
114
+ }
115
+ return { path, records: Object.freeze(records), count: Object.keys(records).length, problem: null };
116
+ }
117
+ //# sourceMappingURL=reviewed-historical-records.js.map
@@ -1,14 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
  /** Fixed cron entrypoint. Load the installed private environment before runtime imports. */
3
+ import { lstatSync } from 'node:fs';
3
4
  import { basename, dirname, resolve } from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
5
6
  import { config } from 'dotenv';
6
7
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
7
8
  // The deployment helper installs the tarball below the stable host directory;
8
9
  // its private .env and .magnus ledger are outside node_modules and survive upgrades.
9
- const install = basename(packageRoot) === 'magnus-agent' && basename(dirname(packageRoot)) === '@hivemagnus'
10
+ let install = basename(packageRoot) === 'magnus-agent' && basename(dirname(packageRoot)) === '@hivemagnus'
10
11
  && basename(dirname(dirname(packageRoot))) === 'node_modules'
11
12
  ? resolve(packageRoot, '../../..') : packageRoot;
13
+ // Versioned layout: Node resolves the cron path (<dir>/node_modules or <dir>/current) to
14
+ // <dir>/versions/<X>; the stable directory is the one that holds ./current.
15
+ if (basename(dirname(install)) === 'versions') {
16
+ const stable = dirname(dirname(install));
17
+ try {
18
+ if (lstatSync(resolve(stable, 'current')).isSymbolicLink())
19
+ install = stable;
20
+ }
21
+ catch { /* not the versioned layout */ }
22
+ }
12
23
  for (const key of ['CONTROL_API_URL', 'HOST_FQDN', 'NODE_MANAGER_SECRET', 'NODE_MANAGER_SECRET_PREVIOUS',
13
24
  'RESULT_SIGNING_SECRET', 'MAGNUS_EXECUTION_SUPERVISOR_ENABLED', 'CONTROL_API_CA_PATH', 'COMMAND_RECONCILIATION_SECRET'])
14
25
  delete process.env[key];
@@ -6,6 +6,7 @@
6
6
  * /opt/magnus-agent/.env
7
7
  * /opt/magnus-agent/versions/<X>/node_modules/@hivemagnus/magnus-agent
8
8
  * /opt/magnus-agent/current -> versions/<X>
9
+ * /opt/magnus-agent/last-good -> versions/<X> (the last confirmed version)
9
10
  * /opt/magnus-agent/launcher/launcher.mjs (ExecStart)
10
11
  *
11
12
  * After this the agent upgrades itself inside that directory; it never needs
@@ -15,7 +16,7 @@ import { execFileSync } from 'node:child_process';
15
16
  import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, symlinkSync, writeFileSync } from 'node:fs';
16
17
  import { dirname, join, resolve } from 'node:path';
17
18
  import { fileURLToPath } from 'node:url';
18
- import { AGENT_PACKAGE, pointCurrentAt } from './self-upgrade.js';
19
+ import { AGENT_PACKAGE, ALIVE_FILE, LOCK_FILE, STATE_FILE, linkVersion, pointCurrentAt, pointLink } from './self-upgrade.js';
19
20
  export const INSTALL_SERVICE_USAGE = `Usage: sudo magnus-agent install-service [options]
20
21
 
21
22
  Moves this install into the versioned layout used by self-upgrades, installs
@@ -188,12 +189,16 @@ export async function installService(argv) {
188
189
  }
189
190
  act(`point ${current} at versions/${version}`, () => pointCurrentAt(dir, version));
190
191
  }
192
+ // The launcher falls back to the last confirmed version when the upgrade state is unreadable.
193
+ if (!isSymlink(join(dir, 'last-good'))) {
194
+ act(`link ${join(dir, 'last-good')} to the running version`, () => pointLink(dir, 'last-good', linkVersion(dir, 'current') ?? version));
195
+ }
191
196
  // Paths written before the versioned layout (cron lines, other units) keep working.
192
197
  if (!existsSync(join(dir, 'node_modules')) && !isSymlink(join(dir, 'node_modules'))) {
193
198
  act(`link ${join(dir, 'node_modules')} to current/node_modules`, () => symlinkSync(join('current', 'node_modules'), join(dir, 'node_modules'), 'dir'));
194
199
  }
195
- // The launcher is copied from the release that runs install-service, not linked:
196
- // self-upgrades never replace it.
200
+ // The launcher is copied from the release that runs install-service, not linked. A confirmed
201
+ // self-upgrade replaces it only with a copy that declares a higher LAUNCHER_PROTOCOL.
197
202
  act(`install ${join(dir, 'launcher', 'launcher.mjs')}`, () => {
198
203
  mkdirSync(join(dir, 'launcher'), { recursive: true, mode: 0o755 });
199
204
  copyFileSync(join(isSymlink(current) ? realpathSync(join(current, 'node_modules', ...AGENT_PACKAGE.split('/'))) : here, 'launcher', 'launcher.mjs'), join(dir, 'launcher', 'launcher.mjs'));
@@ -209,8 +214,13 @@ export async function installService(argv) {
209
214
  execFileSync('chown', [`${user}:${user}`, dir]);
210
215
  execFileSync('chown', ['-R', `${user}:${user}`, join(dir, 'versions'), join(dir, '.npm')]);
211
216
  execFileSync('chown', ['-h', `${user}:${user}`, current]);
212
- if (isSymlink(join(dir, 'node_modules')))
213
- execFileSync('chown', ['-h', `${user}:${user}`, join(dir, 'node_modules')]);
217
+ for (const link of ['node_modules', 'last-good'])
218
+ if (isSymlink(join(dir, link)))
219
+ execFileSync('chown', ['-h', `${user}:${user}`, join(dir, link)]);
220
+ // Left behind by an earlier privileged run: the unprivileged agent must be able to replace them.
221
+ const leftovers = ['staging', '.magnus', STATE_FILE, LOCK_FILE, ALIVE_FILE, 'launcher-crashes.json'].map(name => join(dir, name)).filter(path => existsSync(path));
222
+ if (leftovers.length)
223
+ execFileSync('chown', ['-R', `${user}:${user}`, ...leftovers]);
214
224
  });
215
225
  }
216
226
  act(`write ${options.unitPath}${existsSync(options.unitPath) ? ` (previous unit kept as ${options.unitPath}.pre-launcher)` : ''}`, () => {
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
2
2
  import { agentUpgradeRequestSchema } from '@hivemagnus/magnus-contracts';
3
3
  import { logger } from '../utils/logger.js';
4
4
  import { AGENT_VERSION } from '../version.js';
5
- import { SelfUpgradeError, selfUpgradeStatus, stageUpgrade } from './self-upgrade.js';
5
+ import { EXIT_BACKSTOP_MS, SelfUpgradeError, selfUpgradeStatus, stageUpgrade } from './self-upgrade.js';
6
6
  /** Seconds between the reply and the exit that hands over to the launcher. */
7
7
  export const RESTART_DELAY_MS = 3_000;
8
8
  export const agentUpgradeRoutes = new Hono();
@@ -14,9 +14,14 @@ agentUpgradeRoutes.post('/', async (c) => {
14
14
  }
15
15
  try {
16
16
  const result = await stageUpgrade({ request: parsed.data, version: AGENT_VERSION });
17
+ // A retried request: report the current state, change nothing.
18
+ if (result.repeated)
19
+ return c.json({ success: true, data: { accepted: true, ...result } });
17
20
  logger.info(`Self-upgrade staged: ${result.from} -> ${result.to}; restarting through the launcher`);
18
21
  // Reply first; the launcher starts versions/<to> on trial after this process exits.
19
22
  setTimeout(() => process.kill(process.pid, 'SIGTERM'), RESTART_DELAY_MS).unref();
23
+ // Backstop: a shutdown that hangs must not keep the old version running on the new ./current.
24
+ setTimeout(() => process.exit(0), EXIT_BACKSTOP_MS).unref();
20
25
  return c.json({ success: true, data: { accepted: true, ...result, restartInSeconds: RESTART_DELAY_MS / 1000 } });
21
26
  }
22
27
  catch (error) {