@hivemagnus/magnus-agent 1.1.3 → 1.1.5

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.
@@ -1,4 +1,6 @@
1
1
  import { Hono } from 'hono';
2
+ /** Fixed, non-secret reasons an agent drain proof was refused. */
3
+ export type DrainFailureReason = 'authority-unavailable' | 'authority-binding' | 'fence-failed' | 'supervisor-required' | 'supervisor-unverified' | 'inventory-failed' | 'challenge-expired' | 'receipt-failed';
2
4
  export declare const HOST_LOCAL_WRITER_DOMAIN = "magnus.host-local-writer.v1\n";
3
5
  export declare function canonicalDrainJson(value: unknown): string;
4
6
  export declare const enrollmentDrainRoutes: Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
@@ -6,6 +6,14 @@ import { CONTROL_API_URL, HOST_FQDN, NODE_MANAGER_SECRET, RESULT_SIGNING_SECRET,
6
6
  import { controlApiFetch } from '../tls.js';
7
7
  import { commandLedger, executionSupervisor } from './command-executor.js';
8
8
  import { mutationBarrier } from './mutation-barrier.js';
9
+ import { logger } from '../utils/logger.js';
10
+ class DrainFailure extends Error {
11
+ reason;
12
+ constructor(reason, message) {
13
+ super(message);
14
+ this.reason = reason;
15
+ }
16
+ }
9
17
  const descriptorSchema = z.object({ version: z.literal(1), participant: z.literal('agent'),
10
18
  nodeId: z.string().min(1).max(128), nodeFqdn: z.string().min(1).max(253), epoch: z.number().int().positive(),
11
19
  barrierId: z.string().guid(), challengeId: z.string().guid(), expiresAt: z.number().int().positive() }).strict();
@@ -72,6 +80,9 @@ enrollmentDrainRoutes.post('/enrollment-drain', async (c) => {
72
80
  || descriptor.expiresAt > Date.now() + 300_000 || RESULT_SIGNING_SECRET.length < 32
73
81
  || RESULT_SIGNING_SECRET === NODE_MANAGER_SECRET)
74
82
  return c.json({ success: false, error: { code: 'INVALID_DRAIN_CHALLENGE' } }, 409);
83
+ // The step in progress names the refusal when a dependency throws.
84
+ let step = 'authority-unavailable';
85
+ const started = Date.now();
75
86
  try {
76
87
  const request = { nodeFqdn: HOST_FQDN, nonce: randomUUID() };
77
88
  const response = await controlApiFetch(CONTROL_API_URL.replace(/\/+$/, '') + '/api/v1/resource-leases/enrollment/node-status', {
@@ -87,18 +98,21 @@ enrollmentDrainRoutes.post('/enrollment-drain', async (c) => {
87
98
  || status.nodeId !== descriptor.nodeId || status.state !== 'draining' || status.epoch !== descriptor.epoch
88
99
  || status.barrierId !== descriptor.barrierId || status.agentChallenge?.challengeId !== descriptor.challengeId
89
100
  || status.agentChallenge.expiresAt !== descriptor.expiresAt)
90
- throw new Error('Drain authority binding differs');
101
+ throw new DrainFailure('authority-binding', 'Drain authority binding differs');
91
102
  const identity = { nodeId: descriptor.nodeId, nodeFqdn: descriptor.nodeFqdn, epoch: descriptor.epoch, barrierId: descriptor.barrierId };
103
+ step = 'fence-failed';
92
104
  await mutationBarrier.drain(identity);
93
105
  if (!MAGNUS_EXECUTION_SUPERVISOR_ENABLED)
94
- throw new Error('Supervised execution is required for enrollment proof');
106
+ throw new DrainFailure('supervisor-required', 'Supervised execution is required for enrollment proof');
107
+ step = 'inventory-failed';
95
108
  const supervisor = await executionSupervisor.inventory();
96
109
  if (!supervisor.supported || !supervisor.quiescent)
97
- throw new Error('Supervisor work remains unverified');
110
+ throw new DrainFailure('supervisor-unverified', 'Supervisor work remains unverified');
98
111
  const mutations = await mutationBarrier.snapshot(identity);
99
112
  const commands = await commandLedger.drainSnapshot();
100
113
  if (descriptor.expiresAt <= Date.now())
101
- throw new Error('Drain challenge expired while collecting evidence');
114
+ throw new DrainFailure('challenge-expired', 'Drain challenge expired while collecting evidence');
115
+ step = 'receipt-failed';
102
116
  const activeCount = mutations.activeCount + commands.activeCount;
103
117
  const unknownCount = mutations.unknownCount + commands.unknownCount;
104
118
  const receipt = { ...descriptor, status: activeCount || unknownCount ? 'pending' : 'drained', activeCount, unknownCount,
@@ -107,8 +121,12 @@ enrollmentDrainRoutes.post('/enrollment-drain', async (c) => {
107
121
  c.header('Cache-Control', 'no-store');
108
122
  return c.json({ ...receipt, signature });
109
123
  }
110
- catch {
111
- return c.json({ success: false, error: { code: 'DRAIN_EVIDENCE_UNAVAILABLE',
124
+ catch (error) {
125
+ const reason = error instanceof DrainFailure ? error.reason : step;
126
+ // The local log keeps the real cause; the response carries only the fixed reason.
127
+ logger.warn('Agent enrollment drain refused', { reason, error: error instanceof Error ? error.message : String(error),
128
+ epoch: descriptor.epoch, barrierId: descriptor.barrierId, elapsedMs: Date.now() - started });
129
+ return c.json({ success: false, error: { code: 'DRAIN_EVIDENCE_UNAVAILABLE', reason,
112
130
  message: 'Drain evidence is incomplete; no enrollment acknowledgment issued' } }, 409);
113
131
  }
114
132
  });
@@ -46,6 +46,19 @@ export declare function boundedTimerInlineSources(executable: string, argv: stri
46
46
  commands: string[];
47
47
  mdmonitor: boolean;
48
48
  } | null;
49
+ export declare function stableServiceProperties(raw: string): string;
50
+ /** A timer list keeps unit, load and active state; the SUB column
51
+ * (waiting/running/elapsed) and description change whenever a timer fires. */
52
+ export declare function stableTimerUnitList(raw: string): string;
53
+ /**
54
+ * A process is a possible Hermes runtime when what it executes is Hermes: its
55
+ * executable, the script or module an interpreter runs, or an explicit Hermes
56
+ * home option. Other argument text is data, not identity: an operator's
57
+ * `grep hermes`, a report filter naming a Hermes kind, or a shell whose inline
58
+ * code mentions Hermes is not a runtime (any Hermes it starts is a separate
59
+ * process observed on its own).
60
+ */
61
+ export declare function hermesRuntimeCommand(args: string[]): boolean;
49
62
  export declare function discoverSchedulerSources(options?: DiscoveryOptions): Promise<{
50
63
  homes: string[];
51
64
  hermesRoots: string[];
@@ -113,6 +113,61 @@ export function boundedTimerInlineSources(executable, argv) {
113
113
  commands: ['sponge', ...(shell.startsWith('/') ? [] : [shell])], mdmonitor: false };
114
114
  return null;
115
115
  }
116
+ /**
117
+ * systemd appends the status of the last run to every Exec* record
118
+ * (`start_time=[...] ; stop_time=[...] ; pid=... ; code=... ; status=...`).
119
+ * That is runtime state, not the command definition: a timer that fires
120
+ * between two observations would otherwise change the source evidence on
121
+ * every run. Remove only a trailing run of those exact fields after the
122
+ * record's final `ignore_errors=`; anything else is kept byte for byte.
123
+ */
124
+ const EXEC_RECORD_WITH_RUNTIME = /^((?:ExecStart|ExecStartPre|ExecStartPost|ExecCondition)=\{[\s\S]*;\s*ignore_errors=(?:yes|no))((?:\s*;\s*(?:start_time=\[[^\]\n]*\]|stop_time=\[[^\]\n]*\]|pid=\d+|code=[^\s;}]+|status=[^\s;}]+))+)(\s*\})$/;
125
+ export function stableServiceProperties(raw) {
126
+ return raw.split('\n').map(line => {
127
+ const match = EXEC_RECORD_WITH_RUNTIME.exec(line);
128
+ return match ? match[1] + match[3] : line;
129
+ }).join('\n');
130
+ }
131
+ /** A timer list keeps unit, load and active state; the SUB column
132
+ * (waiting/running/elapsed) and description change whenever a timer fires. */
133
+ export function stableTimerUnitList(raw) {
134
+ return raw.split('\n').map(line => line.trim().split(/\s+/).slice(0, 3).join(' ')).join('\n');
135
+ }
136
+ const HERMES_RUNTIME_TEXT = /(?:hermes|run_gateway\.py)/i;
137
+ const SCRIPT_INTERPRETER = /^(?:python(?:[23](?:\.\d+)?)?|pypy3?|node|bun|deno|bash|sh|dash|zsh)$/;
138
+ /**
139
+ * A process is a possible Hermes runtime when what it executes is Hermes: its
140
+ * executable, the script or module an interpreter runs, or an explicit Hermes
141
+ * home option. Other argument text is data, not identity: an operator's
142
+ * `grep hermes`, a report filter naming a Hermes kind, or a shell whose inline
143
+ * code mentions Hermes is not a runtime (any Hermes it starts is a separate
144
+ * process observed on its own).
145
+ */
146
+ export function hermesRuntimeCommand(args) {
147
+ const executable = args[0] ?? '';
148
+ if (HERMES_RUNTIME_TEXT.test(executable))
149
+ return true;
150
+ if (args.some(arg => /^--hermes-home(?:=|$)/.test(arg)))
151
+ return true;
152
+ if (!SCRIPT_INTERPRETER.test(executable.split('/').pop() ?? ''))
153
+ return false;
154
+ for (let index = 1; index < args.length; index++) {
155
+ const arg = args[index];
156
+ if (arg === '-m')
157
+ return HERMES_RUNTIME_TEXT.test(args[index + 1] ?? '');
158
+ if (/^-m./.test(arg))
159
+ return HERMES_RUNTIME_TEXT.test(arg.slice(2));
160
+ // Inline code is not a script identity.
161
+ if (/^-[a-zA-Z]*[ce]$/.test(arg) || ['--command', '--eval', '--print'].includes(arg))
162
+ return false;
163
+ if (arg === '--')
164
+ return HERMES_RUNTIME_TEXT.test(args[index + 1] ?? '');
165
+ if (arg.startsWith('-'))
166
+ continue;
167
+ return HERMES_RUNTIME_TEXT.test(arg);
168
+ }
169
+ return false;
170
+ }
116
171
  export async function discoverSchedulerSources(options = {}) {
117
172
  const issues = [];
118
173
  const blockers = [];
@@ -207,7 +262,7 @@ export async function discoverSchedulerSources(options = {}) {
207
262
  '--state=active,activating,reloading', '--all', '--plain', '--no-legend', '--no-pager']);
208
263
  const enabledRaw = await run('/usr/bin/systemctl', [...scope, 'list-unit-files', '--type=timer',
209
264
  '--state=enabled,enabled-runtime', '--plain', '--no-legend', '--no-pager']);
210
- sources[sourceKey] = schedulerContentDigest(JSON.stringify([activeRaw, enabledRaw]));
265
+ sources[sourceKey] = schedulerContentDigest(JSON.stringify([stableTimerUnitList(activeRaw), enabledRaw]));
211
266
  const names = [...new Set([...timerNames(activeRaw), ...timerNames(enabledRaw)])].sort(compareText);
212
267
  for (const name of names) {
213
268
  try {
@@ -416,10 +471,11 @@ export async function discoverSchedulerSources(options = {}) {
416
471
  if (error.code !== 'ENOENT')
417
472
  throw error;
418
473
  }
474
+ const stableService = stableServiceProperties(serviceRaw);
419
475
  if (await run('/usr/bin/systemctl', showArgs) !== raw
420
- || await run('/usr/bin/systemctl', serviceShowArgs) !== serviceRaw)
476
+ || stableServiceProperties(await run('/usr/bin/systemctl', serviceShowArgs)) !== stableService)
421
477
  throw new Error('Timer or triggered service identity changed during observation');
422
- sources[sourceKey + ':' + hash(name)] = schedulerContentDigest(JSON.stringify([raw, serviceRaw]));
478
+ sources[sourceKey + ':' + hash(name)] = schedulerContentDigest(JSON.stringify([raw, stableService]));
423
479
  blockEvidence(kind, evidence, 1);
424
480
  }
425
481
  catch (error) {
@@ -628,13 +684,13 @@ export async function discoverSchedulerSources(options = {}) {
628
684
  const command = await boundedSchedulerRead(join(proc, pid, 'cmdline'));
629
685
  if (command.length > 256 * 1024)
630
686
  throw new Error('Process command bound');
631
- if (/(?:hermes|run_gateway\.py)/i.test(command.toString('utf8'))) {
687
+ if (hermesRuntimeCommand(command.toString('utf8').split('\0'))) {
632
688
  try {
633
689
  const startedAt = await processStart(pid);
634
690
  const member = await boundedSchedulerMemberEvidence(join(proc, pid, 'cmdline'), `process:${pid}:start:${startedAt}`);
635
691
  const verified = await boundedSchedulerRead(join(proc, pid, 'cmdline'));
636
692
  if (await processStart(pid) !== startedAt || schedulerContentDigest(verified) !== member.contentSha256
637
- || !/(?:hermes|run_gateway\.py)/i.test(verified.toString('utf8')))
693
+ || !hermesRuntimeCommand(verified.toString('utf8').split('\0')))
638
694
  throw new Error('Process identity changed during observation');
639
695
  blockEvidence('NATIVE_HERMES_RUNTIME_UNINSTRUMENTED', [member]);
640
696
  }
@@ -2,6 +2,15 @@ import { Hono } from 'hono';
2
2
  import { freshSchedulerStatus } from './scheduler-status.js';
3
3
  import { ManagedScheduler } from './managed-scheduler.js';
4
4
  import { type SchedulerInventoryOptions } from './scheduler-inventory.js';
5
+ /** Fixed, non-secret reasons a scheduler drain proof was refused. */
6
+ export type SchedulerDrainFailureReason = 'authority-unavailable' | 'authority-binding' | 'fence-failed' | 'inventory-failed' | 'inventory-changed' | 'ownership-changed' | 'challenge-expired' | 'receipt-failed';
7
+ /** Which parts of two inventories differ: field names, source keys (path hashes or
8
+ * fixed labels) and blocker kinds only, never source content or paths. */
9
+ export declare function schedulerInventoryDifference(first: Record<string, unknown>, second: Record<string, unknown>): {
10
+ fields: string[];
11
+ sources: string[];
12
+ blockerKinds: (string | undefined)[];
13
+ };
5
14
  export declare function canonicalSchedulerEvidence(value: unknown): string;
6
15
  export declare function schedulerDrainRoutes(options?: {
7
16
  scheduler?: ManagedScheduler;
@@ -8,6 +8,32 @@ import { nodeAuthMiddleware } from '../middleware.js';
8
8
  import { ManagedScheduler } from './managed-scheduler.js';
9
9
  import { schedulerInventory } from './scheduler-inventory.js';
10
10
  import { schedulerAuthority } from './scheduler-authority.js';
11
+ import { logger } from '../utils/logger.js';
12
+ class SchedulerDrainFailure extends Error {
13
+ reason;
14
+ detail;
15
+ constructor(reason, message, detail = {}) {
16
+ super(message);
17
+ this.reason = reason;
18
+ this.detail = detail;
19
+ }
20
+ }
21
+ /** Which parts of two inventories differ: field names, source keys (path hashes or
22
+ * fixed labels) and blocker kinds only, never source content or paths. */
23
+ export function schedulerInventoryDifference(first, second) {
24
+ const fields = [...new Set([...Object.keys(first), ...Object.keys(second)])].sort()
25
+ .filter(key => canonicalSchedulerEvidence(first[key]) !== canonicalSchedulerEvidence(second[key]));
26
+ const sourcesOf = (value) => (value['sources'] ?? {});
27
+ const sources = [...new Set([...Object.keys(sourcesOf(first)), ...Object.keys(sourcesOf(second))])].sort()
28
+ .filter(key => sourcesOf(first)[key] !== sourcesOf(second)[key]);
29
+ const blockersOf = (value) => new Set((value['blockers'] ?? [])
30
+ .map(row => row.kind + ':' + row.sourceSha256 + ':' + row.count));
31
+ const left = blockersOf(first);
32
+ const right = blockersOf(second);
33
+ const blockerKinds = [...new Set([...left].filter(row => !right.has(row)).concat([...right].filter(row => !left.has(row)))
34
+ .map(row => row.split(':')[0]))].sort();
35
+ return { fields, sources: sources.slice(0, 32), blockerKinds };
36
+ }
11
37
  const EXTERNAL_SCHEDULER_KINDS = new Set([
12
38
  'LEGACY_OR_UNSUPPORTED_CRONTAB', 'SYSTEM_ANACRON', 'SYSTEM_CRONTAB', 'SYSTEM_CRON_SOURCE',
13
39
  'SYSTEM_TIMER_DEFINITION', 'USER_TIMER_DEFINITION', 'USER_TIMER_OFFLINE_ENABLED',
@@ -33,18 +59,23 @@ export function schedulerDrainRoutes(options = {}) {
33
59
  if (!parsed.success)
34
60
  return c.json({ success: false, error: { code: 'INVALID_SCHEDULER_DRAIN' } }, 400);
35
61
  const descriptor = parsed.data;
62
+ const started = Date.now();
36
63
  if (descriptor.nodeFqdn !== HOST_FQDN || descriptor.expiresAt <= Date.now() || descriptor.expiresAt > Date.now() + 300_000
37
64
  || RESULT_SIGNING_SECRET.length < 32 || RESULT_SIGNING_SECRET === NODE_MANAGER_SECRET)
38
65
  return c.json({ success: false, error: { code: 'INVALID_SCHEDULER_DRAIN' } }, 409);
66
+ // The step in progress names the refusal when a dependency throws.
67
+ let step = 'authority-unavailable';
39
68
  try {
40
69
  const query = { nodeFqdn: HOST_FQDN, nonce: randomUUID() };
41
70
  const status = schedulerStatusSchema.parse(await (options.status ?? freshSchedulerStatus)(query));
42
71
  if (status.nonce !== query.nonce || status.nodeFqdn !== descriptor.nodeFqdn || status.nodeId !== descriptor.nodeId
43
72
  || status.state !== 'draining' || status.epoch !== descriptor.epoch || status.barrierId !== descriptor.barrierId
44
73
  || status.schedulerChallenge?.challengeId !== descriptor.challengeId || status.schedulerChallenge.expiresAt !== descriptor.expiresAt)
45
- throw new Error('Scheduler drain authority binding differs');
74
+ throw new SchedulerDrainFailure('authority-binding', 'Scheduler drain authority binding differs');
46
75
  const identity = { nodeId: descriptor.nodeId, nodeFqdn: descriptor.nodeFqdn, epoch: descriptor.epoch, barrierId: descriptor.barrierId };
76
+ step = 'fence-failed';
47
77
  await scheduler.drain(identity);
78
+ step = 'inventory-failed';
48
79
  const owned = await scheduler.inventory(identity);
49
80
  const recovery = options.recover ?? (id => scheduler.recover(id, schedulerAuthority()));
50
81
  // Recover only original receipts. Never call trigger/admit/run from a drain.
@@ -69,18 +100,19 @@ export function schedulerDrainRoutes(options = {}) {
69
100
  if (firstBoundaryReady) {
70
101
  observed = await schedulerInventory({ ...options.inventory, scheduler, expectedBarrier: identity });
71
102
  if (canonicalSchedulerEvidence(first) !== canonicalSchedulerEvidence(observed))
72
- throw new Error('Scheduler source inventory changed during proof');
103
+ throw new SchedulerDrainFailure('inventory-changed', 'Scheduler source inventory changed during proof', schedulerInventoryDifference(first, observed));
73
104
  }
74
105
  // Bind the final managed snapshot under its flock, after all source reads.
75
106
  const finalOwned = await scheduler.inventory(identity);
76
107
  if (canonicalSchedulerEvidence(finalOwned) !== canonicalSchedulerEvidence(await scheduler.inventory(identity)))
77
- throw new Error('Scheduler ownership changed during proof');
108
+ throw new SchedulerDrainFailure('ownership-changed', 'Scheduler ownership changed during proof');
78
109
  const activeCount = finalOwned.occurrences.filter(row => ['admitting', 'running'].includes(row.state)).length;
79
110
  const unknownCount = finalOwned.occurrences.filter(row => row.state === 'unknown' || row.settlementPending).length
80
111
  + finalOwned.issues.length + observed.issues.length
81
112
  + observed.blockers.filter(source => !EXTERNAL_SCHEDULER_KINDS.has(source.kind)).reduce((sum, source) => sum + source.count, 0);
82
113
  if (descriptor.expiresAt <= Date.now())
83
- throw new Error('Scheduler challenge expired');
114
+ throw new SchedulerDrainFailure('challenge-expired', 'Scheduler challenge expired', { lateByMs: Date.now() - descriptor.expiresAt });
115
+ step = 'receipt-failed';
84
116
  const receipt = { ...descriptor, status: firstBoundaryReady && !activeCount && !unknownCount ? 'managed-drained' : 'pending',
85
117
  scope: 'magnus-managed-scheduler-only', activeCount, unknownCount,
86
118
  externalBlockers: observed.blockers.filter(source => EXTERNAL_SCHEDULER_KINDS.has(source.kind)),
@@ -89,8 +121,14 @@ export function schedulerDrainRoutes(options = {}) {
89
121
  c.header('Cache-Control', 'no-store');
90
122
  return c.json({ ...receipt, signature });
91
123
  }
92
- catch {
93
- return c.json({ success: false, error: { code: 'SCHEDULER_DRAIN_EVIDENCE_UNAVAILABLE', message: 'Scheduler evidence remains incomplete; no drain acknowledgment issued' } }, 409);
124
+ catch (error) {
125
+ const reason = error instanceof SchedulerDrainFailure ? error.reason : step;
126
+ // The local log keeps the real cause; the response carries only the fixed reason.
127
+ logger.warn('Scheduler enrollment drain refused', { reason, error: error instanceof Error ? error.message : String(error),
128
+ ...(error instanceof SchedulerDrainFailure ? error.detail : {}), epoch: descriptor.epoch, barrierId: descriptor.barrierId,
129
+ elapsedMs: Date.now() - started });
130
+ return c.json({ success: false, error: { code: 'SCHEDULER_DRAIN_EVIDENCE_UNAVAILABLE', reason,
131
+ message: 'Scheduler evidence remains incomplete; no drain acknowledgment issued' } }, 409);
94
132
  }
95
133
  });
96
134
  return app;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hivemagnus/magnus-agent",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
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",
@@ -58,7 +58,7 @@
58
58
  "clean": "node -e \"const fs=require('node:fs');fs.rmSync('dist',{recursive:true,force:true});fs.rmSync('tsconfig.tsbuildinfo',{force:true})\""
59
59
  },
60
60
  "dependencies": {
61
- "@hivemagnus/magnus-contracts": "1.1.3",
61
+ "@hivemagnus/magnus-contracts": "1.1.5",
62
62
  "@hono/node-server": "^2.1.5",
63
63
  "dotenv": "^18.0.9",
64
64
  "hono": "^4.13.16",