@hivemagnus/magnus-server 1.1.7 → 1.1.8

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.
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Repairing the distance between the server's command fence generation and a
3
+ * node ledger that has seen a higher fence (written by an older build, or
4
+ * surviving a database restore). A fence is an ordering token the server
5
+ * owns: the ledger refuses anything at or below its high-water, so the only
6
+ * safe repair is to move the server's generation UP.
7
+ *
8
+ * Trust, precisely. The value must be signed with the fleet's result key, under
9
+ * a nonce this server just chose (a preflight) or bound to this node's lease and
10
+ * request (a STALE_FENCE refusal), and it must arrive over a connection this server
11
+ * opened to the node's inventory address. Only that node's own keys are accepted, and
12
+ * the cap keeps a value from pushing the column near BIGINT overflow. The result
13
+ * key and the node credential are shared by the fleet, so these signatures tell
14
+ * agents apart only by what they claim: whoever holds them can sign for any node.
15
+ * The limit is the connection, which ends at whatever the inventory says. A
16
+ * heartbeat with the fleet credential can repoint a node only while its stored
17
+ * address is loopback or unroutable (shouldRepairNodeIp), or to the DNS name of its own
18
+ * agent certificate; a routable address stays. So a holder of the fleet secrets,
19
+ * with a certificate the server trusts for its own address, could answer for a node
20
+ * in that state. It can then inflate that node's counters and settle its leases that
21
+ * left no receipt there. The cost of the counter is only numbering: a generation
22
+ * moves up and nothing depends on its size.
23
+ * Heartbeat values are never used for this.
24
+ *
25
+ * command_resource_fences has no database trigger. The increase-only triggers
26
+ * guard the separate shared_resource_fences table, so "never lower" is enforced
27
+ * here, under the same advisory lock admission takes for this node.
28
+ */
29
+ import { getDb } from '../db/index.js';
30
+ import { logAuditEvent } from './audit-service.js';
31
+ /** Well below BIGINT's 9223372036854775807, with room for every later increment. */
32
+ export const FENCE_HIGH_WATER_CAP = 4000000000000000000n;
33
+ /** A node's fences live under `node:<id>` or `node:<id>:<scope>`. */
34
+ export const isOwnFenceKey = (nodeId, resourceKey) => resourceKey === `node:${nodeId}` || resourceKey.startsWith(`node:${nodeId}:`);
35
+ export function checkReportedFence(nodeId, resourceKey, value) {
36
+ if (!isOwnFenceKey(nodeId, resourceKey))
37
+ return { ok: false, code: 'FENCE_INVALID', detail: `reported a fence for ${resourceKey.slice(0, 100)}, which is not its own` };
38
+ if (typeof value !== 'string' || !/^[1-9][0-9]{0,18}$/.test(value))
39
+ return { ok: false, code: 'FENCE_INVALID', detail: `reported a fence for ${resourceKey} that is not a positive decimal` };
40
+ const parsed = BigInt(value);
41
+ if (parsed > FENCE_HIGH_WATER_CAP)
42
+ return { ok: false, code: 'FENCE_OVERFLOW', detail: `reports fence ${value} for ${resourceKey}, above the ${FENCE_HIGH_WATER_CAP} the server adopts` };
43
+ return { ok: true, value: parsed };
44
+ }
45
+ /** The generation admission would hand out next is below or at the agent's high-water. */
46
+ export const fenceIsAhead = (highWater, generation) => highWater >= generation + 1n;
47
+ /** Move the generation to high-water + 1, audited. Never lowers: the check and the write hold the node's admission lock. */
48
+ export async function advanceCommandFence(input) {
49
+ const check = checkReportedFence(input.nodeId, input.resourceKey, input.highWater);
50
+ if (!check.ok) {
51
+ console.warn(`[command-fence] node ${input.nodeId} ${check.detail}; generation unchanged`);
52
+ return { status: 'rejected', code: check.code, detail: check.detail };
53
+ }
54
+ const moved = await getDb().$transaction(async (tx) => {
55
+ await tx.$executeRaw `SELECT pg_advisory_xact_lock(hashtextextended(${`command-resource-node:${input.nodeId}`}, 0))`;
56
+ const row = await tx.commandResourceFence.findUnique({ where: { resourceKey: input.resourceKey } });
57
+ const from = row?.generation ?? 0n;
58
+ if (!fenceIsAhead(check.value, from))
59
+ return null;
60
+ const to = check.value + 1n;
61
+ await tx.commandResourceFence.upsert({ where: { resourceKey: input.resourceKey },
62
+ create: { resourceKey: input.resourceKey, generation: to }, update: { generation: to } });
63
+ return { from, to };
64
+ });
65
+ if (!moved)
66
+ return { status: 'current' };
67
+ await logAuditEvent({ action: 'agent.fence.advanced', actorId: input.actorId ?? 'system:agent-upgrade', actorType: 'system', targetType: 'node', targetId: input.nodeId,
68
+ details: { resourceKey: input.resourceKey, oldGeneration: moved.from.toString(), newGeneration: moved.to.toString(), reportedHighWater: check.value.toString(), source: input.source } })
69
+ .catch((error) => console.warn(`[command-fence] audit of ${input.nodeId} failed: ${error instanceof Error ? error.message : String(error)}`));
70
+ return { status: 'advanced', ...moved };
71
+ }
72
+ //# sourceMappingURL=command-fence-repair.js.map
@@ -6,18 +6,19 @@ import { assertNodeAdmissionOpen } from './admission-fence.js';
6
6
  * A node requires the explicit durable participant drain before its first lease; switching the flag off does
