@hivemagnus/magnus-server 1.1.2 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,6 +23,17 @@ export class ResourceLeaseConflict extends HttpError {
23
23
  constructor() { super(409, 'RESOURCE_LEASE_CONFLICT', 'Node or service has an unsettled command owner; reconcile its evidence before retrying'); }
24
24
  }
25
25
  export function resourceLeasesRequested() { return process.env['MAGNUS_RESOURCE_LEASES_ENABLED'] === 'true'; }
26
+ /** Online with a heartbeat at most this old: the freshness admission requires. */
27
+ export const RESOURCE_HEARTBEAT_FRESH_MS = 120_000;
28
+ /** Read-only predicates shared with upgrade eligibility, so it cannot drift from admission. */
29
+ export function hasFreshOnlineHealth(node, now = Date.now()) {
30
+ return node.status === 'ONLINE' && Boolean(node.lastHeartbeatAt) && now - node.lastHeartbeatAt.getTime() <= RESOURCE_HEARTBEAT_FRESH_MS;
31
+ }
32
+ /** Legacy unleased dispatch stays possible only while this holds AND the node has no lease history. */
33
+ export function legacyAdmissionPossible(enrollment, leasesRequested = resourceLeasesRequested()) {
34
+ return !leasesRequested && (!enrollment || enrollment.state === 'legacy');
35
+ }
36
+ export const HELD_RESOURCE_STATES = HELD;
26
37
  function canonicalServiceUnit(value) {
27
38
  // A narrow scope is granted only to the exact allowlisted immutable runbook.
28
39
  // Arbitrary shell syntax, aliases, user units and wrapper commands stay node-wide.
@@ -96,7 +107,7 @@ export async function admitResourceCommand(context, raw) {
96
107
  throw new HttpError(409, 'RESOURCE_TARGET_INVALID', 'Canonical command target differs from inventory');
97
108
  await assertNodeAdmissionOpen(tx, node.id);
98
109
  const enrollment = await tx.resourceEnrollment.findUnique({ where: { nodeId: node.id } });
99
- if (!scopedArdent && !resourceLeasesRequested() && (!enrollment || enrollment.state === 'legacy')
110
+ if (!scopedArdent && legacyAdmissionPossible(enrollment)
100
111
  && !await tx.commandResourceLease.findFirst({ where: { nodeId: node.id }, select: { id: true } }))
101
112
  return null;
102
113
  if (scopedArdent)
@@ -106,7 +117,7 @@ export async function admitResourceCommand(context, raw) {
106
117
  if (await tx.resourceParticipantWork.findFirst({ where: { nodeId: node.id,
107
118
  ...(scopedArdent ? { participant: { in: ['agent', 'api', 'dashboard'] } } : {}), state: { in: HELD } } }))
108
119
  throw new ResourceLeaseConflict();
109
- if (node.status !== 'ONLINE' || !node.lastHeartbeatAt || Date.now() - node.lastHeartbeatAt.getTime() > 120_000)
120
+ if (!hasFreshOnlineHealth(node))
110
121
  throw new HttpError(409, 'RESOURCE_TARGET_INVALID', 'Canonical command target lacks fresh online health');
111
122
  if (await tx.commandResourceLease.findUnique({ where: { commandId: payload.commandId } }))
112
123
  throw new HttpError(409, 'COMMAND_ALREADY_OWNED', 'Command already has a durable resource owner; inspect its receipt without replaying');
@@ -7,6 +7,7 @@ import { managedCronSchema, managedCronStageAckSchema, managedCronActivationAckS
7
7
  import { lockResourceNode } from './resource-node-lock.js';
8
8
  import { assertNodeAdmissionOpen } from './admission-fence.js';
9
9
  import { assertEnrollmentComplete } from './resource-enrollment.js';
10
+ import { readOwnershipHistory, stickyEnrollmentObserved } from './resource-ownership.js';
10
11
  import { admitResourceCommand, markResourceDispatched, settleResourceResult, resourceLeasesRequested } from './command-resource-leases.js';
11
12
  import { commandRequestDigest } from '../security/command-request.js';
12
13
  import { verifyCommandResultSignature } from '../security/command-result-signature.js';
@@ -33,14 +34,10 @@ export async function cronMutationMode(nodeId) {
33
34
  const node = await tx.node.findUnique({ where: { id: nodeId } });
34
35
  if (!node)
35
36
  throw new HttpError(404, 'NODE_NOT_FOUND', 'Inventory node not found');
36
- const [enrollment, leaseHistory, definitionHistory, participantHistory] = await Promise.all([
37
- tx.resourceEnrollment.findUnique({ where: { nodeId }, select: { state: true } }),
38
- tx.commandResourceLease.findFirst({ where: { nodeId }, select: { id: true } }),
39
- tx.managedCronDefinition.findFirst({ where: { nodeId }, select: { cronJobId: true } }),
40
- tx.resourceParticipantWork.findFirst({ where: { nodeId }, select: { workId: true } }),
37
+ const [enrollment, history] = await Promise.all([
38
+ tx.resourceEnrollment.findUnique({ where: { nodeId }, select: { state: true } }), readOwnershipHistory(tx, nodeId)
41
39
  ]);
42
- const managed = resourceLeasesRequested() || Boolean(enrollment && enrollment.state !== 'legacy')
43
- || Boolean(leaseHistory || definitionHistory || participantHistory);
40
+ const managed = resourceLeasesRequested() || stickyEnrollmentObserved(enrollment, history);
44
41
  if (managed)
45
42
  await assertEnrollmentComplete(tx, nodeId, node.hostname);
46
43
  return managed ? 'managed' : 'legacy';
@@ -49,6 +49,8 @@ async function checkStaleNodes() {
49
49
  },
50
50
  }, maintenance?.id ?? null);
51
51
  });
52
+ // An agent that went silent mid-upgrade must not stay UPDATING.
53
+ await import('./agent-upgrade-service.js').then(service => service.clearAgentUpdating(node.id)).catch(() => undefined);
52
54
  }
53
55
  recordMonitorPulse('nodes');
54
56
  }
@@ -24,9 +24,13 @@ export function canonical(value) {
24
24
  }
25
25
  export function enrollmentSigningPayload(value) { return 'magnus.resource-enrollment.v1\n' + canonical(value); }
26
26
  function fail(code, message) { throw new HttpError(409, code, message); }
27
+ /** Read-only predicate shared with upgrade eligibility: the enrollment row admits resource execution. */
28
+ export function enrollmentIsComplete(row, nodeFqdn) {
29
+ return Boolean(row) && row.state === 'enrolled' && row.nodeFqdn === nodeFqdn;
30
+ }
27
31
  export async function assertEnrollmentComplete(tx, nodeId, nodeFqdn) {
28
32
  const row = await tx.resourceEnrollment.findUnique({ where: { nodeId } });
29
- if (!row || row.state !== 'enrolled' || row.nodeFqdn !== nodeFqdn)
33
+ if (!enrollmentIsComplete(row, nodeFqdn))
30
34
  fail('RESOURCE_ENROLLMENT_REQUIRED', 'Verified participant drain enrollment is required before resource execution');
31
35
  }
32
36
  export function dashboardEnrollmentSecret() {
@@ -105,7 +109,9 @@ export async function inspectEnrollment(nodeId) {
105
109
  apiBlockers: row?.state === 'enrolled' || participants.some(p => p.participant === 'api' && p.status === 'verified' && p.epoch === row?.epoch
106
110
  && p.challengeExpiresAt.getTime() > Date.now()) ? [] :
107
111
  ['preinstrumentation-work-unverified', 'local-descendants-unverified', 'noninventory-bootstrap-unsupported', 'ssh-descendants-unverified'],
108
- dashboard: await participantSnapshot(tx, nodeId, 'dashboard') };
112
+ dashboard: await participantSnapshot(tx, nodeId, 'dashboard'),
113
+ // Since this process started; empty after a restart.
114
+ lastFailures: lastParticipantFailures(nodeId) };
109
115
  });
