@hivemagnus/magnus-agent 1.0.3 → 1.1.0

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
@@ -116,3 +116,14 @@ LOG_LEVEL=info
116
116
  # MAGNUS_AGENT_WORKSPACE_ROOT=/var/lib/magnus-agent/workspaces
117
117
  # Account the docker-group heartbeat check verifies (default: the agent's own user).
118
118
  # MAGNUS_AGENT_DOCKER_USER=operator
119
+
120
+ # ── Self-upgrade (after `sudo magnus-agent install-service`) ─────────
121
+ # Accept server-driven upgrades to an exact version and integrity.
122
+ # MAGNUS_AGENT_SELF_UPGRADE=true
123
+ # Seconds a new version has to report a heartbeat before the launcher reverts it.
124
+ # MAGNUS_AGENT_UPGRADE_CONFIRM_SECONDS=180
125
+ # Refuse upgrades whose packages fail `npm audit signatures`.
126
+ # MAGNUS_AGENT_UPGRADE_REQUIRE_PROVENANCE=true
127
+ # Offline nodes: install from hivemagnus-magnus-agent-<X>.tgz files in this directory.
128
+ # MAGNUS_AGENT_UPGRADE_SOURCE_DIR=
129
+ # MAGNUS_AGENT_NPM=npm
package/README.md CHANGED
@@ -121,6 +121,34 @@ verification is never disabled.
121
121
  | HiveMem credential and certificate renewal | **yes** | not started |
122
122
  | Execution supervisor (`MAGNUS_EXECUTION_SUPERVISOR_ENABLED`) | **yes** | configuration error if enabled |
123
123
 
124
+ ## Upgrades
125
+
126
+ Run `sudo magnus-agent install-service` (add `--unprivileged` for the ordinary-account mode) once
127
+ per node. It does four things:
128
+
129
+ - moves the install into `/opt/magnus-agent/versions/<X>`
130
+ - links `./current`
131
+ - installs the upgrade launcher (`launcher/launcher.mjs`)
132
+ - writes the systemd unit
133
+
134
+ `--dry-run` prints the steps first.
135
+
136
+ After that, the server can upgrade the agent from the console's **Upgrade agents** action or with
137
+ `magnus-server agents upgrade`, in four steps:
138
+
139
+ 1. The agent downloads the exact version the server names and checks its npm `sha512` integrity. It
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.
142
+ 3. The launcher starts the new version on trial.
143
+ 4. If the new version does not report an accepted heartbeat within
144
+ `MAGNUS_AGENT_UPGRADE_CONFIRM_SECONDS` (default 180), or crashes twice, the launcher switches back
145
+ to the previous version.
146
+
147
+ The agent writes only inside its install directory and never uses sudo. Set
148
+ `MAGNUS_AGENT_SELF_UPGRADE=false` to refuse upgrade requests on a node. Offline nodes can install from
149
+ local tarballs with `MAGNUS_AGENT_UPGRADE_SOURCE_DIR`. See
150
+ [UPGRADING.md](../../UPGRADING.md#agents).
151
+
124
152
  ## CLI
125
153
 
126
154
  ```text
package/dist/agent.js CHANGED
@@ -7,6 +7,7 @@ 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
11
  const HEARTBEAT_INTERVAL_MS = 30_000; // 30s
11
12
  const HEARTBEAT_BACKOFF_MAX_MS = 5 * 60_000; // 5 min max backoff
12
13
  const HERMES_DISCOVERY_INTERVAL_MS = Number(process.env['HERMES_DISCOVERY_INTERVAL_MS'] ?? 3 * 60 * 60 * 1000);
@@ -112,6 +113,8 @@ export function startHeartbeatLoop() {
112
113
  serviceRestart: existsSync('/etc/magnus/security-restart-services'),
113
114
  },
114
115
  ...(hermes ? { hermes } : {}),
116
+ nodeVersion: process.version,
117
+ selfUpgrade: selfUpgradeStatus(AGENT_VERSION),
115
118
  },
116
119
  headers: {
117
120
  'x-node-token': NODE_TOKEN,
@@ -120,8 +123,11 @@ export function startHeartbeatLoop() {
120
123
  timeout: 10_000,
121
124
  fetch: controlApiFetch,
122
125
  }).json().then((resp) => {
126
+ // The first accepted heartbeat from a version on trial confirms the upgrade.
127
+ if (resp.data?.accepted && confirmTrial(AGENT_VERSION))
128
+ logger.info(`Self-upgrade to ${AGENT_VERSION} confirmed`);
123
129
  if (resp.data?.updateAvailable && resp.data.expectedVersion) {
124
- logger.info(`Agent update available: ${AGENT_VERSION} → ${resp.data.expectedVersion}`);
130
+ logger.info(`Agent update available: ${AGENT_VERSION} → ${resp.data.expectedVersion} (upgrade from the console's Nodes page or with \`magnus-server agents upgrade\`)`);
125
131
  }
126
132
  });
