@hivemagnus/magnus-server 1.0.3 → 1.1.1

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/dist/cli.js CHANGED
@@ -4,12 +4,13 @@
4
4
  // named on the command line have been applied.
5
5
  import { spawn } from 'node:child_process';
6
6
  import { existsSync } from 'node:fs';
7
- import { createRequire } from 'node:module';
8
7
  import { dirname, join, resolve } from 'node:path';
9
8
  import { fileURLToPath } from 'node:url';
10
9
  import { parseArgs, USAGE, UsageError } from './cli/args.js';
11
10
  import { buildEnvSchemaDocument, renderEnvTable } from './cli/env-catalog.js';
12
11
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
12
+ /** Node reads NODE_EXTRA_CA_CERTS only at startup; an env file that sets it needs a fresh process. */
13
+ const STARTUP_EXTRA_CA = process.env['NODE_EXTRA_CA_CERTS'];
13
14
  const MIGRATIONS_DIR = join(PACKAGE_ROOT, 'prisma', 'migrations');
14
15
  const PRISMA_CONFIG = join(PACKAGE_ROOT, 'prisma.config.ts');
15
16
  class FatalError extends Error {
@@ -47,10 +48,10 @@ async function version() {
47
48
  async function migrate() {
48
49
  if (!process.env['DATABASE_URL'] && process.env['NODE_ENV'] === 'production')
49
50
  fatal('Configuration error: DATABASE_URL is required to run migrations in production.');
51
+ const { prismaCliPath } = await import('./cli/migrations.js');
50
52
  let prismaCli;
51
53
  try {
52
- const require = createRequire(import.meta.url);
53
- prismaCli = join(dirname(require.resolve('prisma/package.json')), 'build', 'index.js');
54
+ prismaCli = prismaCliPath();
54
55
  }
55
56
  catch {
56
57
  return fatal('Migration error: the prisma CLI package is not installed next to magnus-server.');
@@ -75,7 +76,7 @@ async function migrate() {
75
76
  await disconnectDb();
76
77
  }
77
78
  }
78
- async function commandCheck() {
79
+ async function commandCheck(format) {
79
80
  const { listMissingSecrets } = await import('./utils/env.js');
80
81
  const { loadEnv } = await import('./config/env.js');
81
82
  const { featureFlags } = await import('./config/features.js');
@@ -104,11 +105,19 @@ async function commandCheck() {
104
105
  if (databaseUrl)
105
106
  results.push(...await checkDatabase(databaseUrl, MIGRATIONS_DIR));
106
107
  results.push(await checkRedis(process.env['REDIS_URL'] ?? 'redis://127.0.0.1:6379'));
108
+ const { checkForUpdate, updateResult } = await import('./services/update-check-service.js');
109
+ const update = await checkForUpdate();
110
+ results.push(updateResult(update));
111
+ const features = featureFlags();
112
+ const failures = results.filter(result => result.status === 'fail').length;
113
+ if (format === 'json') {
114
+ console.log(JSON.stringify({ version: await version(), ok: failures === 0, results, integrations: features, update }, null, 2));
115
+ return failures === 0 ? 0 : 1;
116
+ }
107
117
  const symbol = { pass: '✓', warn: '!', fail: '✗' };
108
118
  console.log(`magnus-server ${await version()} configuration check`);
109
119
  for (const result of results)
110
120
  console.log(` ${symbol[result.status]} ${result.name.padEnd(12)} ${result.message}`);
111
- const features = featureFlags();
112
121
  const onOff = (value) => (value ? 'on' : 'off');
113
122
  console.log('Integrations:');
114
123
  console.log(` Hive IAM ${onOff(features.iam)}`);
@@ -119,13 +128,94 @@ async function commandCheck() {
119
128
  console.log(` Hermes registry ${onOff(features.hermesRegistry)}`);
120
129
  console.log(` Resource leases ${onOff(features.resourceLeases)}`);
121
130
  console.log(` Agent discovery ${onOff(features.agentDiscovery)}`);
122
- const failures = results.filter(result => result.status === 'fail').length;
123
131
  console.log(failures === 0 ? 'Result: OK' : `Result: ${failures} problem(s) found`);
124
132
  return failures === 0 ? 0 : 1;
125
133
  }
134
+ /** Applied and pending migrations, with impact notes (`--format json` for tooling). */
135
+ async function commandMigrations(format) {
136
+ const { resolveDatabaseUrl } = await import('./db/client.js');
137
+ const { readMigrationState } = await import('./cli/migrations.js');
138
+ const pg = (await import('pg')).default;
139
+ const client = new pg.Client({ connectionString: resolveDatabaseUrl(), connectionTimeoutMillis: 10_000 });
140
+ await client.connect();
141
+ try {
142
+ const state = await readMigrationState(client, MIGRATIONS_DIR);
143
+ if (format === 'json') {
144
+ console.log(JSON.stringify({ version: await version(), packaged: state.packaged, applied: [...state.applied].sort(), pending: state.pending, fresh: state.fresh }, null, 2));
145
+ }
146
+ else {
147
+ for (const name of state.packaged) {
148
+ const pending = state.pending.find(migration => migration.name === name);
149
+ console.log(` ${pending ? '…' : '✓'} ${name}${pending ? ` (pending, ${pending.impact}${pending.note ? `: ${pending.note}` : ''})` : ''}`);
150
+ }
151
+ console.log(state.pending.length === 0 ? 'All migrations applied.' : `${state.pending.length} pending; run \`magnus-server migrate\`.`);
152
+ }
153
+ return 0;
154
+ }
155
+ finally {
156
+ await client.end().catch(() => undefined);
157
+ }
158
+ }
159
+ /** `pg_dump -Fc` into MAGNUS_BACKUP_DIR; prints JSON with the file and restore command. */
160
+ async function commandBackup(label) {
161
+ const { resolveDatabaseUrl } = await import('./db/client.js');
162
+ const { backupDatabase } = await import('./cli/migrations.js');
163
+ const { defaultBackupDir } = await import('./cli/auto-migrate.js');
164
+ const { magnusHome } = await import('./utils/magnus-home.js');
165
+ const pg = (await import('pg')).default;
166
+ const databaseUrl = resolveDatabaseUrl();
167
+ const client = new pg.Client({ connectionString: databaseUrl, connectionTimeoutMillis: 10_000 });
168
+ await client.connect();
169
+ try {
170
+ const result = await backupDatabase({
171
+ databaseUrl, client, backupDir: defaultBackupDir(process.env, magnusHome()),
172
+ label: label ?? `manual-${await version()}`, pgDump: process.env['MAGNUS_PG_DUMP'],
173
+ });
174
+ console.log(JSON.stringify(result));
175
+ return 0;
176
+ }
177
+ finally {
178
+ await client.end().catch(() => undefined);
179
+ }
180
+ }
181
+ /** `MAGNUS_AUTO_MIGRATE=true`: back up and migrate under an advisory lock before listening. */
182
+ async function startupAutoMigrate() {
183
+ const { upgradeSettings } = await import('./config/features.js');
184
+ const settings = upgradeSettings();
185
+ if (!settings.autoMigrate)
186
+ return;
187
+ const { resolveDatabaseUrl } = await import('./db/client.js');
188
+ const { autoMigrate, defaultBackupDir } = await import('./cli/auto-migrate.js');
189
+ const { magnusHome } = await import('./utils/magnus-home.js');
190
+ try {
191
+ await autoMigrate({
192
+ databaseUrl: resolveDatabaseUrl(),
193
+ migrationsDir: MIGRATIONS_DIR,
194
+ backupDir: defaultBackupDir(process.env, magnusHome()),
195
+ version: await version(),
196
+ settings,
197
+ pgDump: process.env['MAGNUS_PG_DUMP'],
198
+ migrate,
199
+ });
200
+ }
201
+ catch (error) {
202
+ if (error instanceof FatalError)
203
+ throw error;
204
+ fatal(error instanceof Error ? error.message : String(error));
205
+ }
206
+ }
126
207
  async function commandStart(options) {
208
+ // Without --env-file the server reads its package-level .env; apply it here
209
+ // too so startup switches such as MAGNUS_AUTO_MIGRATE see the same values.
210
+ const packageEnv = join(PACKAGE_ROOT, '.env');
211
+ if (!process.env['MAGNUS_ENV_FILE'] && existsSync(packageEnv)) {
212
+ process.loadEnvFile(packageEnv);
213
+ dropEmptyValues();
214
+ }
127
215
  if (options.migrate)
128
216
  await migrate();
217
+ else
218
+ await startupAutoMigrate();
129
219
  await import('./index.js');
130
220
  }
131
221
  async function run(argv) {
@@ -146,7 +236,31 @@ async function run(argv) {
146
236
  return 0;
147
237
  case 'check':
148
238
  loadEnvFiles(options.envFiles);
149
- return commandCheck();
239
+ return commandCheck(options.formatGiven ? options.format : 'table');
240
+ case 'migrations':
241
+ loadEnvFiles(options.envFiles);
242
+ return commandMigrations(options.formatGiven ? options.format : 'table');
243
+ case 'backup':
244
+ loadEnvFiles(options.envFiles);
245
+ return commandBackup(options.label);
246
+ case 'upgrade':
247
+ case 'rollback': {
248
+ const { delegateToProject } = await import('./cli/upgrade.js');
249
+ return delegateToProject(options.command, options.passthrough, PACKAGE_ROOT);
250
+ }
251
+ case 'agents': {
252
+ loadEnvFiles(options.envFiles);
253
+ // The command calls agents over TLS: trust the same extra CA as the service.
254
+ if (process.env['NODE_EXTRA_CA_CERTS'] && process.env['NODE_EXTRA_CA_CERTS'] !== STARTUP_EXTRA_CA) {
255
+ return new Promise((resolvePromise, reject) => {
256
+ const child = spawn(process.execPath, process.argv.slice(1), { stdio: 'inherit', env: process.env });
257
+ child.on('error', reject);
258
+ child.on('close', code => resolvePromise(code ?? 1));
259
+ });
260
+ }
261
+ const { commandAgents } = await import('./cli/agents.js');
262
+ return commandAgents(options.passthrough);
263
+ }
150
264
  case 'migrate':
151
265
  loadEnvFiles(options.envFiles);
152
266
  await migrate();
@@ -13,4 +13,26 @@ export function featureFlags(env = process.env) {
13
13
  agentDiscovery: truthy(env['ENABLE_AGENT_DISCOVERY']),
14
14
  };
15
15
  }
16
+ /** Boolean switch with an explicit default: only `true`/`false` (any case) override it. */
17
+ export function envSwitch(value, fallback) {
18
+ const normalized = (value ?? '').trim().toLowerCase();
19
+ if (normalized === 'true')
20
+ return true;
21
+ if (normalized === 'false')
22
+ return false;
23
+ return fallback;
24
+ }
25
+ export function upgradeSettings(env = process.env) {
26
+ const concurrency = Number.parseInt(env['MAGNUS_AGENT_UPGRADE_CONCURRENCY'] ?? '', 10);
27
+ return {
28
+ autoMigrate: envSwitch(env['MAGNUS_AUTO_MIGRATE'], false),
29
+ autoMigrateBackup: envSwitch(env['MAGNUS_AUTO_MIGRATE_BACKUP'], true),
30
+ autoMigrateAllowSlow: envSwitch(env['MAGNUS_AUTO_MIGRATE_ALLOW_SLOW'], false),
31
+ updateCheck: envSwitch(env['MAGNUS_UPDATE_CHECK'], true),
32
+ updateRegistry: (env['MAGNUS_UPDATE_REGISTRY']?.trim() || 'https://registry.npmjs.org').replace(/\/+$/, ''),
33
+ agentUpgrades: envSwitch(env['MAGNUS_AGENT_UPGRADES'], true),
34
+ agentUpgradeConcurrency: Number.isInteger(concurrency) && concurrency > 0 ? Math.min(concurrency, 50) : 5,
35
+ legacyGitSelfUpdate: envSwitch(env['MAGNUS_LEGACY_GIT_SELF_UPDATE'], false),
36
+ };
37
+ }
16
38
  //# sourceMappingURL=features.js.map
package/dist/index.js CHANGED
@@ -33,6 +33,7 @@ const { startSecurityFusionRetention, stopSecurityFusionRetention } = await impo
33
33
  const { reconcileSecurityFusionEvidence } = await import('./services/security-fusion-evidence-integrity.js');
34
34
  const { startSecurityFusionOutboxDispatcher, stopSecurityFusionOutboxDispatcher, } = await import('./services/security-fusion-outbox.js');
35
35
  const { startCommandWorker, closeCommandQueue } = await import('./queue/workers.js');
36
+ const { startUpdateCheck, stopUpdateCheck } = await import('./services/update-check-service.js');
36
37
  const { startWebhookWorker, closeWebhookQueue } = await import('./services/webhook-service.js');
37
38
  const { setupTerminalWebSocket } = await import('./routes/terminal.js');
38
39
  const { disconnectRedis } = await import('./queue/redis.js');
@@ -51,8 +52,11 @@ if (securityFusionLocal()) {
51
52
  + `updated=${evidenceReconciliation.updated} quarantined=${evidenceReconciliation.quarantined} `
52
53
  + `mismatched_cases=${evidenceReconciliation.mismatchedCases}`);
53
54
  }
55
+ const { writeRuntimeFile } = await import('./utils/runtime-file.js');
56
+ const { CONTROL_API_VERSION } = await import('./config/version.js');
54
57
  /** Background services shared by the TLS and development listeners. */
55
- function startBackgroundServices() {
58
+ function startBackgroundServices(listenPort) {
59
+ writeRuntimeFile({ version: CONTROL_API_VERSION, port: listenPort });
56
60
  startNodeIssueMonitor();
57
61
  startApplicationProbes();
58
62
  startLogRetention();
@@ -67,6 +71,7 @@ function startBackgroundServices() {
67
71
  startRolloutReconciler();
68
72
  startWebhookWorker();
69
73
  startCodexFleetMonitor();
74
+ startUpdateCheck();
70
75
  if (features.hermesRegistry) {
71
76
  startExtensionSourceMonitor();
72
77
  void importPackageSkills().then(result => console.log(`[hermes-registry] catalog import: ${JSON.stringify(result)}`))
@@ -101,11 +106,11 @@ const server = listener.useTls
101
106
  },
102
107
  }, (info) => {
103
108
  console.log(`🚀 Magnus server listening on https://${listener.hostname}:${info.port}`);
104
- startBackgroundServices();
109
+ startBackgroundServices(info.port);
105
110
  })
106
111
  : serve({ fetch: handler, port, hostname: listener.hostname }, (info) => {
107
112
  console.log(`🚀 Magnus server listening on http://${listener.hostname}:${info.port} (development opt-in)`);
108
- startBackgroundServices();
113
+ startBackgroundServices(info.port);
109
114
  });
110
115
  const terminalWss = setupTerminalWebSocket(server);
111
116
  // ── Graceful shutdown ──
@@ -133,6 +138,7 @@ async function shutdown(signal) {
133
138
  stopSecurityExporter();
134
139
  stopSecurityFusionOutboxDispatcher();
135
140
  stopCodexFleetMonitor();
141
+ stopUpdateCheck();
136
142
  stopExtensionSourceMonitor();
137
143
  for (const client of terminalWss.clients)
138
144
  client.terminate();
@@ -2011,36 +2011,6 @@ const paths = {
2011
2011
  },
2012
2012
  },
2013
2013
  },
2014
- '/deploy/upgrade/{nodeId}': {
2015
- post: {
2016
- tags: ['Deploy'],
2017
- summary: 'Upgrade agent',
2018
- description: 'Upgrade the agent on a node to the current expected version. Runs asynchronously.',
2019
- security: apiSecretAuth,
2020
- parameters: [pp('nodeId', 'Node ID')],
2021
- responses: {
2022
- 202: success('Upgrade started'),
2023
- 401: err401,
2024
- 403: err403,
2025
- 404: err404,
2026
- },
2027
- },
2028
- },
2029
- '/deploy/version': {
2030
- get: {
2031
- tags: ['Deploy'],
2032
- summary: 'Expected agent version',
2033
- description: 'Returns the expected agent version and deploy username.',
2034
- security: apiSecretAuth,
2035
- responses: {
2036
- 200: success('Version info', {
2037
- type: 'object',
2038
- properties: { agentVersion: { type: 'string' }, deployUsername: { type: 'string' } },
2039
- }),
2040
- 401: err401,
2041
- },
2042
- },
2043
- },
2044
2014
  '/deploy/control-node': {
2045
2015
  post: {
2046
2016
  tags: ['Deploy'],
@@ -2157,6 +2127,77 @@ const paths = {
2157
2127
  },
2158
2128
  },
2159
2129
  // ──────────────── Self-Update ────────────────
2130
+ '/system/updates': {
2131
+ get: {
2132
+ tags: ['Updates'],
2133
+ summary: 'Available releases and agent version skew',
2134
+ description: 'Result of the daily npm registry check (MAGNUS_UPDATE_CHECK): the newest release of the running major, a newer major if any, release notes, new required settings and slow or one-way migrations. Also lists agents that run a different version than this server ships with, and the command that fixes it.',
2135
+ security: apiSecretAuth,
2136
+ responses: { 200: success('Update status', { type: 'object', properties: {
2137
+ version: { type: 'string' },
2138
+ server: { type: 'object', properties: { enabled: { type: 'boolean' }, current: { type: 'string' }, latest: { type: ['string', 'null'] },
2139
+ latestMajor: { type: ['string', 'null'] }, updateAvailable: { type: 'boolean' }, command: { type: 'string' } } },
2140
+ agents: { type: 'object', properties: { expected: { type: 'string' }, current: { type: 'integer' },
2141
+ behind: { type: 'array', items: { type: 'object' } }, ahead: { type: 'array', items: { type: 'object' } },
2142
+ upgradesEnabled: { type: 'boolean' }, fixCommand: { type: ['string', 'null'] } } },
2143
+ } }), 401: err401 },
2144
+ },
2145
+ },
2146
+ '/system/updates/check': {
2147
+ post: {
2148
+ tags: ['Updates'],
2149
+ summary: 'Check the registry now',
2150
+ description: 'Refreshes the update check immediately (ADMIN or OPERATOR).',
2151
+ security: apiSecretAuth,
2152
+ responses: { 200: success('Update status'), 401: err401, 403: err403 },
2153
+ },
2154
+ },
2155
+ '/agents/upgrade/eligibility': {
2156
+ get: {
2157
+ tags: ['Agent upgrades'],
2158
+ summary: 'Which agents can take a version',
2159
+ description: 'For every node: its agent version and whether it can upgrade itself to `version` (default: the agent version this server ships with) now, or why not (offline, not set up with `magnus-agent install-service`, MAGNUS_AGENT_SELF_UPGRADE=false, another major version, already current).',
2160
+ security: apiSecretAuth,
2161
+ parameters: [qp('version', 'Target agent version (X.Y.Z)')],
2162
+ responses: { 200: success('Eligibility', { type: 'object', properties: {
2163
+ version: { type: 'string' }, upgradesEnabled: { type: 'boolean' }, disabledReason: { type: ['string', 'null'] },
2164
+ nodes: { type: 'array', items: { type: 'object', properties: { nodeId: { type: 'string' }, name: { type: 'string' },
2165
+ version: { type: 'string' }, eligible: { type: 'boolean' }, reason: { type: ['string', 'null'] } } } },
2166
+ } }), 400: err400, 401: err401 },
2167
+ },
2168
+ },
2169
+ '/agents/upgrade': {
2170
+ post: {
2171
+ tags: ['Agent upgrades'],
2172
+ summary: 'Upgrade agents',
2173
+ description: 'Starts an upgrade run (ADMIN, MAGNUS_AGENT_UPGRADES=true): one canary, then MAGNUS_AGENT_UPGRADE_CONCURRENCY agents at a time, stopping at the first agent that does not confirm. Each agent installs the exact version and npm integrity and switches back by itself if the new version does not report a heartbeat in time.',
2174
+ security: apiSecretAuth,
2175
+ requestBody: { required: false, content: { 'application/json': { schema: { type: 'object', properties: {
2176
+ version: { type: 'string', description: 'Agent version (default: the one this server ships with)' },
2177
+ integrity: { type: 'string', description: 'npm sha512 integrity (default: read from the registry)' },
2178
+ nodeIds: { type: 'array', items: { type: 'string' }, description: 'Only these nodes' },
2179
+ } } } } },
2180
+ responses: { 202: success('Run started'), 400: err400, 401: err401, 403: err403, 409: err409 },
2181
+ },
2182
+ },
2183
+ '/agents/upgrade/runs/latest': {
2184
+ get: {
2185
+ tags: ['Agent upgrades'],
2186
+ summary: 'Latest upgrade run',
2187
+ security: apiSecretAuth,
2188
+ responses: { 200: success('Run (or null)'), 401: err401 },
2189
+ },
2190
+ },
2191
+ '/agents/upgrade/runs/{id}': {
2192
+ get: {
2193
+ tags: ['Agent upgrades'],
2194
+ summary: 'Upgrade run status',
2195
+ description: 'Per-node state of a run: pending, requested, confirmed, reverted, failed or skipped.',
2196
+ security: apiSecretAuth,
2197
+ parameters: [pp('id', 'Run ID')],
2198
+ responses: { 200: success('Run'), 401: err401, 404: err404 },
2199
+ },
2200
+ },
2160
2201
  '/self-update/status': {
2161
2202
  get: {
2162
2203
  tags: ['Self-Update'],
@@ -2214,7 +2255,7 @@ const paths = {
2214
2255
  post: {
2215
2256
  tags: ['Self-Update'],
2216
2257
  summary: 'Apply update',
2217
- description: 'Pull latest code, run `npm ci`, build, and optionally restart the process. Requires ADMIN role.',
2258
+ description: 'Source checkouts only, and only with MAGNUS_LEGACY_GIT_SELF_UPDATE=true (otherwise 410 SELF_UPDATE_RETIRED): pull latest code, run `npm ci`, build, and optionally restart the process. npm installs upgrade with `magnus upgrade`. Requires ADMIN role.',
2218
2259
  security: apiSecretAuth,
2219
2260
  requestBody: {
2220
2261
  required: false,
@@ -0,0 +1,42 @@
1
+ import { Hono } from 'hono';
2
+ import { z } from 'zod';
3
+ import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
4
+ import { HttpError } from '../middleware/error-handler.js';
5
+ import { upgradeSettings } from '../config/features.js';
6
+ import { AGENT_VERSION } from '../config/version.js';
7
+ import { getUpgradeRun, latestUpgradeRun, listUpgradeCandidates, startUpgradeRun } from '../services/agent-upgrade-service.js';
8
+ /** /api/v1/agents/upgrade: which agents can take a version, and fleet upgrade runs. */
9
+ export const agentUpgradesRouter = new Hono();
10
+ agentUpgradesRouter.use('*', authMiddleware);
11
+ const version = z.string().regex(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/);
12
+ agentUpgradesRouter.get('/eligibility', async (c) => {
13
+ const target = version.safeParse(c.req.query('version') ?? AGENT_VERSION);
14
+ if (!target.success)
15
+ throw new HttpError(400, 'INVALID_VERSION', 'version must be X.Y.Z');
16
+ const nodes = await listUpgradeCandidates(target.data);
17
+ return c.json({ success: true, data: {
18
+ version: target.data, serverVersion: AGENT_VERSION, upgradesEnabled: upgradeSettings().agentUpgrades,
19
+ disabledReason: upgradeSettings().agentUpgrades ? null : 'MAGNUS_AGENT_UPGRADES=false on the server',
20
+ nodes, latestRun: await latestUpgradeRun(),
21
+ } });
22
+ });
23
+ const startSchema = z.object({
24
+ version: version.optional(),
25
+ integrity: z.string().regex(/^sha512-[A-Za-z0-9+/]+={0,2}$/).optional(),
26
+ nodeIds: z.array(z.string().min(1).max(128)).max(1000).optional(),
27
+ }).strict();
28
+ agentUpgradesRouter.post('/', requireRole('ADMIN'), async (c) => {
29
+ const parsed = startSchema.safeParse(await c.req.json().catch(() => ({})));
30
+ if (!parsed.success)
31
+ throw new HttpError(400, 'VALIDATION_ERROR', parsed.error.issues.map(issue => issue.message).join('; '));
32
+ const { run } = await startUpgradeRun({ ...parsed.data, actorId: getUser(c).userId });
33
+ return c.json({ success: true, data: run }, 202);
34
+ });
35
+ agentUpgradesRouter.get('/runs/latest', async (c) => c.json({ success: true, data: await latestUpgradeRun() }));
36
+ agentUpgradesRouter.get('/runs/:id', async (c) => {
37
+ const run = await getUpgradeRun(c.req.param('id'));
38
+ if (!run)
39
+ throw new HttpError(404, 'NOT_FOUND', 'Upgrade run not found');
40
+ return c.json({ success: true, data: run });
41
+ });
42
+ //# sourceMappingURL=agent-upgrades.js.map
@@ -1,4 +1,6 @@
1
1
  import { enqueueSecurityObservations, securityFusionLocal } from '../services/security-export.js';
2
+ import { warnOnAgentVersionMismatch } from '../services/agent-version-warnings.js';
3
+ import { recordSelfUpgradeReport } from '../services/agent-upgrade-service.js';
2
4
  import { Hono } from 'hono';
3
5
  import { z } from 'zod';
4
6
  import { nanoid } from 'nanoid';
@@ -330,6 +332,11 @@ heartbeatRouter.post('/', async (c) => {
330
332
  }) },
331
333
  });
332
334
  }
335
+ if (existing && body.selfUpgrade !== undefined) {
336
+ await recordSelfUpgradeReport({ id: nodeId, name: existing.name }, body).catch((err) => {
337
+ console.error(`[agent-upgrade] could not record the self-upgrade report of ${existing.name}:`, err instanceof Error ? err.message : err);
338
+ });
339
+ }
333
340
  if (!fqdn && existing) {
334
341
  // If the node's hostname already contains a dot it's likely an FQDN — skip lookup
335
342
  if (!existing.hostname.includes('.')) {
@@ -521,7 +528,7 @@ heartbeatRouter.post('/', async (c) => {
521
528
  }
522
529
  }
523
530
  const expectedVersion = AGENT_VERSION;
524
- const updateAvailable = body.agentVersion && body.agentVersion !== expectedVersion;
531
+ const { updateAvailable } = warnOnAgentVersionMismatch({ id: nodeId, name: existing?.name ?? fqdn ?? nodeId }, body.agentVersion, expectedVersion);
525
532
  return c.json({ success: true, data: { accepted: true, expectedVersion, updateAvailable } });
526
533
  });
527
534
  // ── Retired Redis Streams heartbeat ingress ──
@@ -6,6 +6,7 @@ import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
6
6
  import { logAuditEvent } from '../services/audit-service.js';
7
7
  import { checkForUpdates, performSelfUpdate, scheduleRestart, getUpdateStatus, } from '../services/self-update-service.js';
8
8
  import { HttpError } from '../middleware/error-handler.js';
9
+ import { upgradeSettings } from '../config/features.js';
9
10
  export const selfUpdateRouter = new Hono();
10
11
  selfUpdateRouter.use('*', authMiddleware);
11
12
  selfUpdateRouter.use('*', async (c, next) => {
@@ -16,6 +17,24 @@ selfUpdateRouter.use('*', async (c, next) => {
16
17
  c.header(c.res.headers.has('X-Magnus-Work-Id') ? 'X-Magnus-Restart-Work-Id' : 'X-Magnus-Work-Id', c.error.workId);
17
18
  }
18
19
  });
20
+ /**
21
+ * The git-checkout update path (`git pull`, rebuild, restart) only works for
22
+ * source installs. npm installs upgrade with `magnus upgrade`, which backs up
23
+ * the database and reverts on failure; the old endpoints stay behind
24
+ * MAGNUS_LEGACY_GIT_SELF_UPDATE=true.
25
+ */
26
+ const legacyGitSelfUpdate = async (c, next) => {
27
+ if (!upgradeSettings().legacyGitSelfUpdate) {
28
+ return c.json({ success: false, error: { code: 'SELF_UPDATE_RETIRED',
29
+ message: 'In-place git self-update is off. Upgrade with `magnus upgrade` (or `magnus-server upgrade`) on the server host; '
30
+ + 'set MAGNUS_LEGACY_GIT_SELF_UPDATE=true to re-enable this endpoint for a source checkout.',
31
+ upgradeCommand: 'magnus upgrade' } }, 410);
32
+ }
33
+ await next();
34
+ };
35
+ selfUpdateRouter.use('/check', legacyGitSelfUpdate);
36
+ selfUpdateRouter.use('/apply', legacyGitSelfUpdate);
37
+ selfUpdateRouter.use('/restart', legacyGitSelfUpdate);
19
38
  selfUpdateRouter.get('/work/:workId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
20
39
  const id = z.string().guid().safeParse(c.req.param('workId'));
21
40
  if (!id.success)
@@ -0,0 +1,27 @@
1
+ import { Hono } from 'hono';
2
+ import { authMiddleware, requireRole } from '../middleware/auth.js';
3
+ import { upgradeSettings } from '../config/features.js';
4
+ import { CONTROL_API_VERSION } from '../config/version.js';
5
+ import { agentFixCommand, agentVersionSkew, getUpdateCheck } from '../services/update-check-service.js';
6
+ /**
7
+ * GET /api/v1/system/updates: available releases (MAGNUS_UPDATE_CHECK) and
8
+ * agents running a different version than the server ships with.
9
+ */
10
+ export const systemUpdatesRouter = new Hono();
11
+ systemUpdatesRouter.use('*', authMiddleware);
12
+ async function report(refresh) {
13
+ const [server, agents] = await Promise.all([getUpdateCheck({ refresh }), agentVersionSkew()]);
14
+ const settings = upgradeSettings();
15
+ return {
16
+ version: CONTROL_API_VERSION,
17
+ server,
18
+ agents: {
19
+ ...agents,
20
+ upgradesEnabled: settings.agentUpgrades,
21
+ fixCommand: agents.behind.length > 0 ? agentFixCommand(agents.behind.map(node => node.name)) : null,
22
+ },
23
+ };
24
+ }
25
+ systemUpdatesRouter.get('/', async (c) => c.json({ success: true, data: await report(false) }));
26
+ systemUpdatesRouter.post('/check', requireRole('ADMIN', 'OPERATOR'), async (c) => c.json({ success: true, data: await report(true) }));
27
+ //# sourceMappingURL=system-updates.js.map
@@ -390,9 +390,10 @@ export class NodeApiClient {
390
390
  async removeSkill(skillName) {
391
391
  return this.mutate('DELETE', `skills/${encodeURIComponent(skillName)}`);
392
392
  }
393
- // ── Self-update ──
394
- async triggerUpdate(payload) {
395
- return this.mutate('POST', 'update', payload);
393
+ // ── Agent self-upgrade ──
394
+ /** Stage an exact agent version; the agent restarts into it through its launcher. */
395
+ async upgradeAgent(request) {
396
+ return this.mutate('POST', 'agent-upgrade', request, 15 * 60_000);
396
397
  }
397
398
  // ── Ping ──
398
399
  async ping() {
@@ -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
@@ -30,6 +30,8 @@ import { libraryRouter } from './routes/library.js';
30
30
  import { rolesRouter } from './routes/roles.js';
31
31
  import { notificationsRouter } from './routes/notifications.js';
32
32
  import { selfUpdateRouter } from './routes/self-update.js';
33
+ import { systemUpdatesRouter } from './routes/system-updates.js';
34
+ import { agentUpgradesRouter } from './routes/agent-upgrades.js';
33
35
  import { scanRouter } from './routes/scan.js';
34
36
  import { terminalTicketRouter } from './routes/terminal-ticket.js';
35
37
  import { usersRouter, authRouter } from './routes/users.js';
@@ -93,6 +95,7 @@ const v1 = new Hono();
93
95
  // ── Rate limiting on sensitive endpoints (permissive — Beta) ──
94
96
  v1.use('/auth/*', rateLimitMiddleware({ windowMs: 60_000, max: 20, keyPrefix: 'rl:auth', failClosed: true }));
95
97
  v1.use('/deploy/*', rateLimitMiddleware({ windowMs: 60_000, max: 10, keyPrefix: 'rl:deploy', failClosed: true }));
98
+ v1.use('/agents/upgrade', rateLimitMiddleware({ windowMs: 60_000, max: 10, keyPrefix: 'rl:agent-upgrade', failClosed: true }));
96
99
  v1.use('/self-update/*', rateLimitMiddleware({ windowMs: 60_000, max: 10, keyPrefix: 'rl:selfupdate', failClosed: true }));
97
100
  v1.use('/commands/*', rateLimitMiddleware({ windowMs: 60_000, max: 60, keyPrefix: 'rl:commands' }));
98
101
  v1.use('/quick-command/*', rateLimitMiddleware({ windowMs: 60_000, max: 30, keyPrefix: 'rl:quickcmd' }));
@@ -125,6 +128,8 @@ v1.route('/library', libraryRouter);
125
128
  v1.route('/roles', rolesRouter);
126
129
  v1.route('/notifications', notificationsRouter);
127
130
  v1.route('/self-update', selfUpdateRouter);
131
+ v1.route('/system/updates', systemUpdatesRouter);
132
+ v1.route('/agents/upgrade', agentUpgradesRouter);
128
133
  v1.route('/scan', scanRouter);
129
134
  v1.route('/terminal', terminalTicketRouter);
130
135
  v1.route('/users', usersRouter);