110
116
  }
111
117
  export const drainDescriptorSchema = z.object({ version: z.literal(1), participant: z.enum(PARTICIPANTS),
@@ -145,11 +151,46 @@ export const apiDrainAttestationSchema = drainDescriptorSchema.extend({
145
151
  export function apiDrainAttestationSigningPayload(value) {
146
152
  return 'magnus.resource-enrollment-api-attestation.v1\n' + canonical(value);
147
153
  }
154
+ /**
155
+ * Response bounds. Drain receipts are small, except the scheduler's: it lists
156
+ * up to 1024 external blockers (schema maximum), each about 200 bytes of JSON
157
+ * (an 80-character kind, a 64-hex digest, a count and the keys), so a control
158
+ * host with many system timers needs about 210 KiB. 256 KiB covers that with
159
+ * margin and stays bounded.
160
+ */
161
+ export const PARTICIPANT_RECEIPT_LIMITS = { default: 16_384, scheduler: 256 * 1024 };
162
+ /** A participant refresh that failed, as a fixed reason code (never the raw provider error or body). */
163
+ export class ParticipantEvidenceError extends Error {
164
+ code;
165
+ agentCode;
166
+ constructor(code, agentCode = null) {
167
+ super(code);
168
+ this.code = code;
169
+ this.agentCode = agentCode;
170
+ }
171
+ }
172
+ const failures = new Map();
173
+ /** The last refresh failure of each participant on a node, as seen by this server process. */
174
+ export function lastParticipantFailures(nodeId) {
175
+ return [...failures.entries()].filter(([key]) => key.startsWith(`${nodeId}\n`)).map(([, value]) => value);
176
+ }
148
177
  /** Bounded response consumption, including dishonest or absent content-length. */
149
- async function readReceipt(response) {
178
+ export async function readParticipantReceipt(response, limit = PARTICIPANT_RECEIPT_LIMITS.default) {
150
179
  if (response.status !== 200 || !response.body) {
151
- await response.body?.cancel().catch(() => undefined);
152
- throw new Error('Participant response unavailable');
180
+ // An optional machine-readable code from the participant (`{ error: { code } }`) is kept; nothing else is.
181
+ let agentCode = null;
182
+ if (response.body && response.status >= 400) {
183
+ const text = await response.text().catch(() => '');
184
+ try {
185
+ const code = JSON.parse(text.slice(0, 4096))?.error?.code;
186
+ if (typeof code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(code))
187
+ agentCode = code;
188
+ }
189
+ catch { /* no code */ }
190
+ }
191
+ else
192
+ await response.body?.cancel().catch(() => undefined);
193
+ throw new ParticipantEvidenceError(`participant-http-${response.status}`, agentCode);
153
194
  }
154
195
  const reader = response.body.getReader();
155
196
  const chunks = [];
@@ -160,11 +201,16 @@ async function readReceipt(response) {
160
201
  if (done)
161
202
  break;
162
203
  size += value.byteLength;
163
- if (size > 16_384)
164
- throw new Error('Participant response exceeded bound');
204
+ if (size > limit)
205
+ throw new ParticipantEvidenceError('participant-response-too-large');
165
206
  chunks.push(value);
166
207
  }
167
- return JSON.parse(Buffer.concat(chunks).toString('utf8'));
208
+ try {
209
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
210
+ }
211
+ catch {
212
+ throw new ParticipantEvidenceError('participant-response-invalid');
213
+ }
168
214
  }
169
215
  finally {
170
216
  await reader.cancel().catch(() => undefined);
@@ -197,9 +243,9 @@ export async function refreshEnrollmentParticipant(nodeId, participant) {
197
243
  if (participant === 'agent' || participant === 'scheduler') {
198
244
  key = RESULT_SIGNING_SECRET;
199
245
  if (key.length < 32)
200
- throw new Error('Agent receipt credential unavailable');
246
+ throw new ParticipantEvidenceError('receipt-credential-unavailable');
201
247
  if (new URL(prepared.baseUrl).protocol !== 'https:')
202
- throw new Error('Verified HTTPS agent endpoint required');
248
+ throw new ParticipantEvidenceError('https-endpoint-required');
203
249
  url = prepared.baseUrl.replace(/\/$/, '') + (participant === 'scheduler'
204
250
  ? '/api/v1/cron/enrollment-drain' : '/api/v1/commands/enrollment-drain');
205
251
  headers = { 'x-node-token': getNodeManagerSecret() };
@@ -208,14 +254,18 @@ export async function refreshEnrollmentParticipant(nodeId, participant) {
208
254
  key = dashboardEnrollmentSecret();
209
255
  const origin = new URL(process.env['DASHBOARD_PUBLIC_ORIGIN'] ?? '');
210
256
  if (origin.protocol !== 'https:' || origin.username || origin.password || origin.pathname !== '/' || origin.search || origin.hash)
211
- throw new Error('Fixed HTTPS dashboard origin unavailable');
257
+ throw new ParticipantEvidenceError('dashboard-origin-unavailable');
212
258
  url = origin.origin + '/api/internal/resource-enrollment/drain';
213
259
  headers = { 'x-resource-drain-key': key };
214
260
  }
215
261
  const response = await fetch(url, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(participant !== 'dashboard' ? 25_000 : 5_000),
216
- headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify(prepared.descriptor) });
217
- const raw = await readReceipt(response);
218
- receipt = participant === 'scheduler' ? schedulerBoundaryReceiptSchema.parse(raw) : drainReceiptSchema.parse(raw);
262
+ headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify(prepared.descriptor) })
263
+ .catch(() => { throw new ParticipantEvidenceError('participant-unreachable'); });
264
+ const raw = await readParticipantReceipt(response, participant === 'scheduler' ? PARTICIPANT_RECEIPT_LIMITS.scheduler : PARTICIPANT_RECEIPT_LIMITS.default);
265
+ const parsed = participant === 'scheduler' ? schedulerBoundaryReceiptSchema.safeParse(raw) : drainReceiptSchema.safeParse(raw);
266
+ if (!parsed.success)
267
+ throw new ParticipantEvidenceError('participant-receipt-invalid');
268
+ receipt = parsed.data;
219
269
  const { signature, ...unsigned } = receipt;
220
270
  const { status: _status, activeCount: _active, unknownCount: _unknown, evidenceSha256: _evidence, ...remaining } = unsigned;
221
271
  const descriptor = participant === 'scheduler'
@@ -223,12 +273,17 @@ export async function refreshEnrollmentParticipant(nodeId, participant) {
223
273
  : remaining;
224
274
  if (canonical(descriptor) !== canonical(prepared.descriptor) || receipt.expiresAt <= Date.now()
225
275
  || !timingSafeEqual(Buffer.from(signature, 'hex'), createHmac('sha256', key).update(enrollmentSigningPayload(unsigned)).digest()))
226
- throw new Error('Participant receipt failed exact challenge authentication');
276
+ throw new ParticipantEvidenceError('participant-receipt-unauthenticated');
227
277
  }
228
- catch {
229
- // No raw provider error/body is exposed. Outstanding challenge remains pending.
230
- return { participant, status: 'pending', reason: 'participant-evidence-unavailable' };
278
+ catch (error) {
279
+ // No raw provider error/body is exposed, only a fixed cause (and the participant's own code, if it sent one).
280
+ // Outstanding challenge remains pending.
281
+ const cause = error instanceof ParticipantEvidenceError ? error.code : 'participant-evidence-unavailable';
282
+ const agentCode = error instanceof ParticipantEvidenceError ? error.agentCode : null;
283
+ failures.set(`${nodeId}\n${participant}`, { participant, cause, agentCode, at: new Date().toISOString() });
284
+ return { participant, status: 'pending', reason: 'participant-evidence-unavailable', cause, agentCode };
231
285
  }
286
+ failures.delete(`${nodeId}\n${participant}`);
232
287
  return getDb().$transaction(async (tx) => {
233
288
  await lockResourceNode(tx, nodeId);
234
289
  const { row } = await identity(tx, nodeId);
@@ -0,0 +1,20 @@
1
+ export async function readOwnershipHistory(tx, nodeId) {
2
+ const [lease, managedCron, participantWork] = await Promise.all([
3
+ tx.commandResourceLease.findFirst({ where: { nodeId }, select: { id: true } }),
4
+ tx.managedCronDefinition.findFirst({ where: { nodeId }, select: { cronJobId: true } }),
5
+ tx.resourceParticipantWork.findFirst({ where: { nodeId }, select: { workId: true } }),
6
+ ]);
7
+ return { lease: Boolean(lease), managedCron: Boolean(managedCron), participantWork: Boolean(participantWork) };
8
+ }
9
+ /** The sticky rule: what legacy-admission tells an agent (`enrolled`) and what managed cron requires. */
10
+ export function stickyEnrollmentObserved(enrollment, history) {
11
+ return Boolean(enrollment && enrollment.state !== 'legacy') || history.lease || history.managedCron || history.participantWork;
12
+ }
13
+ /** Why the sticky rule holds, for operator-facing reasons. */
14
+ export function stickyEnrollmentCause(enrollment, history) {
15
+ const causes = [enrollment && enrollment.state !== 'legacy' ? `a ${enrollment.state} enrollment` : null,
16
+ history.lease ? 'command lease history' : null, history.managedCron ? 'managed cron history' : null,
17
+ history.participantWork ? 'participant work history' : null].filter(Boolean);
18
+ return causes.length ? causes.join(', ') : null;
19
+ }
20
+ //# sourceMappingURL=resource-ownership.js.map
@@ -1,4 +1,4 @@
1
- import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { magnusHome } from './magnus-home.js';
4
4
  /**
@@ -8,6 +8,7 @@ import { magnusHome } from './magnus-home.js';
8
8
  export function runtimeFilePath(env = process.env) {
9
9
  return env['MAGNUS_RUNTIME_FILE']?.trim() || join(magnusHome(env), 'run', 'server-runtime.json');
10
10
  }
11
+ /** `host` is the bind address, so tooling probes the address the server listens on. */
11
12
  export function writeRuntimeFile(details, env = process.env) {
12
13
  const path = runtimeFilePath(env);
13
14
  try {
@@ -20,4 +21,27 @@ export function writeRuntimeFile(details, env = process.env) {
20
21
  console.warn(`[startup] could not write ${path}: ${error instanceof Error ? error.message : String(error)}`);
21
22
  }
22
23
  }
24
+ /** The server this runtime file names, when its process is still alive on this host. */
25
+ export function runningServer(env = process.env) {
26
+ const path = runtimeFilePath(env);
27
+ if (!existsSync(path))
28
+ return null;
29
+ try {
30
+ const info = JSON.parse(readFileSync(path, 'utf8'));
31
+ const pid = Number(info.pid);
32
+ if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid)
33
+ return null;
34
+ try {
35
+ process.kill(pid, 0);
36
+ }
37
+ catch (error) {
38
+ if (error.code !== 'EPERM')
39
+ return null;
40
+ }
41
+ return { pid, version: String(info.version ?? 'unknown') };
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
23
47
  //# sourceMappingURL=runtime-file.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hivemagnus/magnus-server",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
4
4
  "description": "Magnus server: the fleet control plane API (Hono, PostgreSQL, Redis) for nodes running magnus-agent, with optional Hive IAM, Hive Router, HiveMem, Hermes Agent and external security console integrations.",
5
5
  "keywords": [
6
6
  "magnus",
@@ -84,7 +84,7 @@
84
84
  "prepublishOnly": "npm run db:generate && npm run clean && npm run build"
85
85
  },
86
86
  "dependencies": {
87
- "@hivemagnus/magnus-contracts": "1.1.2",
87
+ "@hivemagnus/magnus-contracts": "1.1.3",
88
88
  "@hono/node-server": "^2.1.5",
89
89
  "@hono/swagger-ui": "^0.6.2",
90
90
  "@prisma/adapter-pg": "7.10.1",
@@ -102,7 +102,7 @@
102
102
  "ws": "^8.22.0",
103
103
  "yaml": "2.9.1",
104
104
  "zod": "^4.6.7",
105
- "@hivemagnus/magnus-skills": "1.1.2",
105
+ "@hivemagnus/magnus-skills": "1.1.3",
106
106
  "prisma": "^7.10.1"
107
107
  },
108
108
  "devDependencies": {