@hivemagnus/magnus-agent 1.0.2 → 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
@@ -95,6 +95,9 @@ LOG_LEVEL=info
95
95
  # ── Ardent (optional) ────────────────────────────────────────────────
96
96
  # ARDENT_OWNER_USER=operator
97
97
  # ARDENT_STAGED_PACKAGE=/opt/magnus-agent/ardent-package.tgz
98
+ # Scoped npm names accepted as the Ardent runtime, in preference order. Add an
99
+ # older build's package name to keep managing it (interoperating with older components).
100
+ # MAGNUS_AGENT_ARDENT_PACKAGES=@hivemagnus/ardent
98
101
 
99
102
  # ── HiveMem credential renewal (optional, all lists empty by default) ─
100
103
  # Entries live in a root-owned 0600 JSON file; the lists below bound what the
@@ -113,3 +116,14 @@ LOG_LEVEL=info
113
116
  # MAGNUS_AGENT_WORKSPACE_ROOT=/var/lib/magnus-agent/workspaces
114
117
  # Account the docker-group heartbeat check verifies (default: the agent's own user).
115
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
@@ -150,7 +178,7 @@ and `magnus-agent env-schema` prints them as JSON.
150
178
  | `SKILLS_DIR` / `SKILLS_EXTRA_DIRS` | Skill directories |
151
179
  | `HERMES_USER`, `HERMES_REGISTRY_TOKEN`, `MAGNUS_HERMES_SYNC_PULL` | Hermes Agent management and registry sync |
152
180
  | `CODEX_USER`, `CODEX_BIN`, `CODEX_HOME` | Codex CLI management |
153
- | `ARDENT_OWNER_USER`, `ARDENT_STAGED_PACKAGE` | Ardent management |
181
+ | `ARDENT_OWNER_USER`, `ARDENT_STAGED_PACKAGE`, `MAGNUS_AGENT_ARDENT_PACKAGES` | Ardent management |
154
182
  | `MAGNUS_AGENT_RENEWAL_*` | HiveMem credential renewal allowlists (empty by default) |
155
183
  | `MAGNUS_AGENT_TLS_RENEWAL=tailscale` | Renew a Tailscale listener certificate |
156
184
  | `MAGNUS_AGENT_PRIVILEGED` | Privileged (root, default `true`) or unprivileged mode |
@@ -175,6 +203,17 @@ With `--submit-only` it only reports the local Hermes inventory. Applying the
175
203
  server's desired skills, plugins and MCP releases also needs
176
204
  `MAGNUS_HERMES_SYNC_PULL=true`.
177
205
 
206
+ ## Interoperating with older components
207
+
208
+ `MAGNUS_AGENT_ARDENT_PACKAGES` (default `@hivemagnus/ardent`) lists the scoped
209
+ npm package names the agent accepts as the Ardent runtime, in preference order.
210
+ It applies to the staged `ardent-package.json` sidecar, the install path
211
+ `node_modules/<name>`, the install receipt and the managed `ardent@<profile>`
212
+ user unit. To keep managing an Ardent build published under another scope, add
213
+ its name, for example `MAGNUS_AGENT_ARDENT_PACKAGES=@hivemagnus/ardent,@legacy-scope/ardent`.
214
+ Older sidecars with the `{name, version, sha256}` shape are accepted unchanged.
215
+ Profile channel maps accept any channel key that matches `[a-z][a-z0-9_-]{0,31}`.
216
+
178
217
  ## Development
179
218
 