7
7
  * not remove held/unknown ownership or permit a legacy bypass on that node.
8
8
  */
9
- import { createHash } from 'node:crypto';
9
+ import { createHash, randomUUID } from 'node:crypto';
10
10
  import { nanoid } from 'nanoid';
11
11
  import { getDb } from '../db/index.js';
12
12
  import { HttpError } from '../middleware/error-handler.js';
13
13
  import { commandRequestDigest } from '../security/command-request.js';
14
14
  import { commandFenceSchema } from '../schemas/node-responses.js';
15
- import { verifyCommandResultSignature } from '../security/command-result-signature.js';
15
+ import { commandAbsenceSigningPayload, commandRefusalSigningPayload, verifyCommandEvidence, verifyCommandResultSignature } from '../security/command-result-signature.js';
16
16
  import { RESULT_SIGNING_SECRET, RESULT_SIGNING_PREVIOUS_SECRET } from '../utils/env.js';
17
17
  import { getNodeBaseUrl } from '../utils/node-url.js';
18
18
  import { COMMAND_COMPLETION_REGISTRY, COMMAND_COMPLETION_REGISTRY_SHA256, commandCompletionDescriptor } from '../security/command-completion-registry.js';
19
19
  import { MANAGED_HTTP_COMMAND } from '../security/managed-http.js';
20
20
  import { NATIVE_COMPLETION_REGISTRY, NATIVE_COMPLETION_REGISTRY_SHA256, nativeCompletionDescriptor } from '../security/native-completion-registry.js';
21
+ import { logAuditEvent } from './audit-service.js';
21
22
  const HELD = ['reserved', 'running', 'unknown'];
22
23
  export class ResourceLeaseConflict extends HttpError {
23
24
  constructor() { super(409, 'RESOURCE_LEASE_CONFLICT', 'Node or service has an unsettled command owner; reconcile its evidence before retrying'); }
@@ -204,28 +205,154 @@ export async function markResourceDispatched(admission, verifyBeforeDispatch) {
204
205
  throw new HttpError(409, 'RESOURCE_DEADLINE_INVALID', 'Resource admission expired or changed before dispatch');
205
206
  });
206
207
  }