127
133
  if (consecutiveFailures > 0) {
@@ -12,6 +12,11 @@ export const agentEnvCatalog = [
12
12
  { 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.' },
13
13
  { name: 'ENABLE_NODE_MANAGEMENT', required: false, secret: false, default: 'true', group: 'core', description: 'Node management routes and heartbeat. Must stay true.' },
14
14
  { name: 'MAGNUS_AGENT_PRIVILEGED', required: false, secret: false, default: 'true', group: 'core', description: 'true: run as root with every executor (startup fails if not root). false: run as an ordinary account; cron, users, system updates, docker-group remediation, Ardent management, credential renewal and the execution supervisor are disabled and their routes return 403 AGENT_NOT_PRIVILEGED.' },
15
+ { name: 'MAGNUS_AGENT_SELF_UPGRADE', required: false, secret: false, default: 'true', group: 'core', description: 'true: accept server-driven upgrades to an exact version and npm integrity, installed into versions/<X> and confirmed by the launcher (needs `magnus-agent install-service`). false: refuse them; upgrade by hand or by image.' },
16
+ { 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
+ { 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
+ { 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.' },
15
20
  { name: 'MAGNUS_EXECUTION_SUPERVISOR_ENABLED', required: false, secret: false, default: 'false', group: 'core', description: 'Run commands under the durable execution supervisor.' },
16
21
  { 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.' },
17
22
  { 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.' },
@@ -6,8 +6,8 @@ export declare const schedulerStatusSchema: z.ZodObject<{
6
6
  nodeFqdn: z.ZodString;
7
7
  nonce: z.ZodString;
8
8
  state: z.ZodEnum<{
9
- enrolled: "enrolled";
10
9
  legacy: "legacy";
10
+ enrolled: "enrolled";
11
11
  draining: "draining";
12
12
  }>;
13
13
  epoch: z.ZodNumber;
@@ -25,7 +25,7 @@ export declare function freshSchedulerStatus(input: {
25
25
  nodeId: string;
26
26
  nodeFqdn: string;
27
27
  nonce: string;
28
- state: "enrolled" | "legacy" | "draining";
28
+ state: "legacy" | "enrolled" | "draining";
29
29
  epoch: number;
30
30
  barrierId: string | null;
31
31
  schedulerChallenge: {
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ Usage:
12
12
  magnus-agent [start] [--env-file <path>] Start the agent (default)
13
13
  magnus-agent check [--json] [--env-file <path>] Validate configuration without starting
14
14
  magnus-agent env-schema Print the configuration schema as JSON
15
+ magnus-agent install-service [--unprivileged] (root, once) Versioned layout, upgrade launcher and systemd unit
15
16
  magnus-agent --help | --version
16
17
 
17
18
  Options:
@@ -92,6 +93,10 @@ export async function main(argv) {
92
93
  }
93
94
  return report.ok ? 0 : 1;
94
95
  }
96
+ case 'install-service': {
97
+ const { installService } = await import('./upgrade/install-service.js');
98
+ return installService(cli.rest);
99
+ }
95
100
  default:
96
101
  return fail(`unknown command: ${cli.command}. Run "magnus-agent --help".`);
97
102
  }
@@ -82,6 +82,16 @@ export declare const NATIVE_COMPLETION_REGISTRY: {
82
82
  readonly completionMode: "buffered-synchronous-http";
83
83
  readonly timeoutCeilingSeconds: 900;
84
84
  readonly resourceClassifier: "node-ardent";
85
+ }, {
86
+ readonly id: "agent-self-upgrade";
87
+ readonly method: "POST";
88
+ readonly pathMatcher: {
89
+ readonly kind: "exact";
90
+ readonly path: "/agent-upgrade";
91
+ };
92
+ readonly completionMode: "buffered-synchronous-http";
93
+ readonly timeoutCeilingSeconds: 900;
94
+ readonly resourceClassifier: "node-filesystem";
85
95
  }];
86
96
  };
87
97
  export type NativeCompletionDescriptor = (typeof NATIVE_COMPLETION_REGISTRY.descriptors)[number];
@@ -20,6 +20,8 @@ export const NATIVE_COMPLETION_REGISTRY = {
20
20
  completionMode: 'buffered-synchronous-http', timeoutCeilingSeconds: 3600, resourceClassifier: 'node-scheduler' },
21
21
  { id: 'ardent-action', method: 'POST', pathMatcher: { kind: 'exact', path: '/ardent/action' },
22
22
  completionMode: 'buffered-synchronous-http', timeoutCeilingSeconds: 900, resourceClassifier: 'node-ardent' },
23
+ { id: 'agent-self-upgrade', method: 'POST', pathMatcher: { kind: 'exact', path: '/agent-upgrade' },
24
+ completionMode: 'buffered-synchronous-http', timeoutCeilingSeconds: 900, resourceClassifier: 'node-filesystem' },
23
25
  ],
24
26
  };
25
27
  export const NATIVE_COMPLETION_REGISTRY_SHA256 = createHash('sha256')
package/dist/server.js CHANGED
@@ -162,7 +162,7 @@ export async function createApp() {
162
162
  });
163
163
  // ── Node-management routes (fleet agent) ──
164
164
  if (ENABLE_NODE_MANAGEMENT) {
165
- const [{ healthRoutes }, { commandRoutes }, { managedMutationGuard, registerManagedHttp }, { cronRoutes }, { fileRoutes }, { skillRoutes }, { updateRoutes }, { driftRoutes }, { networkInfoRoutes }, { profilingRoutes }, { otelRoutes }, { workspaceRoutes }, { hermesRoutes }, { codexRoutes }, { ardentRoutes },] = await Promise.all([
165
+ const [{ healthRoutes }, { commandRoutes }, { managedMutationGuard, registerManagedHttp }, { cronRoutes }, { fileRoutes }, { skillRoutes }, { updateRoutes }, { driftRoutes }, { networkInfoRoutes }, { profilingRoutes }, { otelRoutes }, { workspaceRoutes }, { hermesRoutes }, { codexRoutes }, { ardentRoutes }, { agentUpgradeRoutes },] = await Promise.all([
166
166
  import('./collectors/health-routes.js'),
167
167
  import('./executors/command-executor.js'),
168
168
  import('./executors/managed-http.js'),
@@ -178,6 +178,7 @@ export async function createApp() {
178
178
  import('./executors/hermes-manager.js'),
179
179
  import('./executors/codex-manager.js'),
180
180
  import('./executors/ardent-manager.js'),
181
+ import('./upgrade/routes.js'),
181
182
  ]);
182
183
  const v1 = new Hono();
183
184
  v1.use('*', nodeAuthMiddleware);
@@ -205,6 +206,7 @@ export async function createApp() {
205
206
  v1.route('/hermes', hermesRoutes);
206
207
  v1.route('/codex', codexRoutes);
207
208
  v1.route('/ardent', ardentRoutes);
209
+ v1.route('/agent-upgrade', agentUpgradeRoutes);
208
210
  // User management (provision, password, listing)
209
211
  const { userRoutes } = await import('./routes/user-manager.js');
210
212
  v1.route('/users', userRoutes);
@@ -0,0 +1,14 @@
1
+ export interface InstallServiceOptions {
2
+ dir: string;
3
+ unprivileged: boolean;
4
+ user: string;
5
+ dryRun: boolean;
6
+ start: boolean;
7
+ unitPath: string;
8
+ nodePath: string;
9
+ }
10
+ export declare const INSTALL_SERVICE_USAGE = "Usage: sudo magnus-agent install-service [options]\n\nMoves this install into the versioned layout used by self-upgrades, installs\nthe upgrade launcher and writes the systemd unit. Run it once per node.\n\nOptions:\n --dir DIR Install directory (default: AGENT_INSTALL_DIR or /opt/magnus-agent)\n --unprivileged Run as an ordinary account (MAGNUS_AGENT_PRIVILEGED=false)\n --user NAME Account for --unprivileged (default magnus-agent; must exist)\n --unit PATH Unit file (default /etc/systemd/system/magnus-agent.service)\n --no-start Write everything, but do not restart the service\n --dry-run Print the steps without changing anything";
11
+ export declare function parseInstallServiceArgs(argv: readonly string[], env?: NodeJS.ProcessEnv): InstallServiceOptions | 'help';
12
+ export declare function renderUnit(options: Pick<InstallServiceOptions, 'dir' | 'unprivileged' | 'user' | 'nodePath'>): string;
13
+ export declare function installService(argv: readonly string[]): Promise<number>;
14
+ //# sourceMappingURL=install-service.d.ts.map
@@ -0,0 +1,227 @@
1
+ /**
2
+ * `magnus-agent install-service`: one-time, operator-run (root) setup that
3
+ * moves an npm-prefix install into the versioned layout the self-upgrade
4
+ * needs, installs the launcher and writes the systemd unit:
5
+ *
6
+ * /opt/magnus-agent/.env
7
+ * /opt/magnus-agent/versions/<X>/node_modules/@hivemagnus/magnus-agent
8
+ * /opt/magnus-agent/current -> versions/<X>
9
+ * /opt/magnus-agent/launcher/launcher.mjs (ExecStart)
10
+ *
11
+ * After this the agent upgrades itself inside that directory; it never needs
12
+ * root for it and never calls sudo.
13
+ */
14
+ import { execFileSync } from 'node:child_process';
15
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
16
+ import { dirname, join, resolve } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { AGENT_PACKAGE, pointCurrentAt } from './self-upgrade.js';
19
+ export const INSTALL_SERVICE_USAGE = `Usage: sudo magnus-agent install-service [options]
20
+
21
+ Moves this install into the versioned layout used by self-upgrades, installs
22
+ the upgrade launcher and writes the systemd unit. Run it once per node.
23
+
24
+ Options:
25
+ --dir DIR Install directory (default: AGENT_INSTALL_DIR or /opt/magnus-agent)
26
+ --unprivileged Run as an ordinary account (MAGNUS_AGENT_PRIVILEGED=false)
27
+ --user NAME Account for --unprivileged (default magnus-agent; must exist)
28
+ --unit PATH Unit file (default /etc/systemd/system/magnus-agent.service)
29
+ --no-start Write everything, but do not restart the service
30
+ --dry-run Print the steps without changing anything`;
31
+ export function parseInstallServiceArgs(argv, env = process.env) {
32
+ const options = {
33
+ dir: env['AGENT_INSTALL_DIR'] ?? '/opt/magnus-agent', unprivileged: false, user: 'magnus-agent', dryRun: false, start: true,
34
+ unitPath: '/etc/systemd/system/magnus-agent.service', nodePath: process.execPath,
35
+ };
36
+ for (let index = 0; index < argv.length; index++) {
37
+ const arg = argv[index];
38
+ const value = () => {
39
+ const next = argv[++index];
40
+ if (!next || next.startsWith('--'))
41
+ throw new Error(`${arg} needs a value`);
42
+ return next;
43
+ };
44
+ if (arg === '--help' || arg === '-h')
45
+ return 'help';
46
+ else if (arg === '--dir')
47
+ options.dir = value();
48
+ else if (arg === '--unprivileged')
49
+ options.unprivileged = true;
50
+ else if (arg === '--user')
51
+ options.user = value();
52
+ else if (arg === '--unit')
53
+ options.unitPath = value();
54
+ else if (arg === '--no-start')
55
+ options.start = false;
56
+ else if (arg === '--dry-run')
57
+ options.dryRun = true;
58
+ else
59
+ throw new Error(`unknown option ${arg}`);
60
+ }
61
+ options.dir = resolve(options.dir);
62
+ return options;
63
+ }
64
+ export function renderUnit(options) {
65
+ const { dir } = options;
66
+ const common = [
67
+ 'Type=simple',
68
+ `WorkingDirectory=${dir}`,
69
+ `Environment=AGENT_INSTALL_DIR=${dir}`,
70
+ `Environment=npm_config_cache=${join(dir, '.npm')}`,
71
+ `EnvironmentFile=${join(dir, '.env')}`,
72
+ `ExecStart=${options.nodePath} ${join(dir, 'launcher', 'launcher.mjs')}`,
73
+ '# Any exit restarts: the launcher exits to hand over to a new or reverted version.',
74
+ 'Restart=always',
75
+ 'RestartSec=5',
76
+ 'StandardOutput=journal',
77
+ 'StandardError=journal',
78
+ 'UMask=0077',
79
+ 'NoNewPrivileges=true',
80
+ 'PrivateTmp=true',
81
+ ];
82
+ const hardening = options.unprivileged ? [
83
+ `User=${options.user}`,
84
+ `Group=${options.user}`,
85
+ 'Environment=MAGNUS_AGENT_PRIVILEGED=false',
86
+ 'CapabilityBoundingSet=',
87
+ 'AmbientCapabilities=',
88
+ 'PrivateDevices=true',
89
+ 'ProtectSystem=strict',
90
+ `ReadWritePaths=${dir}`,
91
+ 'StateDirectory=magnus-agent',
92
+ 'ProtectHome=read-only',
93
+ 'ProtectKernelTunables=true',
94
+ 'ProtectKernelModules=true',
95
+ 'ProtectKernelLogs=true',
96
+ 'ProtectControlGroups=true',
97
+ 'ProtectClock=true',
98
+ 'ProtectHostname=true',
99
+ 'RestrictSUIDSGID=true',
100
+ 'RestrictRealtime=true',
101
+ 'RestrictNamespaces=true',
102
+ 'LockPersonality=true',
103
+ 'RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK',
104
+ 'SystemCallArchitectures=native',
105
+ ] : [];
106
+ return [
107
+ '# Generated by `magnus-agent install-service`. The launcher runs ./current and',
108
+ '# reverts a self-upgrade that does not report a heartbeat in time.',
109
+ '[Unit]',
110
+ `Description=Magnus Agent${options.unprivileged ? ' (unprivileged)' : ''}`,
111
+ 'After=network-online.target',
112
+ 'Wants=network-online.target',
113
+ 'StartLimitIntervalSec=0',
114
+ '',
115
+ '[Service]',
116
+ ...common,
117
+ ...hardening,
118
+ '',
119
+ '[Install]',
120
+ 'WantedBy=multi-user.target',
121
+ '',
122
+ ].join('\n');
123
+ }
124
+ function packageRoot() {
125
+ return resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
126
+ }
127
+ function ownVersion() {
128
+ return JSON.parse(readFileSync(join(packageRoot(), 'package.json'), 'utf8')).version;
129
+ }
130
+ function isSymlink(path) {
131
+ try {
132
+ return lstatSync(path).isSymbolicLink();
133
+ }
134
+ catch {
135
+ return false;
136
+ }
137
+ }
138
+ export async function installService(argv) {
139
+ let options;
140
+ try {
141
+ options = parseInstallServiceArgs(argv);
142
+ }
143
+ catch (error) {
144
+ console.error(`magnus-agent install-service: ${error.message}`);
145
+ return 2;
146
+ }
147
+ if (options === 'help') {
148
+ console.log(INSTALL_SERVICE_USAGE);
149
+ return 0;
150
+ }
151
+ const { dir, dryRun } = options;
152
+ if (!dryRun && (typeof process.geteuid !== 'function' || process.geteuid() !== 0)) {
153
+ console.error('magnus-agent install-service writes a system unit; run it as root (sudo). Use --dry-run to preview.');
154
+ return 1;
155
+ }
156
+ const version = ownVersion();
157
+ const steps = [];
158
+ const act = (description, action) => {
159
+ steps.push(description);
160
+ if (!dryRun)
161
+ action();
162
+ };
163
+ const target = join(dir, 'versions', version);
164
+ const current = join(dir, 'current');
165
+ const here = realpathSync(packageRoot());
166
+ if (!existsSync(join(dir, '.env')))
167
+ console.warn(`warning: ${join(dir, '.env')} does not exist; create it before the service starts`);
168
+ if (isSymlink(current)) {
169
+ steps.push(`keep the versioned layout (./current -> ${realpathSync(current)})`);
170
+ }
171
+ else {
172
+ const prefixModules = join(dir, 'node_modules');
173
+ const fromPrefix = existsSync(prefixModules) && here === realpathSync(join(prefixModules, ...AGENT_PACKAGE.split('/')));
174
+ act(`create ${target}`, () => mkdirSync(target, { recursive: true }));
175
+ if (fromPrefix) {
176
+ act(`move ${prefixModules} to ${join(target, 'node_modules')}`, () => renameSync(prefixModules, join(target, 'node_modules')));
177
+ for (const file of ['package.json', 'package-lock.json']) {
178
+ if (existsSync(join(dir, file)))
179
+ act(`move ${file} into ${target}`, () => renameSync(join(dir, file), join(target, file)));
180
+ }
181
+ }
182
+ else {
183
+ act(`npm install ${AGENT_PACKAGE}@${version} into ${target}`, () => {
184
+ writeFileSync(join(target, 'package.json'), `${JSON.stringify({ name: `magnus-agent-${version}`, version, private: true,
185
+ dependencies: { [AGENT_PACKAGE]: version }, allowScripts: { 'cpu-features': true, ssh2: true } }, null, 2)}\n`);
186
+ execFileSync('npm', ['install', '--omit=dev', '--no-audit', '--no-fund', '--loglevel=error'], { cwd: target, stdio: 'inherit' });
187
+ });
188
+ }
189
+ act(`point ${current} at versions/${version}`, () => pointCurrentAt(dir, version));
190
+ }
191
+ // The launcher is copied from the release that runs install-service, not linked:
192
+ // self-upgrades never replace it.
193
+ act(`install ${join(dir, 'launcher', 'launcher.mjs')}`, () => {
194
+ mkdirSync(join(dir, 'launcher'), { recursive: true, mode: 0o755 });
195
+ copyFileSync(join(isSymlink(current) ? realpathSync(join(current, 'node_modules', ...AGENT_PACKAGE.split('/'))) : here, 'launcher', 'launcher.mjs'), join(dir, 'launcher', 'launcher.mjs'));
196
+ });
197
+ act(`create ${join(dir, '.npm')} and ${join(dir, 'versions')}`, () => {
198
+ mkdirSync(join(dir, '.npm'), { recursive: true, mode: 0o700 });
199
+ mkdirSync(join(dir, 'versions'), { recursive: true });
200
+ });
201
+ if (options.unprivileged) {
202
+ const user = options.user;
203
+ act(`give ${user} the install directory (the agent writes versions/, current and its upgrade state)`, () => {
204
+ execFileSync('id', ['-u', user], { stdio: 'ignore' });
205
+ execFileSync('chown', [`${user}:${user}`, dir]);
206
+ execFileSync('chown', ['-R', `${user}:${user}`, join(dir, 'versions'), join(dir, '.npm')]);
207
+ execFileSync('chown', ['-h', `${user}:${user}`, current]);
208
+ });
209
+ }
210
+ act(`write ${options.unitPath}${existsSync(options.unitPath) ? ` (previous unit kept as ${options.unitPath}.pre-launcher)` : ''}`, () => {
211
+ if (existsSync(options.unitPath) && !existsSync(`${options.unitPath}.pre-launcher`))
212
+ copyFileSync(options.unitPath, `${options.unitPath}.pre-launcher`);
213
+ writeFileSync(options.unitPath, renderUnit(options), { mode: 0o644 });
214
+ });
215
+ const unit = options.unitPath.split('/').pop();
216
+ act(`systemctl daemon-reload && systemctl enable ${unit}`, () => {
217
+ execFileSync('systemctl', ['daemon-reload']);
218
+ execFileSync('systemctl', ['enable', unit], { stdio: 'ignore' });
219
+ });
220
+ if (options.start)
221
+ act(`systemctl restart ${unit}`, () => execFileSync('systemctl', ['restart', unit]));
222
+ console.log(`${dryRun ? 'Would' : 'Done'}:\n${steps.map(step => ` - ${step}`).join('\n')}`);
223
+ if (!dryRun)
224
+ console.log(`The agent now runs ${version} through the launcher; the server can upgrade it from the Nodes page or with \`magnus-server agents upgrade\`.`);
225
+ return 0;
226
+ }
227
+ //# sourceMappingURL=install-service.js.map
@@ -0,0 +1,5 @@
1
+ import { Hono } from 'hono';
2
+ /** Seconds between the reply and the exit that hands over to the launcher. */
3
+ export declare const RESTART_DELAY_MS = 3000;
4
+ export declare const agentUpgradeRoutes: Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
5
+ //# sourceMappingURL=routes.d.ts.map
@@ -0,0 +1,31 @@
1
+ import { Hono } from 'hono';
2
+ import { agentUpgradeRequestSchema } from '@hivemagnus/magnus-contracts';
3
+ import { logger } from '../utils/logger.js';
4
+ import { AGENT_VERSION } from '../version.js';
5
+ import { SelfUpgradeError, selfUpgradeStatus, stageUpgrade } from './self-upgrade.js';
6
+ /** Seconds between the reply and the exit that hands over to the launcher. */
7
+ export const RESTART_DELAY_MS = 3_000;
8
+ export const agentUpgradeRoutes = new Hono();
9
+ agentUpgradeRoutes.get('/status', c => c.json({ success: true, data: selfUpgradeStatus(AGENT_VERSION) }));
10
+ agentUpgradeRoutes.post('/', async (c) => {
11
+ const parsed = agentUpgradeRequestSchema.safeParse(await c.req.json().catch(() => null));
12
+ if (!parsed.success) {
13
+ return c.json({ success: false, error: { code: 'VALIDATION_ERROR', message: 'version, integrity (sha512-…) and requestId are required' } }, 400);
14
+ }
15
+ try {
16
+ const result = await stageUpgrade({ request: parsed.data, version: AGENT_VERSION });
17
+ logger.info(`Self-upgrade staged: ${result.from} -> ${result.to}; restarting through the launcher`);
18
+ // Reply first; the launcher starts versions/<to> on trial after this process exits.
19
+ setTimeout(() => process.kill(process.pid, 'SIGTERM'), RESTART_DELAY_MS).unref();
20
+ return c.json({ success: true, data: { accepted: true, ...result, restartInSeconds: RESTART_DELAY_MS / 1000 } });
21
+ }
22
+ catch (error) {
23
+ if (error instanceof SelfUpgradeError) {
24
+ logger.warn(`Self-upgrade to ${parsed.data.version} refused: ${error.message}`);
25
+ return c.json({ success: false, error: { code: error.code, message: error.message } }, error.status);
26
+ }
27
+ logger.error('Self-upgrade failed', { error: error instanceof Error ? error.message : String(error) });
28
+ return c.json({ success: false, error: { code: 'SELF_UPGRADE_FAILED', message: error instanceof Error ? error.message : String(error) } }, 500);
29
+ }
30
+ });
31
+ //# sourceMappingURL=routes.js.map
@@ -0,0 +1,65 @@
1
+ import { type AgentSelfUpgradeStatus, type AgentUpgradeRequest } from '@hivemagnus/magnus-contracts';
2
+ export declare const STATE_FILE = "upgrade-state.json";
3
+ export declare const AGENT_PACKAGE = "@hivemagnus/magnus-agent";
4
+ export interface UpgradeState {
5
+ state: 'idle' | 'staged' | 'trial' | 'confirmed' | 'reverted' | 'failed';
6
+ from: string | null;
7
+ to: string | null;
8
+ requestId: string | null;
9
+ attempt?: number;
10
+ maxAttempts?: number;
11
+ trialDeadline?: string;
12
+ reason?: string | null;
13
+ updatedAt?: string;
14
+ }
15
+ export declare function installDir(env?: NodeJS.ProcessEnv): string;
16
+ export declare function readUpgradeState(dir: string): UpgradeState | null;
17
+ export declare function writeUpgradeState(dir: string, state: UpgradeState): void;
18
+ export declare function pointCurrentAt(dir: string, version: string): void;
19
+ export type Layout = AgentSelfUpgradeStatus['layout'];
20
+ export declare function detectLayout(dir: string, env?: NodeJS.ProcessEnv): Layout;
21
+ /** Whether this agent can upgrade itself, and why not. Reported with every heartbeat. */
22
+ export declare function selfUpgradeStatus(version: string, env?: NodeJS.ProcessEnv): AgentSelfUpgradeStatus;
23
+ /**
24
+ * Called after every accepted heartbeat: the first one from a version on trial
25
+ * confirms it, and versions other than the previous and the current one are pruned.
26
+ */
27
+ export declare function confirmTrial(version: string, env?: NodeJS.ProcessEnv, now?: Date): boolean;
28
+ export declare function pruneVersions(dir: string, keep: ReadonlyArray<string | null>): void;
29
+ export declare class SelfUpgradeError extends Error {
30
+ readonly code: string;
31
+ readonly status: 400 | 409 | 422 | 500;
32
+ constructor(code: string, message: string, status?: 400 | 409 | 422 | 500);
33
+ }
34
+ export interface Runner {
35
+ (command: string, args: readonly string[], options: {
36
+ cwd: string;
37
+ env: NodeJS.ProcessEnv;
38
+ }): Promise<{
39
+ code: number;
40
+ stdout: string;
41
+ stderr: string;
42
+ }>;
43
+ }
44
+ export declare const runProcess: Runner;
45
+ /** npm pack names files `<scope>-<name>-<version>.tgz`. */
46
+ export declare function tarballName(version: string): string;
47
+ export declare function sha512Integrity(file: string): string;
48
+ /** Other @hivemagnus tarballs in the source directory, used instead of the registry (offline nodes). */
49
+ export declare function sourceOverrides(sourceDir: string, agentTarball: string): Record<string, string>;
50
+ export interface StageOptions {
51
+ request: AgentUpgradeRequest;
52
+ version: string;
53
+ env?: NodeJS.ProcessEnv;
54
+ run?: Runner;
55
+ now?: Date;
56
+ }
57
+ export interface StageResult {
58
+ from: string;
59
+ to: string;
60
+ integrity: string;
61
+ requestId: string;
62
+ }
63
+ /** Download, verify, install and switch. The caller exits afterwards so the launcher starts the new version. */
64
+ export declare function stageUpgrade(options: StageOptions): Promise<StageResult>;
65
+ //# sourceMappingURL=self-upgrade.d.ts.map
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Agent self-upgrade (MAGNUS_AGENT_SELF_UPGRADE). The server asks for an exact
3
+ * version and its npm integrity; the agent downloads that tarball, checks it,
4
+ * installs it into versions/<X> beside the running one, points ./current at
5
+ * it and exits. The launcher (launcher/launcher.mjs, installed by
6
+ * `magnus-agent install-service`) starts the new version on trial and switches
7
+ * back if it does not report an accepted heartbeat in time. Nothing here uses
8
+ * sudo: the agent only writes inside its own install directory.
9
+ */
10
+ import { createHash } from 'node:crypto';
11
+ import { spawn } from 'node:child_process';
12
+ import { accessSync, constants, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
13
+ import { join, resolve } from 'node:path';
14
+ import { AGENT_SELF_UPGRADE_PROTOCOL, checkUpgradeAllowed, compareVersions, } from '@hivemagnus/magnus-contracts';
15
+ export const STATE_FILE = 'upgrade-state.json';
16
+ export const AGENT_PACKAGE = '@hivemagnus/magnus-agent';
17
+ export function installDir(env = process.env) {
18
+ return resolve(env['AGENT_INSTALL_DIR'] ?? process.cwd());
19
+ }
20
+ export function readUpgradeState(dir) {
21
+ try {
22
+ return JSON.parse(readFileSync(join(dir, STATE_FILE), 'utf8'));
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ export function writeUpgradeState(dir, state) {
29
+ const path = join(dir, STATE_FILE);
30
+ const temporary = `${path}.${process.pid}.tmp`;
31
+ writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
32
+ renameSync(temporary, path);
33
+ }
34
+ export function pointCurrentAt(dir, version) {
35
+ const temporary = join(dir, `current.${process.pid}.tmp`);
36
+ rmSync(temporary, { force: true });
37
+ symlinkSync(join('versions', version), temporary, 'dir');
38
+ renameSync(temporary, join(dir, 'current'));
39
+ }
40
+ export function detectLayout(dir, env = process.env) {
41
+ if (env['MAGNUS_AGENT_CONTAINER'] === '1')
42
+ return 'container';
43
+ try {
44
+ if (env['MAGNUS_AGENT_LAUNCHER'] === '1' && lstatSync(join(dir, 'current')).isSymbolicLink())
45
+ return 'versioned';
46
+ }
47
+ catch {
48
+ // no ./current
49
+ }
50
+ return 'legacy';
51
+ }
52
+ function selfUpgradeSwitch(env) {
53
+ return (env['MAGNUS_AGENT_SELF_UPGRADE'] ?? 'true').trim().toLowerCase() !== 'false';
54
+ }
55
+ /** Whether this agent can upgrade itself, and why not. Reported with every heartbeat. */
56
+ export function selfUpgradeStatus(version, env = process.env) {
57
+ const dir = installDir(env);
58
+ const layout = detectLayout(dir, env);
59
+ const launcherProtocol = Number.parseInt(env['MAGNUS_AGENT_LAUNCHER_PROTOCOL'] ?? '', 10);
60
+ const state = readUpgradeState(dir);
61
+ let reason = null;
62
+ if (!selfUpgradeSwitch(env))
63
+ reason = 'MAGNUS_AGENT_SELF_UPGRADE=false on this node';
64
+ else if (layout === 'container')
65
+ reason = 'container agent: upgrade by pulling a newer image';
66
+ else if (layout === 'legacy')
67
+ reason = 'not running under the upgrade launcher; run `sudo magnus-agent install-service` once on this node';
68
+ else {
69
+ try {
70
+ accessSync(dir, constants.W_OK);
71
+ accessSync(join(dir, 'versions'), constants.W_OK);
72
+ }
73
+ catch {
74
+ reason = `the agent cannot write ${dir}`;
75
+ }
76
+ }
77
+ return {
78
+ protocol: AGENT_SELF_UPGRADE_PROTOCOL,
79
+ launcherProtocol: Number.isInteger(launcherProtocol) ? launcherProtocol : null,
80
+ layout,
81
+ enabled: reason === null,
82
+ reason,
83
+ upgrade: state ? reportOf(state) : { state: 'idle', from: null, to: version },
84
+ };
85
+ }
86
+ function reportOf(state) {
87
+ return {
88
+ state: state.state, from: state.from, to: state.to, requestId: state.requestId, reason: state.reason ?? null,
89
+ ...(state.attempt !== undefined ? { attempt: state.attempt } : {}), ...(state.updatedAt ? { updatedAt: state.updatedAt } : {}),
90
+ };
91
+ }
92
+ /**
93
+ * Called after every accepted heartbeat: the first one from a version on trial
94
+ * confirms it, and versions other than the previous and the current one are pruned.
95
+ */
96
+ export function confirmTrial(version, env = process.env, now = new Date()) {
97
+ const dir = installDir(env);
98
+ const state = readUpgradeState(dir);
99
+ if (!state || state.state !== 'trial' || state.to !== version)
100
+ return false;
101
+ writeUpgradeState(dir, { ...state, state: 'confirmed', reason: null, updatedAt: now.toISOString() });
102
+ pruneVersions(dir, [state.from, state.to]);
103
+ return true;
104
+ }
105
+ export function pruneVersions(dir, keep) {
106
+ const versions = join(dir, 'versions');
107
+ if (!existsSync(versions))
108
+ return;
109
+ for (const name of readdirSync(versions)) {
110
+ if (keep.includes(name))
111
+ continue;
112
+ rmSync(join(versions, name), { recursive: true, force: true });
113
+ }
114
+ }
115
+ export class SelfUpgradeError extends Error {
116
+ code;
117
+ status;
118
+ constructor(code, message, status = 409) {
119
+ super(message);
120
+ this.code = code;
121
+ this.status = status;
122
+ }
123
+ }
124
+ export const runProcess = (command, args, options) => new Promise((resolvePromise, reject) => {
125
+ const child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: ['ignore', 'pipe', 'pipe'] });
126
+ let stdout = '';
127
+ let stderr = '';
128
+ child.stdout.on('data', chunk => { stdout += String(chunk); });
129
+ child.stderr.on('data', chunk => { stderr += String(chunk); });
130
+ child.on('error', reject);
131
+ child.on('close', code => resolvePromise({ code: code ?? 1, stdout, stderr }));
132
+ });
133
+ /** npm pack names files `<scope>-<name>-<version>.tgz`. */
134
+ export function tarballName(version) {
135
+ return `hivemagnus-magnus-agent-${version}.tgz`;
136
+ }
137
+ export function sha512Integrity(file) {
138
+ return `sha512-${createHash('sha512').update(readFileSync(file)).digest('base64')}`;
139
+ }
140
+ const ALLOW_SCRIPTS = { 'cpu-features': true, ssh2: true };
141
+ const SOURCE_TARBALL = /^hivemagnus-(.+?)-(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\.tgz$/;
142
+ /** Other @hivemagnus tarballs in the source directory, used instead of the registry (offline nodes). */
143
+ export function sourceOverrides(sourceDir, agentTarball) {
144
+ const overrides = {};
145
+ for (const file of readdirSync(sourceDir)) {
146
+ const match = SOURCE_TARBALL.exec(file);
147
+ const path = join(sourceDir, file);
148
+ if (!match || path === agentTarball || match[1] === 'magnus-agent')
149
+ continue;
150
+ overrides[`@hivemagnus/${match[1]}`] = `file:${path}`;
151
+ }
152
+ return overrides;
153
+ }
154
+ let staging = false;
155
+ /** Download, verify, install and switch. The caller exits afterwards so the launcher starts the new version. */
156
+ export async function stageUpgrade(options) {
157
+ const env = options.env ?? process.env;
158
+ const run = options.run ?? runProcess;
159
+ const { request, version: from } = options;
160
+ const status = selfUpgradeStatus(from, env);
161
+ if (!status.enabled)
162
+ throw new SelfUpgradeError('SELF_UPGRADE_UNAVAILABLE', status.reason ?? 'self-upgrade is unavailable');
163
+ if (request.packageName !== AGENT_PACKAGE)
164
+ throw new SelfUpgradeError('SELF_UPGRADE_PACKAGE', `only ${AGENT_PACKAGE} can be installed`, 422);
165
+ if (compareVersions(request.version, from) === 0)
166
+ throw new SelfUpgradeError('SELF_UPGRADE_CURRENT', `already running ${from}`);
167
+ const decision = checkUpgradeAllowed(from, request.version);
168
+ if (!decision.allowed)
169
+ throw new SelfUpgradeError('SELF_UPGRADE_REFUSED', decision.reason ?? 'upgrade refused', 422);
170
+ const previous = status.upgrade?.state;
171
+ if (previous === 'staged' || previous === 'trial')
172
+ throw new SelfUpgradeError('SELF_UPGRADE_BUSY', `an upgrade to ${status.upgrade?.to} is still on trial`);
173
+ if (staging)
174
+ throw new SelfUpgradeError('SELF_UPGRADE_BUSY', 'an upgrade is already being staged');
175
+ staging = true;
176
+ const dir = installDir(env);
177
+ const work = join(dir, 'staging', request.version);
178
+ const target = join(dir, 'versions', request.version);
179
+ try {
180
+ rmSync(work, { recursive: true, force: true });
181
+ mkdirSync(work, { recursive: true, mode: 0o700 });
182
+ const npm = env['MAGNUS_AGENT_NPM'] || 'npm';
183
+ const npmEnv = { ...env, npm_config_cache: env['npm_config_cache'] ?? join(dir, '.npm'), npm_config_update_notifier: 'false' };
184
+ const fail = (step, result) => {
185
+ throw new SelfUpgradeError('SELF_UPGRADE_FAILED', `${step} failed (exit ${result.code}): ${(result.stderr || result.stdout).trim().split('\n').slice(-3).join(' ')}`, 500);
186
+ };
187
+ // 1. The exact tarball: from an operator-provided directory, or the registry.
188
+ const sourceDir = env['MAGNUS_AGENT_UPGRADE_SOURCE_DIR']?.trim();
189
+ let tarball;
190
+ if (sourceDir) {
191
+ tarball = join(resolve(sourceDir), tarballName(request.version));
192
+ if (!existsSync(tarball))
193
+ throw new SelfUpgradeError('SELF_UPGRADE_SOURCE', `${tarball} does not exist`, 422);
194
+ }
195
+ else {
196
+ const packed = await run(npm, ['pack', `${AGENT_PACKAGE}@${request.version}`, '--pack-destination', work, '--json', '--loglevel=error'], { cwd: work, env: npmEnv });
197
+ if (packed.code !== 0)
198
+ fail('npm pack', packed);
199
+ tarball = join(work, tarballName(request.version));
200
+ }
201
+ // 2. The bytes must match what the server pinned (npm also checks the registry's own digest on download).
202
+ const integrity = sha512Integrity(tarball);
203
+ if (integrity !== request.integrity) {
204
+ throw new SelfUpgradeError('SELF_UPGRADE_INTEGRITY', `tarball integrity ${integrity.slice(0, 20)}… differs from the requested ${request.integrity.slice(0, 20)}…`, 422);
205
+ }
206
+ // 3. Install beside the running version.
207
+ const install = join(work, 'install');
208
+ mkdirSync(install, { recursive: true });
209
+ const overrides = sourceDir ? sourceOverrides(resolve(sourceDir), tarball) : {};
210
+ writeFileSync(join(install, 'package.json'), `${JSON.stringify({
211
+ name: `magnus-agent-${request.version}`, version: request.version, private: true, type: 'module',
212
+ dependencies: { [AGENT_PACKAGE]: `file:${tarball}` }, ...(Object.keys(overrides).length ? { overrides } : {}), allowScripts: ALLOW_SCRIPTS,
213
+ }, null, 2)}\n`);
214
+ const installed = await run(npm, ['install', '--omit=dev', '--no-audit', '--no-fund', '--loglevel=error'], { cwd: install, env: npmEnv });
215
+ if (installed.code !== 0)
216
+ fail('npm install', installed);
217
+ // 4. Registry signatures and provenance of everything installed.
218
+ if ((env['MAGNUS_AGENT_UPGRADE_REQUIRE_PROVENANCE'] ?? 'true').trim().toLowerCase() !== 'false') {
219
+ const audit = await run(npm, ['audit', 'signatures', '--omit=dev', '--loglevel=error'], { cwd: install, env: npmEnv });
220
+ if (audit.code !== 0)
221
+ fail('npm audit signatures', audit);
222
+ }
223
+ // 5. The new code must at least start and report the requested version.
224
+ const entry = join(install, 'node_modules', '@hivemagnus', 'magnus-agent', 'dist', 'index.js');
225
+ const smoke = await run(process.execPath, [entry, '--version'], { cwd: install, env });
226
+ if (smoke.code !== 0 || smoke.stdout.trim() !== request.version) {
227
+ throw new SelfUpgradeError('SELF_UPGRADE_FAILED', `the installed agent reports "${smoke.stdout.trim()}", not ${request.version}`, 500);
228
+ }
229
+ rmSync(target, { recursive: true, force: true });
230
+ mkdirSync(join(dir, 'versions'), { recursive: true });
231
+ renameSync(install, target);
232
+ const now = (options.now ?? new Date()).toISOString();
233
+ writeUpgradeState(dir, { state: 'staged', from, to: request.version, requestId: request.requestId, attempt: 0, maxAttempts: 2, reason: null, updatedAt: now });
234
+ pointCurrentAt(dir, request.version);
235
+ return { from, to: request.version, integrity, requestId: request.requestId };
236
+ }
237
+ finally {
238
+ rmSync(work, { recursive: true, force: true });
239
+ staging = false;
240
+ }
241
+ }
242
+ //# sourceMappingURL=self-upgrade.js.map
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * magnus-agent launcher: the stable process systemd starts. It runs the agent
4
+ * from ./current and guards self-upgrades:
5
+ *
6
+ * staged the agent installed versions/<to>, pointed ./current at it and exited
7
+ * trial the new version runs; it must report one accepted heartbeat
8
+ * (state "confirmed") before the deadline
9
+ * confirmed done
10
+ * reverted the trial failed (crash loop or no heartbeat in time); ./current
11
+ * points back at versions/<from>
12
+ *
13
+ * The launcher has no dependencies and never changes during an upgrade; only
14
+ * `magnus-agent install-service` replaces it. It never escalates privileges.
15
+ */
16
+ import { spawn } from 'node:child_process';
17
+ import { existsSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync, openSync, fsyncSync, closeSync } from 'node:fs';
18
+ import { dirname, join, resolve } from 'node:path';
19
+ import { fileURLToPath, pathToFileURL } from 'node:url';
20
+
21
+ export const LAUNCHER_PROTOCOL = 1;
22
+ export const STATE_FILE = 'upgrade-state.json';
23
+ const POLL_MS = 2_000;
24
+
25
+ /**
26
+ * Pure decision for one launch.
27
+ * @param {null | Record<string, any>} state
28
+ * @param {number} now epoch ms
29
+ * @param {number} confirmMs trial window
30
+ * @returns {{ action: 'run' | 'revert', next: Record<string, any> | null, trial: boolean, reason?: string }}
31
+ */
32
+ export function decideLaunch(state, now, confirmMs) {
33
+ if (!state || typeof state !== 'object') return { action: 'run', next: null, trial: false };
34
+ const maxAttempts = Number.isInteger(state.maxAttempts) && state.maxAttempts > 0 ? state.maxAttempts : 2;
35
+ const stamp = new Date(now).toISOString();
36
+ if (state.state === 'staged') {
37
+ return { action: 'run', trial: true,
38
+ next: { ...state, state: 'trial', attempt: 1, maxAttempts, trialDeadline: new Date(now + confirmMs).toISOString(), updatedAt: stamp } };
39
+ }
40
+ if (state.state === 'trial') {
41
+ const deadline = Date.parse(state.trialDeadline ?? '');
42
+ const attempt = Number.isInteger(state.attempt) ? state.attempt : maxAttempts;
43
+ if (!Number.isFinite(deadline) || now >= deadline) {
44
+ return { action: 'revert', trial: false, reason: `no confirmed heartbeat from ${state.to} within ${Math.round(confirmMs / 1000)} s`,
45
+ next: { ...state, state: 'reverted', updatedAt: stamp } };
46
+ }
47
+ if (attempt >= maxAttempts) {
48
+ return { action: 'revert', trial: false, reason: `${state.to} exited ${attempt} time(s) before reporting a heartbeat`,
49
+ next: { ...state, state: 'reverted', updatedAt: stamp } };
50
+ }
51
+ return { action: 'run', trial: true, next: { ...state, attempt: attempt + 1, updatedAt: stamp } };
52
+ }
53
+ return { action: 'run', next: null, trial: false };
54
+ }
55
+
56
+ export function readState(dir) {
57
+ try {
58
+ return JSON.parse(readFileSync(join(dir, STATE_FILE), 'utf8'));
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ export function writeState(dir, state) {
65
+ const path = join(dir, STATE_FILE);
66
+ const temporary = `${path}.${process.pid}.tmp`;
67
+ writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
68
+ const fd = openSync(temporary, 'r');
69
+ try { fsyncSync(fd); } finally { closeSync(fd); }
70
+ renameSync(temporary, path);
71
+ }
72
+
73
+ export function pointCurrentAt(dir, version) {
74
+ const temporary = join(dir, `current.${process.pid}.tmp`);
75
+ rmSync(temporary, { force: true });
76
+ symlinkSync(join('versions', version), temporary, 'dir');
77
+ renameSync(temporary, join(dir, 'current'));
78
+ }
79
+
80
+ function revert(dir, decision) {
81
+ const state = decision.next;
82
+ if (state.from && existsSync(join(dir, 'versions', state.from))) pointCurrentAt(dir, state.from);
83
+ else state.reason = `${decision.reason}; versions/${state.from} is missing, so ./current was left as it is`;
84
+ writeState(dir, { ...state, reason: state.reason ?? decision.reason });
85
+ console.error(`[launcher] reverted ${state.to} -> ${state.from}: ${decision.reason}`);
86
+ }
87
+
88
+ export function confirmSeconds(env) {
89
+ const value = Number.parseInt(env.MAGNUS_AGENT_UPGRADE_CONFIRM_SECONDS ?? '', 10);
90
+ return Number.isInteger(value) && value >= 10 ? Math.min(value, 3600) : 180;
91
+ }
92
+
93
+ function main() {
94
+ const dir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
95
+ const confirmMs = confirmSeconds(process.env) * 1000;
96
+ const decision = decideLaunch(readState(dir), Date.now(), confirmMs);
97
+ if (decision.action === 'revert') revert(dir, decision);
98
+ else if (decision.next) writeState(dir, decision.next);
99
+
100
+ const entry = join(dir, 'current', 'node_modules', '@hivemagnus', 'magnus-agent', 'dist', 'index.js');
101
+ if (!existsSync(entry)) {
102
+ console.error(`[launcher] ${entry} is missing; reinstall with magnus-agent install-service`);
103
+ process.exit(1);
104
+ }
105
+ const child = spawn(process.execPath, [entry, 'start'], {
106
+ cwd: dir,
107
+ stdio: 'inherit',
108
+ env: {
109
+ ...process.env,
110
+ AGENT_INSTALL_DIR: process.env.AGENT_INSTALL_DIR ?? dir,
111
+ MAGNUS_AGENT_LAUNCHER: '1',
112
+ MAGNUS_AGENT_LAUNCHER_PROTOCOL: String(LAUNCHER_PROTOCOL),
113
+ },
114
+ });
115
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, () => child.kill(signal));
116
+
117
+ let failedTrial = false;
118
+ let watcher;
119
+ if (decision.trial) {
120
+ watcher = setInterval(() => {
121
+ const state = readState(dir);
122
+ if (!state || state.state !== 'trial') { clearInterval(watcher); return; }
123
+ const verdict = decideLaunch({ ...state, attempt: 0 }, Date.now(), confirmMs);
124
+ if (verdict.action === 'revert') {
125
+ clearInterval(watcher);
126
+ failedTrial = true;
127
+ revert(dir, verdict);
128
+ child.kill('SIGTERM');
129
+ setTimeout(() => child.kill('SIGKILL'), 10_000).unref();
130
+ }
131
+ }, POLL_MS);
132
+ }
133
+ child.on('exit', (code, signal) => {
134
+ if (watcher) clearInterval(watcher);
135
+ // Any exit restarts through systemd (Restart=always); a failed trial exits non-zero.
136
+ process.exit(failedTrial ? 1 : code ?? (signal ? 1 : 0));
137
+ });
138
+ }
139
+
140
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hivemagnus/magnus-agent",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "Magnus node agent: a small daemon that enrolls a Linux host with a magnus-server and runs authenticated health, command, cron, file and skill management, with optional Hermes Agent, Codex and Ardent management.",
5
5
  "keywords": [
6
6
  "magnus",
@@ -36,7 +36,8 @@
36
36
  "LICENSE",
37
37
  ".env.example",
38
38
  "deploy/*.service",
39
- "deploy/*.timer"
39
+ "deploy/*.timer",
40
+ "launcher/launcher.mjs"
40
41
  ],
41
42
  "engines": {
42
43
  "node": "^24.22.1 || ^26.13.0"
@@ -54,6 +55,7 @@
54
55
  "clean": "node -e \"const fs=require('node:fs');fs.rmSync('dist',{recursive:true,force:true});fs.rmSync('tsconfig.tsbuildinfo',{force:true})\""
55
56
  },
56
57
  "dependencies": {
58
+ "@hivemagnus/magnus-contracts": "1.1.0",
57
59
  "@hono/node-server": "^2.1.5",
58
60
  "dotenv": "^18.0.9",
59
61
  "hono": "^4.13.14",