180
219
  ```bash
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.' },
@@ -36,6 +41,7 @@ export const agentEnvCatalog = [
36
41
  { name: 'CODEX_HOME', required: false, secret: false, default: '~/.codex', group: 'codex', description: 'Codex state directory override.' },
37
42
  { name: 'ARDENT_OWNER_USER', required: false, secret: false, group: 'ardent', description: 'Local account that owns the Ardent runtime and profiles.' },
38
43
  { name: 'ARDENT_STAGED_PACKAGE', required: false, secret: false, default: '/opt/magnus-agent/ardent-package.tgz', group: 'ardent', description: 'Verified Ardent package tarball staged for installation.' },
44
+ { name: 'MAGNUS_AGENT_ARDENT_PACKAGES', required: false, secret: false, default: '@hivemagnus/ardent', group: 'ardent', description: 'Comma-separated scoped npm package names accepted as the Ardent runtime (staged sidecar, install path, receipt), in preference order. Add a legacy name to keep managing an older Ardent build.' },
39
45
  { name: 'MAGNUS_AGENT_RENEWAL_CONFIG', required: false, secret: false, default: '$AGENT_INSTALL_DIR/credential-renewal.json', group: 'renewal', description: 'HiveMem credential renewal entries (root-owned, mode 0600). Absent means renewal is off.' },
40
46
  { name: 'MAGNUS_AGENT_RENEWAL_ENDPOINTS', required: false, secret: false, group: 'renewal', description: 'Comma-separated HTTPS HiveMem base URLs renewal may call.' },
41
47
  { name: 'MAGNUS_AGENT_RENEWAL_UNITS', required: false, secret: false, group: 'renewal', description: 'Comma-separated systemd units renewal may try-restart after replacing a credential.' },
package/dist/env.js CHANGED
@@ -10,6 +10,7 @@
10
10
  import { getFqdn } from './fqdn.js';
11
11
  import { isIPv4 } from 'node:net';
12
12
  import { agentPrivileged, isRoot } from './privileged.js';
13
+ import { ardentPackageNames } from './executors/ardent-packages.js';
13
14
  function envFlag(name, fallback = false) {
14
15
  return (process.env[name] ?? String(fallback)).toLowerCase() === 'true';
15
16
  }
@@ -113,6 +114,12 @@ export function configurationErrors() {
113
114
  if (!agentPrivileged() && MAGNUS_EXECUTION_SUPERVISOR_ENABLED) {
114
115
  errors.push('MAGNUS_EXECUTION_SUPERVISOR_ENABLED=true requires MAGNUS_AGENT_PRIVILEGED=true');
115
116
  }
117
+ try {
118
+ ardentPackageNames();
119
+ }
120
+ catch (error) {
121
+ errors.push(error instanceof Error ? error.message : 'MAGNUS_AGENT_ARDENT_PACKAGES is invalid');
122
+ }
116
123
  return errors;
117
124
  }
118
125
  //# sourceMappingURL=env.js.map
@@ -1,4 +1,5 @@
1
1
  import { Hono } from 'hono';
2
+ export { ardentPackageNames, DEFAULT_ARDENT_PACKAGE } from './ardent-packages.js';
2
3
  type ArdentRoot = {
3
4
  owner: string;
4
5
  home: string;
@@ -6,13 +7,13 @@ type ArdentRoot = {
6
7
  install: string;
7
8
  };
8
9
  export declare function verifiedInstalledArdentPackage(install: string, staged: {
10
+ name?: string;
9
11
  version: string;
10
12
  sha256: string;
11
13
  }): Promise<boolean>;
12
14
  export declare function readArdentState(target?: ArdentRoot): Promise<object>;
13
15
  export declare function hermesTrafficConsumersDisabled(home: string): Promise<boolean>;
14
- export declare function ardentUserUnit(root: ArdentRoot, profile: string): string;
16
+ export declare function ardentUserUnit(root: ArdentRoot, profile: string, packageName?: string): string;
15
17
  export declare function createArdentRoutes(resolveRoot?: () => Promise<ArdentRoot>): Hono;
16
18
  export declare const ardentRoutes: Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
17
- export {};
18
19
  //# sourceMappingURL=ardent-manager.d.ts.map
@@ -7,6 +7,8 @@ import { Hono } from 'hono';
7
7
  import { parse as parseEnv } from 'dotenv';
8
8
  import YAML from 'yaml';
9
9
  import { z } from 'zod';
10
+ import { ardentPackageNames } from './ardent-packages.js';
11
+ export { ardentPackageNames, DEFAULT_ARDENT_PACKAGE } from './ardent-packages.js';
10
12
  const profileName = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/);
11
13
  const configSummary = z.object({
12
14
  schema_version: z.literal(1), profile: profileName, name: z.string().min(1),
@@ -15,12 +17,34 @@ const configSummary = z.object({
15
17
  a2a: z.object({ enabled: z.boolean() }).passthrough(),
16
18
  channels: z.record(z.string().regex(/^[a-z][a-z0-9_-]{0,31}$/), z.object({ enabled: z.boolean() }).passthrough()).default({}),
17
19
  }).passthrough();
18
- const packageVersion = z.object({ name: z.literal('@hivemagnus/ardent'), version: z.string() });
20
+ const acceptedPackageName = z.string().refine(name => ardentPackageNames().includes(name), 'Ardent package name is not accepted by MAGNUS_AGENT_ARDENT_PACKAGES');
21
+ const packageVersion = z.object({ name: acceptedPackageName, version: z.string() });
19
22
  const installedPackageReceipt = z.object({
20
- schemaVersion: z.literal(1), name: z.literal('@hivemagnus/ardent'),
23
+ schemaVersion: z.literal(1), name: acceptedPackageName,
21
24
  version: z.string(), sha256: z.string().regex(/^[a-f0-9]{64}$/),
22
25
  packageTreeSha256: z.string().regex(/^[a-f0-9]{64}$/),
23
26
  }).strict();
27
+ /** Package directory of an accepted Ardent package name inside an install prefix. */
28
+ function packageDir(install, name) {
29
+ if (!ardentPackageNames().includes(name))
30
+ throw new Error('Ardent package name is not accepted by MAGNUS_AGENT_ARDENT_PACKAGES');
31
+ return join(install, 'node_modules', ...name.split('/'));
32
+ }
33
+ /** The accepted Ardent package installed under `install`, in preference order. */
34
+ async function installedPackageName(install) {
35
+ for (const name of ardentPackageNames()) {
36
+ try {
37
+ const installed = packageVersion.parse(JSON.parse(await readFile(join(packageDir(install, name), 'package.json'), 'utf8')));
38
+ if (installed.name === name)
39
+ return name;
40
+ }
41
+ catch { /* Try the next accepted name. */ }
42
+ }
43
+ return null;
44
+ }
45
+ async function installedCli(install) {
46
+ return join(packageDir(install, await installedPackageName(install) ?? ardentPackageNames()[0]), 'dist/cli.js');
47
+ }
24
48
  /** Channel name -> enabled, for every channel the profile config declares. */
25
49
  function channelStates(config) {
26
50
  return Object.fromEntries(Object.entries(config.channels).map(([name, channel]) => [name, channel.enabled]));
@@ -112,11 +136,11 @@ async function stagedPackage() {
112
136
  const sidecarBytes = await readFile(sidecar);
113
137
  if (createHash('sha256').update(sidecarBytes).digest('hex') !== entry('ardent-package.json'))
114
138
  throw new Error('Staged Ardent package sidecar differs from release manifest');
115
- const details = z.object({ name: z.literal('@hivemagnus/ardent'), version: z.string(), sha256: z.string().regex(/^[a-f0-9]{64}$/) }).parse(JSON.parse(sidecarBytes.toString('utf8')));
139
+ const details = z.object({ name: acceptedPackageName, version: z.string(), sha256: z.string().regex(/^[a-f0-9]{64}$/) }).parse(JSON.parse(sidecarBytes.toString('utf8')));
116
140
  const digest = createHash('sha256').update(await readFile(archive)).digest('hex');
117
141
  if (digest !== details.sha256 || digest !== entry('ardent-package.tgz'))
118
142
  throw new Error('Staged Ardent package differs from release manifest');
119
- return { version: details.version, sha256: digest };
143
+ return { name: details.name, version: details.version, sha256: digest };
120
144
  }
121
145
  async function packageTreeDigest(packageRoot) {
122
146
  const entries = [];
@@ -160,15 +184,20 @@ export async function verifiedInstalledArdentPackage(install, staged) {
160
184
  const receipt = installedPackageReceipt.safeParse(JSON.parse(await readFile(receiptPath, 'utf8')));
161
185
  if (!receipt.success || receipt.data.version !== staged.version || receipt.data.sha256 !== staged.sha256)
162
186
  return false;
163
- return await packageTreeDigest(join(install, 'node_modules/@hivemagnus/ardent')) === receipt.data.packageTreeSha256;
187
+ if (staged.name !== undefined && receipt.data.name !== staged.name)
188
+ return false;
189
+ return await packageTreeDigest(packageDir(install, receipt.data.name)) === receipt.data.packageTreeSha256;
164
190
  }
165
191
  export async function readArdentState(target) {
166
192
  const root = target ?? await ardentRoot();
167
193
  let version = null;
194
+ let packageName = null;
168
195
  try {
169
- const packageFile = join(root.install, 'node_modules/@hivemagnus/ardent/package.json');
170
- const installed = JSON.parse(await readFile(packageFile, 'utf8'));
171
- version = packageVersion.parse(installed).version;
196
+ packageName = await installedPackageName(root.install);
197
+ if (packageName) {
198
+ const installed = JSON.parse(await readFile(join(packageDir(root.install, packageName), 'package.json'), 'utf8'));
199
+ version = packageVersion.parse(installed).version;
200
+ }
172
201
  }
173
202
  catch { /* An uninstalled package is a normal inventory state. */ }
174
203
  const profiles = await Promise.all((await safeProfiles(root.root)).map(async (name) => {
@@ -191,7 +220,7 @@ export async function readArdentState(target) {
191
220
  catch (error) {
192
221
  stagedPackageError = error instanceof Error ? error.message.slice(0, 200) : 'Staged package unavailable';
193
222
  }
194
- return { installed: version !== null, version, owner: root.owner, profiles, stagedPackage: staged, stagedPackageError, checkedAt: new Date().toISOString() };
223
+ return { installed: version !== null, version, packageName, owner: root.owner, profiles, stagedPackage: staged, stagedPackageError, checkedAt: new Date().toISOString() };
195
224
  }
196
225
  async function managementToken(dir, envName) {
197
226
  const processToken = process.env[envName];
@@ -264,12 +293,12 @@ export async function hermesTrafficConsumersDisabled(home) {
264
293
  return false;
265
294
  }
266
295
  }
267
- export function ardentUserUnit(root, profile) {
296
+ export function ardentUserUnit(root, profile, packageName = ardentPackageNames()[0]) {
268
297
  profileName.parse(profile);
269
298
  const safe = /^[A-Za-z0-9_./-]+$/;
270
299
  if (![root.home, root.root, root.install].every(path => safe.test(path)))
271
300
  throw new Error('Ardent service paths contain unsupported characters');
272
- return `[Unit]\nDescription=Magnus Ardent ${profile}\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nWorkingDirectory=${join(root.root, 'profiles', profile)}\nEnvironment=ARDENT_HOME=${root.root}\nExecStart=/usr/bin/node ${join(root.install, 'node_modules/@hivemagnus/ardent/dist/cli.js')} --profile ${profile} gateway run\nRestart=on-failure\nRestartSec=5\nTimeoutStopSec=75\nNoNewPrivileges=true\n\n[Install]\nWantedBy=default.target\n`;
301
+ return `[Unit]\nDescription=Magnus Ardent ${profile}\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nWorkingDirectory=${join(root.root, 'profiles', profile)}\nEnvironment=ARDENT_HOME=${root.root}\nExecStart=/usr/bin/node ${join(packageDir(root.install, packageName), 'dist/cli.js')} --profile ${profile} gateway run\nRestart=on-failure\nRestartSec=5\nTimeoutStopSec=75\nNoNewPrivileges=true\n\n[Install]\nWantedBy=default.target\n`;
273
302
  }
274
303
  async function serviceAction(root, profile, action) {
275
304
  if (process.getuid?.() !== 0)
@@ -289,7 +318,7 @@ async function serviceAction(root, profile, action) {
289
318
  }
290
319
  const unit = `ardent@${profile}.service`;
291
320
  const path = join(root.home, '.config/systemd/user', unit);
292
- const expected = ardentUserUnit(root, profile);
321
+ const expected = ardentUserUnit(root, profile, await installedPackageName(root.install) ?? undefined);
293
322
  const metadata = await lstat(path).catch((error) => {
294
323
  if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
295
324
  return undefined;
@@ -307,7 +336,7 @@ async function serviceAction(root, profile, action) {
307
336
  if (action !== 'service-start' && existing === undefined)
308
337
  throw new Error('Managed Ardent user service is not installed');
309
338
  if (action === 'service-start' && existing === undefined) {
310
- const script = join(root.install, 'node_modules/@hivemagnus/ardent/dist/cli.js');
339
+ const script = await installedCli(root.install);
311
340
  const scriptInfo = await lstat(script);
312
341
  if (!scriptInfo.isFile() || scriptInfo.isSymbolicLink())
313
342
  throw new Error('Ardent package executable is unavailable');
@@ -377,14 +406,14 @@ async function installPackage(root) {
377
406
  let installed = false;
378
407
  try {
379
408
  await runAsOwner(root, ['/usr/bin/npm', 'install', '--prefix', stage, '--omit=dev', '--no-audit', '--no-fund', '--no-save', archive], 9 * 60_000);
380
- const packageFile = join(stage, 'node_modules/@hivemagnus/ardent/package.json');
409
+ const packageFile = join(packageDir(stage, staged.name), 'package.json');
381
410
  const candidate = packageVersion.parse(JSON.parse(await readFile(packageFile, 'utf8')));
382
- if (candidate.version !== staged.version)
383
- throw new Error('Staged Ardent package version differs');
411
+ if (candidate.name !== staged.name || candidate.version !== staged.version)
412
+ throw new Error('Staged Ardent package name or version differs');
384
413
  await runAsOwner(root, ['/usr/bin/node', '-e', "const Database=require(process.argv[1]);new Database(':memory:').close()", join(stage, 'node_modules/better-sqlite3')], 10_000);
385
- const packageTreeSha256 = await packageTreeDigest(join(stage, 'node_modules/@hivemagnus/ardent'));
414
+ const packageTreeSha256 = await packageTreeDigest(packageDir(stage, staged.name));
386
415
  await writeFile(join(stage, 'package-receipt.json'), `${JSON.stringify({
387
- schemaVersion: 1, name: '@hivemagnus/ardent', version: staged.version, sha256: staged.sha256, packageTreeSha256,
416
+ schemaVersion: 1, name: staged.name, version: staged.version, sha256: staged.sha256, packageTreeSha256,
388
417
  })}\n`, { mode: 0o644 });
389
418
  if (priorInfo) {
390
419
  await rename(root.install, previous);
@@ -426,7 +455,7 @@ async function createProfile(root, profile, body) {
426
455
  if (current.config.api.port === input.api_port)
427
456
  throw new Error(`API port is already assigned to profile ${name}`);
428
457
  }
429
- const script = join(root.install, 'node_modules/@hivemagnus/ardent/dist/cli.js');
458
+ const script = await installedCli(root.install);
430
459
  const metadata = await lstat(script);
431
460
  if (!metadata.isFile() || metadata.isSymbolicLink())
432
461
  throw new Error('Installed Ardent CLI is unsafe');
@@ -453,7 +482,7 @@ async function importHermesProfile(root, profile, body) {
453
482
  const sourceInfo = await lstat(source);
454
483
  if (!sourceInfo.isDirectory() || sourceInfo.isSymbolicLink())
455
484
  throw new Error('Hermes source home is unsafe');
456
- const script = join(root.install, 'node_modules/@hivemagnus/ardent/dist/cli.js');
485
+ const script = await installedCli(root.install);
457
486
  const scriptInfo = await lstat(script);
458
487
  if (!scriptInfo.isFile() || scriptInfo.isSymbolicLink())
459
488
  throw new Error('Installed Ardent CLI is unsafe');
@@ -0,0 +1,8 @@
1
+ export declare const DEFAULT_ARDENT_PACKAGE = "@hivemagnus/ardent";
2
+ /**
3
+ * npm package names the agent accepts as the Ardent runtime, in preference
4
+ * order. `MAGNUS_AGENT_ARDENT_PACKAGES` (comma-separated scoped names) lets a
5
+ * node keep managing an Ardent build published under another scope.
6
+ */
7
+ export declare function ardentPackageNames(value?: string | undefined): string[];
8
+ //# sourceMappingURL=ardent-packages.d.ts.map
@@ -0,0 +1,16 @@
1
+ export const DEFAULT_ARDENT_PACKAGE = '@hivemagnus/ardent';
2
+ const scopedPackageName = /^@[a-z0-9][a-z0-9._-]{0,99}\/[a-z0-9][a-z0-9._-]{0,99}$/;
3
+ /**
4
+ * npm package names the agent accepts as the Ardent runtime, in preference
5
+ * order. `MAGNUS_AGENT_ARDENT_PACKAGES` (comma-separated scoped names) lets a
6
+ * node keep managing an Ardent build published under another scope.
7
+ */
8
+ export function ardentPackageNames(value = process.env['MAGNUS_AGENT_ARDENT_PACKAGES']) {
9
+ if (value === undefined || value.trim() === '')
10
+ return [DEFAULT_ARDENT_PACKAGE];
11
+ const names = value.split(',').map(item => item.trim()).filter(Boolean);
12
+ if (!names.length || names.some(name => !scopedPackageName.test(name)))
13
+ throw new Error('MAGNUS_AGENT_ARDENT_PACKAGES must list scoped npm package names such as @scope/ardent');
14
+ return [...new Set(names)];
15
+ }
16
+ //# sourceMappingURL=ardent-packages.js.map
@@ -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.2",
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",