207
- export async function failResourceCommand(admission) {
208
+ /** The agent's signed word that this exact request did not start and has no receipt. Unsigned or mismatched words prove nothing. */
209
+ function refusalBindsLease(row, nodeFqdn, refusal) {
210
+ return refusal.commandId === row.commandId && refusal.requestSha256 === row.requestSha256 && refusal.leaseId === row.id && refusal.nodeFqdn === nodeFqdn
211
+ && verifyCommandEvidence(commandRefusalSigningPayload(refusal), refusal.signature, RESULT_SIGNING_SECRET, RESULT_SIGNING_PREVIOUS_SECRET);
212
+ }
213
+ /**
214
+ * Close a lease whose dispatch ended without a result. A lease never sent, or
215
+ * one the agent proved it refused, is `not-dispatched`; anything else is
216
+ * `unknown` and held until reconciled. Answers whether the refusal settled it.
217
+ */
218
+ export async function failResourceCommand(admission, refusal) {
208
219
  const db = getDb();
209
- await db.$transaction(async (tx) => {
220
+ return db.$transaction(async (tx) => {
210
221
  const row = await tx.commandResourceLease.findUnique({ where: { id: admission.leaseId } });
211
222
  if (!row || row.state === 'released')
212
- return;
223
+ return false;
213
224
  const notSent = row.state === 'reserved';
225
+ const refused = !notSent && refusal !== undefined && refusalBindsLease(row, admission.nodeFqdn, refusal);
226
+ if (refusal && !notSent && !refused)
227
+ console.warn(`[resource-leases] refusal for ${row.commandId} does not verify; the lease stays uncertain`);
214
228
  const changed = await tx.commandResourceLease.updateMany({ where: { id: row.id, state: row.state }, data: notSent
215
229
  ? { state: 'released', settledAt: new Date(), settlement: 'not-dispatched',
216
230
  evidenceSha256: createHash('sha256').update(`pre-post:${row.commandId}:${row.requestSha256}`).digest('hex') }
217
- : { state: 'unknown' } });
231
+ : refused ? { state: 'released', settledAt: new Date(), settlement: 'not-dispatched',
232
+ evidenceSha256: createHash('sha256').update(`certain-refusal:${JSON.stringify(refusal)}`).digest('hex') }
233
+ : { state: 'unknown' } });
218
234
  if (!changed.count)
219
- return;
235
+ return false;
220
236
  if (row.directCommand) {
221
237
  await tx.remoteCommand.updateMany({ where: { id: row.commandId, status: 'RUNNING' }, data: { status: 'FAILED', completedAt: new Date() } });
222
238
  await tx.commandResult.upsert({ where: { commandId: row.commandId }, update: {}, create: {
223
239
  commandId: row.commandId, exitCode: -1, stdout: '', durationMs: 0,
224
- stderr: notSent ? 'Resource admission ended before dispatch.' : 'Execution outcome unknown; resource ownership retained until verified evidence reconciliation.'
240
+ stderr: notSent ? 'Resource admission ended before dispatch.' : refused ? `The node refused the request before it started (${refusal.code}); nothing ran.`
241
+ : 'Execution outcome unknown; resource ownership retained until verified evidence reconciliation.'
225
242
  } });
226
243
  }
244
+ return refused;
227
245
  });
228
246
  }
247
+ /** Past the deadline the ledger refuses a late request, and /claim stops confirming it; the margin covers clock skew. */
248
+ export const ABSENT_RECEIPT_MARGIN_MS = 60_000;
249
+ /**
250
+ * What a signed trace with no receipt says about a lease held for this node, or
251
+ * why it says nothing. "No receipt" alone proves nothing, since a ledger that is
252
+ * gone holds nothing either. execute() writes in this order: claim, fence, marker,
253
+ * durable receipt, and only then runs the callback. So in a ledger that is there:
254
+ * - no fence, with this node's marker: the marker shows this is its ledger, and the
255
+ * fence comes before everything that could run, so no request passed admission.
256
+ * - no fence and no marker: nothing shows this is the ledger the command would have
257
+ * used. Stays held.
258
+ * - high-water below the lease's fence: the request never passed admission.
259
+ * - above it: the stale-refusal case, or a later fence won.
260
+ * - equal, with this command's claim: a crash or a failed write between the claim and
261
+ * the receipt, which never started.
262
+ * - equal, with its durable record of a failed admission for this fence: the same.
263
+ * - equal with neither: the receipt was removed, or a build without such records
264
+ * failed the admission. These look the same, so it stays held.
265
+ * Not provable from here: a ledger wiped and used again looks like a later fence
266
+ * winning, and one wiped and told its enrollment again looks like the first case.
267
+ */
268
+ export function judgeTrace(answer, nodeId, leaseFence) {
269
+ const no = (code, detail) => ({ settle: false, code, detail });
270
+ if (!answer.ledgerExists)
271
+ return no('LEDGER_MISSING', 'the agent reports no ledger directory');
272
+ if (answer.markerState === 'unknown')
273
+ return no('ENROLLMENT_MARKER_UNKNOWN', 'the agent could not read its enrollment marker');
274
+ if (answer.markerState === 'present' && answer.markerNodeId !== nodeId)
275
+ return no('ENROLLMENT_MARKER_MISMATCH', `the agent's enrollment marker names ${answer.markerNodeId ?? 'nothing'}`);
276
+ if (answer.fenceHighWater === null || !/^[0-9]{1,20}$/.test(answer.fenceHighWater)) {
277
+ if (answer.markerState === 'present' && !answer.activeLock)
278
+ return { settle: true };
279
+ return no('LEDGER_NO_FENCE', `the ledger holds no fence for ${answer.resourceKey} and nothing shows it is this node's`);
280
+ }
281
+ const highWater = BigInt(answer.fenceHighWater);
282
+ if (highWater === leaseFence && !answer.activeLock && answer.admissionFailedFence !== String(leaseFence))
283
+ return no('LEDGER_EVIDENCE_INCONSISTENT', `the ledger accepted fence ${leaseFence} but holds no receipt, claim or failed admission for it`);
284
+ return { settle: true };
285
+ }
286
+ export async function recoverAbsentLeases(nodeId, nodeFqdn, trace, actorId = 'system:agent-upgrade', now = Date.now(), signedReceipt) {
287
+ const db = getDb();
288
+ const rows = await db.commandResourceLease.findMany({ where: { nodeId, state: 'unknown' } });
289
+ const results = [];
290
+ for (const row of rows) {
291
+ const { commandId } = row;
292
+ if (row.completionClass === 'managed-scheduler' || row.completionDescriptorId === 'skill-install') {
293
+ results.push({ commandId, outcome: 'unsupported' });
294
+ continue;
295
+ }
296
+ if (row.deadline.getTime() + ABSENT_RECEIPT_MARGIN_MS > now) {
297
+ results.push({ commandId, outcome: 'not-past-deadline' });
298
+ continue;
299
+ }
300
+ const coordination = commandFenceSchema.parse(JSON.parse(row.coordinationJson));
301
+ const nonce = randomUUID();
302
+ let answer;
303
+ try {
304
+ answer = await trace(commandId, nonce, coordination.resourceKey);
305
+ }
306
+ catch (error) {
307
+ console.warn(`[resource-leases] trace of ${commandId} failed: ${error instanceof Error ? error.message : String(error)}`);
308
+ results.push({ commandId, outcome: 'unavailable' });
309
+ continue;
310
+ }
311
+ // An agent without this route keeps the current behaviour: held until reconciled.
312
+ if (!answer) {
313
+ results.push({ commandId, outcome: 'unsupported' });
314
+ continue;
315
+ }
316
+ if (answer.commandId !== commandId || answer.nonce !== nonce || answer.nodeFqdn !== nodeFqdn || answer.resourceKey !== coordination.resourceKey
317
+ || !verifyCommandEvidence(commandAbsenceSigningPayload(answer), answer.signature, RESULT_SIGNING_SECRET, RESULT_SIGNING_PREVIOUS_SECRET)) {
318
+ console.warn(`[resource-leases] trace of ${commandId} does not verify; the lease stays held`);
319
+ results.push({ commandId, outcome: 'unverified', code: 'LEDGER_EVIDENCE_INCONSISTENT', detail: 'the signed answer does not bind this command, node and nonce' });
320
+ continue;
321
+ }
322
+ if (answer.receipt) {
323
+ // The usual path out for a receipt: a signed result or reconciliation settles it, anything else stays held.
324
+ await signedReceipt?.(commandId, { nodeFqdn, requestSha256: row.requestSha256 }).catch((error) => {
325
+ console.warn(`[resource-leases] receipt of ${commandId} settles nothing: ${error instanceof Error ? error.message : String(error)}`);
326
+ });
327
+ results.push({ commandId, outcome: 'has-trace' });
328
+ continue;
329
+ }
330
+ const judged = judgeTrace(answer, nodeId, BigInt(coordination.fence));
331
+ if (!judged.settle) {
332
+ console.warn(`[resource-leases] lease ${commandId} stays held: ${judged.code}: ${judged.detail}`);
333
+ results.push({ commandId, outcome: 'unverified', code: judged.code, detail: judged.detail });
334
+ continue;
335
+ }
336
+ const evidence = createHash('sha256').update(`receipt-absent:${JSON.stringify(answer)}`).digest('hex');
337
+ const settled = await db.$transaction(async (tx) => {
338
+ await tx.$executeRaw `SELECT pg_advisory_xact_lock(hashtextextended(${`command-resource-node:${nodeId}`}, 0))`;
339
+ const changed = await tx.commandResourceLease.updateMany({ where: { id: row.id, state: 'unknown' },
340
+ data: { state: 'released', settledAt: new Date(), settlement: 'not-dispatched', evidenceSha256: evidence } });
341
+ if (!changed.count)
342
+ return false;
343
+ if (row.directCommand)
344
+ await tx.remoteCommand.updateMany({ where: { id: commandId, status: 'RUNNING' }, data: { status: 'FAILED', completedAt: new Date() } });
345
+ return true;
346
+ });
347
+ if (!settled)
348
+ continue;
349
+ await logAuditEvent({ action: 'agent.lease.recovered', actorId, actorType: 'system', targetType: 'node', targetId: nodeId,
350
+ details: { commandId, leaseId: row.id, settlement: 'not-dispatched', evidenceSha256: evidence, reason: 'the agent ledger holds no receipt for it after the lease deadline', fenceHighWater: answer.fenceHighWater, leaseFence: coordination.fence, activeLock: answer.activeLock } })
351
+ .catch((error) => console.warn(`[resource-leases] audit of ${commandId} failed: ${error instanceof Error ? error.message : String(error)}`));
352
+ results.push({ commandId, outcome: 'settled' });
353
+ }
354
+ return results;
355
+ }
229
356
  export async function retainUnknownResourceCommand(commandId) {
230
357
  await getDb().$transaction(async (tx) => {
231
358
  // Persisted 'reserved' proves the sole POST barrier was never crossed. This
@@ -222,6 +222,8 @@ export async function readParticipantReceipt(response, limit = PARTICIPANT_RECEI
222
222
  await reader.cancel().catch(() => undefined);
223
223
  }
224
224
  }
225
+ /** The reply is a fixed code; what was wrong with the stored evidence goes to the log. */
226
+ const warnEvidence = (what, nodeId, error) => console.warn(`[resource-enrollment] ${what} of node ${nodeId} does not parse: ${error instanceof Error ? error.message : String(error)}`);
225
227
  export async function refreshEnrollmentParticipant(nodeId, participant) {
226
228
  // Fixed inventories and origins only: operator input cannot choose an endpoint or key.
227
229
  const prepared = await getDb().$transaction(async (tx) => {
@@ -328,7 +330,8 @@ export async function acceptSchedulerBoundaryAttestation(nodeId, raw) {
328
330
  try {
329
331
  receipt = schedulerBoundaryReceiptSchema.parse(JSON.parse(ack?.receiptJson ?? 'null'));
330
332
  }
331
- catch {
333
+ catch (error) {
334
+ warnEvidence('scheduler receipt', nodeId, error);
332
335
  fail('SCHEDULER_BOUNDARY_EVIDENCE_INVALID', 'Current signed scheduler receipt is required');
333
336
  }
334
337
  const { signature: receiptSignature, ...receiptUnsigned } = receipt;
@@ -411,7 +414,8 @@ export async function completeEnrollment(nodeId) {
411
414
  reviewed = z.object({ receipt: schedulerBoundaryReceiptSchema, assertion: schedulerBoundaryAttestationSchema }).strict()
412
415
  .parse(JSON.parse(ack.receiptJson ?? 'null'));
413
416
  }
414
- catch {
417
+ catch (error) {
418
+ warnEvidence('reviewed scheduler receipt', nodeId, error);
415
419
  fail('RESOURCE_DRAIN_INCOMPLETE', 'Reviewed scheduler receipt is unavailable');
416
420
  }
417
421
  const { receipt, assertion } = reviewed;
@@ -440,7 +444,8 @@ export async function completeEnrollment(nodeId) {
440
444
  ? apiDrainAttestationSchema.parse(JSON.parse(ack.receiptJson ?? 'null'))
441
445
  : drainReceiptSchema.parse(JSON.parse(ack.receiptJson ?? 'null'));
442
446
  }
443
- catch {
447
+ catch (error) {
448
+ warnEvidence(`${participant} receipt`, nodeId, error);
444
449
  fail('RESOURCE_DRAIN_INCOMPLETE', 'Stored participant receipt is unavailable');
445
450
  }
446
451
  const { signature, ...unsigned } = receipt;
@@ -487,7 +492,8 @@ export async function dashboardParticipantReadiness() {
487
492
  await tx.resourceParticipantWork.findFirst({ select: { workId: true } });
488
493
  }, { maxWait: 2_000, timeout: 3_000 });
489
494
  }
490
- catch {
495
+ catch (error) {
496
+ console.warn(`[resource-enrollment] participant schema check failed: ${error instanceof Error ? error.message : String(error)}`);
491
497
  throw new HttpError(503, 'RESOURCE_PARTICIPANT_UNAVAILABLE', 'Durable participant schema is not ready');
492
498
  }
493
499
  return { version: 1, participant: 'dashboard', protocol: 'resource-enrollment-v1', ready: true };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hivemagnus/magnus-server",
3
- "version": "1.1.7",
3
+ "version": "1.1.8",
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.7",
87
+ "@hivemagnus/magnus-contracts": "1.1.8",
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.7",
105
+ "@hivemagnus/magnus-skills": "1.1.8",
106
106
  "prisma": "^7.10.1"
107
107
  },
108
108
  "devDependencies": {