@hivemagnus/magnus-agent 1.1.4 → 1.1.7

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/README.md CHANGED
@@ -18,6 +18,10 @@ integrations is optional and stays inert until you configure it.
18
18
  - Command execution with timeouts, signed results and a durable command ledger.
19
19
  - Cron management, including finite managed schedules (see
20
20
  [MANAGED_SCHEDULER.md](https://github.com/ChefTaco/magnus/blob/main/packages/agent/MANAGED_SCHEDULER.md)).
21
+ Nodes upgraded from before 1.1.6 may keep one stale remembered Hermes schedule
22
+ source per past Hermes run; see
23
+ [Hermes schedule sources remembered before 1.1.6](https://github.com/ChefTaco/magnus/blob/main/packages/agent/MANAGED_SCHEDULER.md#hermes-schedule-sources-remembered-before-116)
24
+ for the cleanup.
21
25
  - File read and write with symlink-resolved path checks. Agent state and
22
26
  secrets can never be read or written through the API.
23
27
  - Skill publication, listing and removal, for example from
@@ -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
  });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Classifies `hermes gateway status` and systemd-style output. Mirrored
3
+ * byte-for-byte by the server's copy, which also carries the Python port used
4
+ * by SSH detection; a server test pins all three to one table.
5
+ *
6
+ * `systemctl status` output appends a journal tail, so a running service can
7
+ * print "Stopped ..." or "error" from an earlier run. A systemd `Active:` line
8
+ * is therefore authoritative, and Hermes' own "Recent gateway health" history is
9
+ * skipped. Without an `Active:` line, only summary lines count, and on
10
+ * those a negative state outranks a failure, which outranks a positive one.
11
+ */
12
+ export type HermesGatewayState = 'running' | 'inactive' | 'failed' | 'unknown';
13
+ export declare function parseGatewayStatusText(text: string): HermesGatewayState;
14
+ //# sourceMappingURL=hermes-gateway-status.d.ts.map
@@ -0,0 +1,67 @@
1
+ const ANSI_COLOUR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
2
+ const ACTIVE_LINE = /^\s*active:\s*([a-z-]+)(?:\s*\(([^)]*)\))?/;
3
+ const IGNORED_LINE = new RegExp([
4
+ // Journal tail: "Nov 02 10:00:00 host ..." or "2026-11-02 10:00:00 ...".
5
+ String.raw `^\s*(?:[a-z]{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}|\d{4}-\d{2}-\d{2}[t ]\d{2}:\d{2})`,
6
+ // systemctl status fields other than Active, its unit title and its process tree.
7
+ String.raw `^\s*(?:loaded|docs|main pid|tasks|memory|cpu|cgroup|process|drop-in|triggeredby|invocation|status|ip|mem peak)\s*:`,
8
+ String.raw `^\s*[●○×↻✗✓]?\s*\S+\.service\b`,
9
+ String.raw `^\s*[│├└]`,
10
+ // Boot and session settings say nothing about the gateway's current state.
11
+ String.raw `\b(?:linger|auto-?start|on boot|at boot)\b`,
12
+ // systemctl could not ask: that is not evidence about the gateway either.
13
+ String.raw `failed to connect to bus`,
14
+ ].join('|'));
15
+ // `hermes gateway status` ends with history under a heading such as "Recent gateway health:".
16
+ const HISTORY_HEADING = /^\s*recent\b.*:\s*$/;
17
+ const HEALTHY_PHRASE = /\b(?:no|0|zero)\s+(?:errors?|failures?|failed)\b|\b(?:last|previous|recent)\s+(?:errors?|failures?)\b.*$/g;
18
+ const NEGATIVE = /\b(?:not[\s_-]+(?:running|active|started|loaded|installed)|isn'?t\s+running|inactive|stopped|disabled|dead|exited|could not be found)\b/;
19
+ const FAILED = /\b(?:failed|failure|crashed|fatal|errors?|errored|unhealthy|not[\s_-]+healthy)\b/;
20
+ const POSITIVE = /\b(?:running|active|healthy)\b/;
21
+ function withoutHistory(lines) {
22
+ let inHistory = false;
23
+ return lines.filter((line) => {
24
+ if (HISTORY_HEADING.test(line))
25
+ inHistory = true;
26
+ else if (!line.trim())
27
+ inHistory = false;
28
+ return !inHistory;
29
+ });
30
+ }
31
+ function activeLineState(state, detail) {
32
+ if (state === 'active')
33
+ return detail.startsWith('exited') ? 'inactive' : 'running';
34
+ if (state === 'reloading')
35
+ return 'running';
36
+ if (state === 'inactive' || state === 'deactivating')
37
+ return 'inactive';
38
+ if (state === 'failed')
39
+ return 'failed';
40
+ // A unit waiting to restart after a crash is failing, one starting up is not known yet.
41
+ if (state === 'activating')
42
+ return detail.includes('auto-restart') ? 'failed' : 'unknown';
43
+ return 'unknown';
44
+ }
45
+ export function parseGatewayStatusText(text) {
46
+ const lines = text.replace(ANSI_COLOUR, '').toLowerCase().split(/\r?\n/);
47
+ const activeStates = lines.flatMap((line) => {
48
+ const match = ACTIVE_LINE.exec(line);
49
+ return match ? [activeLineState(match[1] ?? '', match[2] ?? '')] : [];
50
+ });
51
+ if (activeStates.length > 0) {
52
+ // User and system units can both print a status block.
53
+ for (const state of ['running', 'failed', 'inactive'])
54
+ if (activeStates.includes(state))
55
+ return state;
56
+ return 'unknown';
57
+ }
58
+ const summary = withoutHistory(lines).filter((line) => !IGNORED_LINE.test(line)).map((line) => line.replace(HEALTHY_PHRASE, ' '));
59
+ if (summary.some((line) => NEGATIVE.test(line)))
60
+ return 'inactive';
61
+ if (summary.some((line) => FAILED.test(line)))
62
+ return 'failed';
63
+ if (summary.some((line) => POSITIVE.test(line)))
64
+ return 'running';
65
+ return 'unknown';
66
+ }
67
+ //# sourceMappingURL=hermes-gateway-status.js.map
@@ -108,6 +108,12 @@ export declare function configureHermes(options: {
108
108
  warnings: string[];
109
109
  state: HermesNodeState;
110
110
  }>;
111
+ export declare function hermesInstallationStatus(input: {
112
+ installed: boolean;
113
+ runnable: boolean;
114
+ gatewayStatus: HermesServiceStatus;
115
+ configPresent: boolean;
116
+ }): HermesNodeState['status'];
111
117
  export declare function collectHermesState(options?: {
112
118
  hermesHome?: string;
113
119
  hermesBin?: string;
@@ -7,6 +7,7 @@ import { Hono } from 'hono';
7
7
  import { z } from 'zod';
8
8
  import { observeHermesDashboard } from './hermes-dashboard-discovery.js';
9
9
  import { inspectHiveMemToken } from './hivemem-token.js';
10
+ import { parseGatewayStatusText } from './hermes-gateway-status.js';
10
11
  import { childEnvironment } from '../utils/child-env.js';
11
12
  import { armChildTimeout } from '../utils/child-timeout.js';
12
13
  const OFFICIAL_INSTALL_SCRIPT = 'https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh';
@@ -417,7 +418,7 @@ function parseProfileListText(text, home) {
417
418
  name,
418
419
  profileHome: join(home, 'profiles', name),
419
420
  isDefault: nameCell.startsWith('*') || nameCell.startsWith('◆') || /\bdefault\b/i.test(nameCell),
420
- gatewayStatus: cells[2] && !['-', '—'].includes(cells[2]) ? cells[2] : 'unknown',
421
+ gatewayStatus: cells[2] ? parseGatewayStatusText(cells[2]) : 'unknown',
421
422
  modelSummary: cells[1] && !['-', '—'].includes(cells[1]) ? cells[1] : null,
422
423
  distribution: cells[4] && !['-', '—'].includes(cells[4]) ? cells[4] : null,
423
424
  metadata: { source: 'hermes profile list' },
@@ -425,6 +426,14 @@ function parseProfileListText(text, home) {
425
426
  }
426
427
  return profiles;
427
428
  }
429
+ function profileGatewayStatus(profile, installationStatus) {
430
+ const reported = profileString(profile, 'gatewayStatus', 'gateway_status');
431
+ const state = reported ? parseGatewayStatusText(reported) : 'unknown';
432
+ if (state !== 'unknown')
433
+ return state;
434
+ return profile['isDefault'] === true || profile['is_default'] === true || profile['default'] === true || profile['name'] === 'default'
435
+ ? installationStatus : 'unknown';
436
+ }
428
437
  function profileString(profile, ...keys) {
429
438
  for (const key of keys) {
430
439
  const value = profile[key];
@@ -458,15 +467,17 @@ function profileNumber(profile, ...keys) {
458
467
  return null;
459
468
  }
460
469
  /**
461
- * Hermes exposes gateway, API-server, and model routing at the installation
462
- * level. Profile-list output varies by Hermes release and often contains only
463
- * names, so carry the observed installation posture into every profile unless
464
- * that profile explicitly reported its own value.
470
+ * Hermes exposes API-server and model routing at the installation level.
471
+ * Profile-list output varies by Hermes release and often contains only names,
472
+ * so carry the observed installation posture into every profile unless that
473
+ * profile explicitly reported its own value. `hermes gateway status` observes
474
+ * only the default profile's gateway, so another profile that reported nothing
475
+ * stays unknown instead of counting as one more running gateway.
465
476
  */
466
477
  function enrichProfiles(profiles, installation) {
467
478
  return profiles.map((profile) => ({
468
479
  ...profile,
469
- gatewayStatus: profileString(profile, 'gatewayStatus', 'gateway_status') ?? installation.gatewayStatus,
480
+ gatewayStatus: profileGatewayStatus(profile, installation.gatewayStatus),
470
481
  providerSummary: profileString(profile, 'providerSummary', 'provider_summary', 'provider') ?? installation.configuration.modelProvider,
471
482
  modelSummary: profileString(profile, 'modelSummary', 'model_summary', 'model') ?? installation.configuration.modelName,
472
483
  apiServerEnabled: profileBoolean(profile, installation.apiServerEnabled, 'apiServerEnabled', 'api_server_enabled'),
@@ -589,6 +600,15 @@ export async function configureHermes(options, configuration) {
589
600
  return { warnings, state: await collectHermesState(options, true) };
590
601
  }
591
602
  let stateCache = null;
603
+ export function hermesInstallationStatus(input) {
604
+ if (!input.installed)
605
+ return 'not_installed';
606
+ if (input.gatewayStatus === 'running')
607
+ return 'running';
608
+ if (!input.runnable)
609
+ return 'degraded';
610
+ return input.configPresent ? 'detected' : 'needs_setup';
611
+ }
592
612
  export async function collectHermesState(options = {}, refresh = false) {
593
613
  const requested = { hermesHome: options.hermesHome ?? DEFAULT_HERMES_HOME, hermesBin: options.hermesBin ?? 'hermes' };
594
614
  const runtime = await discoverRuntimeTarget(requested);
@@ -605,9 +625,11 @@ export async function collectHermesState(options = {}, refresh = false) {
605
625
  const fallbackVersion = runtime.fallbackVersion;
606
626
  const homeProfiles = await profilesFromHome(resolved.home);
607
627
  const installed = versionResult.code === 0 || fallbackVersion?.code === 0 || runtime.homeDetected || configPresent || homeProfiles.length > 0;
608
- const version = installed ? firstLine(versionResult.stdout || fallbackVersion?.stdout || versionResult.stderr) : null;
609
- const gatewayResult = versionResult.code === 0 || fallbackVersion?.code === 0 ? await runHermes(runtime.requested, ['gateway', 'status']) : null;
610
- const profileListResult = versionResult.code === 0 || fallbackVersion?.code === 0 ? await runHermes(runtime.requested, ['profile', 'list', '--json'], 15_000) : null;
628
+ // A Hermes home without a working binary is leftover data, not a usable installation.
629
+ const runnable = versionResult.code === 0 || fallbackVersion?.code === 0;
630
+ const version = runnable ? firstLine(versionResult.stdout || fallbackVersion?.stdout || versionResult.stderr) : null;
631
+ const gatewayResult = runnable ? await runHermes(runtime.requested, ['gateway', 'status']) : null;
632
+ const profileListResult = runnable ? await runHermes(runtime.requested, ['profile', 'list', '--json'], 15_000) : null;
611
633
  let profiles = [];
612
634
  if (profileListResult?.code === 0 && profileListResult.stdout.trim()) {
613
635
  try {
@@ -617,7 +639,7 @@ export async function collectHermesState(options = {}, refresh = false) {
617
639
  }
618
640
  catch { /* fall through with empty profiles */ }
619
641
  }
620
- if (profiles.length === 0 && (versionResult.code === 0 || fallbackVersion?.code === 0)) {
642
+ if (profiles.length === 0 && runnable) {
621
643
  const textResult = await runHermes(runtime.requested, ['profile', 'list'], 15_000);
622
644
  profiles = parseProfileListText(`${textResult.stdout}\n${textResult.stderr}`, resolved.home).slice(0, 50);
623
645
  }
@@ -632,12 +654,9 @@ export async function collectHermesState(options = {}, refresh = false) {
632
654
  metadata: { source: 'implicit default profile' },
633
655
  }];
634
656
  }
635
- const gatewayText = `${gatewayResult?.stdout ?? ''}\n${gatewayResult?.stderr ?? ''}`.toLowerCase();
636
657
  const gatewayStatus = !installed
637
658
  ? 'not_installed'
638
- : /running|active|healthy/.test(gatewayText) ? 'running'
639
- : /failed|error/.test(gatewayText) ? 'failed'
640
- : /inactive|stopped|not running/.test(gatewayText) ? 'inactive' : 'unknown';
659
+ : parseGatewayStatusText(`${gatewayResult?.stdout ?? ''}\n${gatewayResult?.stderr ?? ''}`);
641
660
  const modelProvider = yamlBlockValue(config, 'model', 'provider');
642
661
  const modelName = yamlBlockValue(config, 'model', 'default');
643
662
  const modelBaseUrl = yamlBlockValue(config, 'model', 'base_url');
@@ -703,7 +722,7 @@ export async function collectHermesState(options = {}, refresh = false) {
703
722
  const dashboard = await observeHermesDashboard(resolved.home);
704
723
  const state = {
705
724
  installed,
706
- status: !installed ? 'not_installed' : gatewayStatus === 'running' ? 'running' : configPresent ? 'detected' : 'needs_setup',
725
+ status: hermesInstallationStatus({ installed, runnable, gatewayStatus, configPresent }),
707
726
  installMode: installed ? 'official-script' : 'existing',
708
727
  version,
709
728
  hermesBin: requested.hermesBin,
@@ -46,6 +46,30 @@ 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
+ * Whether a program or script file name is Hermes: the `hermes` CLI and its
55
+ * `hermes-*`/`hermes_*` shims, or the gateway runner. Only the final path
56
+ * component counts; a `.hermes` or `hermes` directory is where unrelated
57
+ * software can live too, so it never identifies a runtime by itself.
58
+ */
59
+ export declare function hermesProgramName(path: string): boolean;
60
+ /** Whether a Python module (`-m`) is Hermes: `hermes`, `hermes_cli`, `hermes_cli.main`, `hermes_agent`... */
61
+ export declare function hermesModuleName(module: string): boolean;
62
+ /**
63
+ * A process is a possible Hermes runtime when what it executes is Hermes: the
64
+ * basename of its executable, the basename of the script or the module an
65
+ * interpreter runs, or an explicit `--hermes-home` option. Other argument text
66
+ * is data, not identity: an operator's `grep hermes`, a report filter naming a
67
+ * Hermes kind, a shell whose inline code mentions Hermes, or unrelated software
68
+ * installed under a `.hermes` directory is not a runtime (any Hermes it starts
69
+ * is a separate process observed on its own). Every command the dashboard
70
+ * recogniser accepts (hermes-dashboard-discovery.ts) is recognised here too.
71
+ */
72
+ export declare function hermesRuntimeCommand(args: string[]): boolean;
49
73
  export declare function discoverSchedulerSources(options?: DiscoveryOptions): Promise<{
50
74
  homes: string[];
51
75
  hermesRoots: string[];
@@ -113,6 +113,85 @@ 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 SCRIPT_INTERPRETER = /^(?:python(?:[23](?:\.\d+)?)?|pypy3?|node|bun|deno|bash|sh|dash|zsh)$/;
137
+ /** Interpreter options whose next argument is a value (a preloaded module, a
138
+ * warning filter), never the script being run. */
139
+ const INTERPRETER_VALUE_OPTIONS = new Set(['-r', '--require', '--import', '--loader', '--experimental-loader', '-C',
140
+ '--conditions', '--env-file', '--input-type', '-W', '-X', '--check-hash-based-pycs', '-o']);
141
+ const basenameOf = (path) => path.slice(path.lastIndexOf('/') + 1);
142
+ /**
143
+ * Whether a program or script file name is Hermes: the `hermes` CLI and its
144
+ * `hermes-*`/`hermes_*` shims, or the gateway runner. Only the final path
145
+ * component counts; a `.hermes` or `hermes` directory is where unrelated
146
+ * software can live too, so it never identifies a runtime by itself.
147
+ */
148
+ export function hermesProgramName(path) {
149
+ const name = basenameOf(path).replace(/\.(?:py|pyc|js|mjs|cjs|ts|sh|exe)$/i, '');
150
+ return /^hermes(?:[-_][A-Za-z0-9_.-]*)?$/i.test(name) || /^run_gateway$/i.test(name);
151
+ }
152
+ /** Whether a Python module (`-m`) is Hermes: `hermes`, `hermes_cli`, `hermes_cli.main`, `hermes_agent`... */
153
+ export function hermesModuleName(module) {
154
+ return /^hermes(?:_[A-Za-z0-9_]*)?(?:\.[A-Za-z0-9_.]*)?$/i.test(module) || /^run_gateway$/i.test(module);
155
+ }
156
+ /**
157
+ * A process is a possible Hermes runtime when what it executes is Hermes: the
158
+ * basename of its executable, the basename of the script or the module an
159
+ * interpreter runs, or an explicit `--hermes-home` option. Other argument text
160
+ * is data, not identity: an operator's `grep hermes`, a report filter naming a
161
+ * Hermes kind, a shell whose inline code mentions Hermes, or unrelated software
162
+ * installed under a `.hermes` directory is not a runtime (any Hermes it starts
163
+ * is a separate process observed on its own). Every command the dashboard
164
+ * recogniser accepts (hermes-dashboard-discovery.ts) is recognised here too.
165
+ */
166
+ export function hermesRuntimeCommand(args) {
167
+ const executable = args[0] ?? '';
168
+ if (hermesProgramName(executable))
169
+ return true;
170
+ if (args.some(arg => /^--hermes-home(?:=|$)/.test(arg)))
171
+ return true;
172
+ if (!SCRIPT_INTERPRETER.test(basenameOf(executable)))
173
+ return false;
174
+ for (let index = 1; index < args.length; index++) {
175
+ const arg = args[index];
176
+ if (arg === '-m')
177
+ return hermesModuleName(args[index + 1] ?? '');
178
+ if (/^-m./.test(arg))
179
+ return hermesModuleName(arg.slice(2));
180
+ // Inline code is not a script identity.
181
+ if (/^-[a-zA-Z]*[ce]$/.test(arg) || ['--command', '--eval', '--print'].includes(arg))
182
+ return false;
183
+ if (arg === '--')
184
+ return hermesProgramName(args[index + 1] ?? '');
185
+ if (INTERPRETER_VALUE_OPTIONS.has(arg)) {
186
+ index++;
187
+ continue;
188
+ }
189
+ if (arg.startsWith('-'))
190
+ continue;
191
+ return hermesProgramName(arg);
192
+ }
193
+ return false;
194
+ }
116
195
  export async function discoverSchedulerSources(options = {}) {
117
196
  const issues = [];
118
197
  const blockers = [];
@@ -207,7 +286,7 @@ export async function discoverSchedulerSources(options = {}) {
207
286
  '--state=active,activating,reloading', '--all', '--plain', '--no-legend', '--no-pager']);
208
287
  const enabledRaw = await run('/usr/bin/systemctl', [...scope, 'list-unit-files', '--type=timer',
209
288
  '--state=enabled,enabled-runtime', '--plain', '--no-legend', '--no-pager']);
210
- sources[sourceKey] = schedulerContentDigest(JSON.stringify([activeRaw, enabledRaw]));
289
+ sources[sourceKey] = schedulerContentDigest(JSON.stringify([stableTimerUnitList(activeRaw), enabledRaw]));
211
290
  const names = [...new Set([...timerNames(activeRaw), ...timerNames(enabledRaw)])].sort(compareText);
212
291
  for (const name of names) {
213
292
  try {
@@ -416,10 +495,11 @@ export async function discoverSchedulerSources(options = {}) {
416
495
  if (error.code !== 'ENOENT')
417
496
  throw error;
418
497
  }
498
+ const stableService = stableServiceProperties(serviceRaw);
419
499
  if (await run('/usr/bin/systemctl', showArgs) !== raw
420
- || await run('/usr/bin/systemctl', serviceShowArgs) !== serviceRaw)
500
+ || stableServiceProperties(await run('/usr/bin/systemctl', serviceShowArgs)) !== stableService)
421
501
  throw new Error('Timer or triggered service identity changed during observation');
422
- sources[sourceKey + ':' + hash(name)] = schedulerContentDigest(JSON.stringify([raw, serviceRaw]));
502
+ sources[sourceKey + ':' + hash(name)] = schedulerContentDigest(JSON.stringify([raw, stableService]));
423
503
  blockEvidence(kind, evidence, 1);
424
504
  }
425
505
  catch (error) {
@@ -628,13 +708,13 @@ export async function discoverSchedulerSources(options = {}) {
628
708
  const command = await boundedSchedulerRead(join(proc, pid, 'cmdline'));
629
709
  if (command.length > 256 * 1024)
630
710
  throw new Error('Process command bound');
631
- if (/(?:hermes|run_gateway\.py)/i.test(command.toString('utf8'))) {
711
+ if (hermesRuntimeCommand(command.toString('utf8').split('\0'))) {
632
712
  try {
633
713
  const startedAt = await processStart(pid);
634
714
  const member = await boundedSchedulerMemberEvidence(join(proc, pid, 'cmdline'), `process:${pid}:start:${startedAt}`);
635
715
  const verified = await boundedSchedulerRead(join(proc, pid, 'cmdline'));
636
716
  if (await processStart(pid) !== startedAt || schedulerContentDigest(verified) !== member.contentSha256
637
- || !/(?:hermes|run_gateway\.py)/i.test(verified.toString('utf8')))
717
+ || !hermesRuntimeCommand(verified.toString('utf8').split('\0')))
638
718
  throw new Error('Process identity changed during observation');
639
719
  blockEvidence('NATIVE_HERMES_RUNTIME_UNINSTRUMENTED', [member]);
640
720
  }
@@ -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;
@@ -13,6 +13,16 @@ export interface SchedulerInventoryOptions {
13
13
  atSpools?: string[];
14
14
  wrapper?: (job: Awaited<ReturnType<ManagedScheduler['job']>> & {}) => string;
15
15
  }
16
+ export declare const hermesJobClaimed: (job: Record<string, unknown>) => boolean;
17
+ /** Path-scoped identity of one Hermes cron/jobs.json. Schedules: the canonical
18
+ * job definitions, so adding, editing or enabling a job is a new source while a
19
+ * run-state rewrite is not. Claims: the set of jobs currently holding a claim. */
20
+ export declare function hermesJobsIdentity(path: string, value: unknown): {
21
+ schedulesSha256: string;
22
+ claimsSha256: string;
23
+ enabled: number;
24
+ claims: number;
25
+ };
16
26
  export declare function schedulerInventory(options?: SchedulerInventoryOptions): Promise<{
17
27
  version: number;
18
28
  barrier: {
@@ -5,6 +5,55 @@ import { join } from 'node:path';
5
5
  import { hash } from './execution-supervisor-state.js';
6
6
  import { aggregateSchedulerBlockers, boundedSchedulerMemberEvidence, boundedSchedulerRead, discoverSchedulerSources, schedulerContentDigest, schedulerMemberEvidenceDigest, schedulerMembersDigest } from './scheduler-discovery.js';
7
7
  import { ManagedScheduler } from './managed-scheduler.js';
8
+ /** Per-job fields Hermes rewrites while running or finishing a job. They are
9
+ * run state, not schedule definition: excluding them keeps one remembered source
10
+ * per jobs.json definition instead of one per run. Every other field, including
11
+ * fields this list does not know, is part of the definition. */
12
+ const HERMES_RUN_STATE_FIELDS = new Set(['next_run_at', 'updated_at', 'run_count', 'runs', 'failure_count',
13
+ 'consecutive_failures', 'fire_claim', 'run_claim']);
14
+ const hermesRunStateField = (key) => key.startsWith('last_') || HERMES_RUN_STATE_FIELDS.has(key);
15
+ function canonicalJson(value) {
16
+ if (Array.isArray(value))
17
+ return value.map(canonicalJson);
18
+ if (!value || typeof value !== 'object')
19
+ return value;
20
+ return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonicalJson(value[key])]));
21
+ }
22
+ function hermesJobDefinition(job) {
23
+ const definition = {};
24
+ for (const [key, value] of Object.entries(job))
25
+ if (!hermesRunStateField(key))
26
+ definition[key] = value;
27
+ // `repeat.times` is configuration; `repeat.completed` is a run counter.
28
+ if (definition['repeat'] && typeof definition['repeat'] === 'object' && !Array.isArray(definition['repeat'])) {
29
+ const { completed: _completed, ...repeat } = definition['repeat'];
30
+ definition['repeat'] = repeat;
31
+ }
32
+ return canonicalJson(definition);
33
+ }
34
+ export const hermesJobClaimed = (job) => Boolean(job['fire_claim'] || job['run_claim'] || ['running', 'working'].includes(job['last_status']));
35
+ /** Path-scoped identity of one Hermes cron/jobs.json. Schedules: the canonical
36
+ * job definitions, so adding, editing or enabling a job is a new source while a
37
+ * run-state rewrite is not. Claims: the set of jobs currently holding a claim. */
38
+ export function hermesJobsIdentity(path, value) {
39
+ const document = Array.isArray(value) ? undefined : value && typeof value === 'object' ? value : undefined;
40
+ const jobs = Array.isArray(value) ? value : document?.['jobs'];
41
+ if (!Array.isArray(jobs) || jobs.length > 1000 || jobs.some(job => !job || typeof job !== 'object' || Array.isArray(job)
42
+ || job.enabled !== undefined && typeof job.enabled !== 'boolean' || job.paused !== undefined && typeof job.paused !== 'boolean'))
43
+ throw new Error('Invalid native jobs');
44
+ const rows = jobs;
45
+ const definitions = rows.map(job => JSON.stringify(hermesJobDefinition(job))).sort();
46
+ // Top-level document fields other than the job list are kept, except its rewrite stamp.
47
+ const envelope = document ? canonicalJson(Object.fromEntries(Object.entries(document).filter(([key]) => key !== 'jobs' && key !== 'updated_at'))) : null;
48
+ const claimed = rows.filter(hermesJobClaimed).map(job => ['string', 'number'].includes(typeof job['id']) && String(job['id'])
49
+ ? 'id:' + String(job['id']) : 'definition:' + hash(JSON.stringify(hermesJobDefinition(job)))).sort();
50
+ return {
51
+ schedulesSha256: schedulerContentDigest(JSON.stringify({ domain: 'magnus.hermes-schedules.v1', source: hash(path), envelope, definitions })),
52
+ claimsSha256: schedulerContentDigest(JSON.stringify({ domain: 'magnus.hermes-claims.v1', source: hash(path), claimed })),
53
+ enabled: rows.filter(job => job['enabled'] !== false && job['paused'] !== true).length,
54
+ claims: claimed.length,
55
+ };
56
+ }
8
57
  export async function schedulerInventory(options = {}) {
9
58
  const blockers = [];
10
59
  const issues = [];
@@ -147,15 +196,14 @@ export async function schedulerInventory(options = {}) {
147
196
  if (raw === undefined)
148
197
  continue;
149
198
  try {
150
- const value = JSON.parse(raw);
151
- const jobs = Array.isArray(value) ? value : value.jobs;
152
- if (!Array.isArray(jobs) || jobs.length > 1000 || jobs.some(job => !job || typeof job !== 'object'
153
- || job.enabled !== undefined && typeof job.enabled !== 'boolean' || job.paused !== undefined && typeof job.paused !== 'boolean'))
154
- throw new Error('Invalid native jobs');
155
- blockContent('NATIVE_HERMES_SCHEDULES', raw, jobs.filter(job => job.enabled !== false && job.paused !== true).length);
156
- // Native claim TTL/PID liveness cannot prove descendants stopped. A paused
157
- // schedule with any unresolved claim remains uncertain until its owner proves completion.
158
- blockContent('NATIVE_HERMES_UNSETTLED_CLAIMS', raw, jobs.filter(job => job.fire_claim || job.run_claim || ['running', 'working'].includes(job.last_status)).length);
199
+ const identity = hermesJobsIdentity(path, JSON.parse(raw));
200
+ // Hermes rewrites jobs.json on every run. Evidence and remembered identity
201
+ // follow the job definitions and the set of claimed jobs, not run state.
202
+ sources[hash(path)] = schedulerContentDigest(JSON.stringify({ schedules: identity.schedulesSha256, claims: identity.claimsSha256 }));
203
+ blockers.push(...[{ kind: 'NATIVE_HERMES_SCHEDULES', sourceSha256: identity.schedulesSha256, count: identity.enabled },
204
+ // Native claim TTL/PID liveness cannot prove descendants stopped. A paused
205
+ // schedule with any unresolved claim remains uncertain until its owner proves completion.
206
+ { kind: 'NATIVE_HERMES_UNSETTLED_CLAIMS', sourceSha256: identity.claimsSha256, count: identity.claims }].filter(row => row.count));
159
207
  }
160
208
  catch {
161
209
  issues.push('HERMES_SCHEDULE_INVENTORY_INVALID:' + hash(path));
@@ -7,6 +7,7 @@ import { access, chown, chmod, lstat, mkdir, mkdtemp, readFile, readlink, readdi
7
7
  import { dirname, isAbsolute, join, resolve } from 'node:path';
8
8
  import { tmpdir } from 'node:os';
9
9
  import { fileURLToPath } from 'node:url';
10
+ import { parseGatewayStatusText } from './executors/hermes-gateway-status.js';
10
11
  import { childEnvironment } from './utils/child-env.js';
11
12
  import { armChildTimeout } from './utils/child-timeout.js';
12
13
  // Compare real paths: the service may start this file through a link (node_modules -> current/node_modules).
@@ -667,7 +668,7 @@ async function coreUpdate() {
667
668
  const current = (await runAsOwner('/usr/bin/git', home, ['-C', checkout, 'rev-parse', 'HEAD'])).trim();
668
669
  evidence.beforeVersion = (await runHermes(bin, home, ['--version'])).trim().slice(0, 100);
669
670
  const gatewayBefore = await runHermes(bin, home, ['gateway', 'status']);
670
- evidence.beforeGatewayActive = /active|running/i.test(gatewayBefore) && !/inactive|not active|not running|stopped|dead/i.test(gatewayBefore);
671
+ evidence.beforeGatewayActive = parseGatewayStatusText(gatewayBefore) === 'running';
671
672
  const pluginsBefore = await runHermes(bin, home, ['plugins', 'list', '--json']);
672
673
  const toolsBefore = await toolsSummary(bin, home);
673
674
  if (current === slot.commit) {
@@ -730,7 +731,7 @@ async function coreUpdate() {
730
731
  }
731
732
  evidence.afterVersion = (await runHermes(bin, home, ['--version'])).trim().slice(0, 100);
732
733
  const gatewayAfter = await runHermes(bin, home, ['gateway', 'status']);
733
- evidence.afterGatewayActive = /active|running/i.test(gatewayAfter) && !/inactive|not active|not running|stopped|dead/i.test(gatewayAfter);
734
+ evidence.afterGatewayActive = parseGatewayStatusText(gatewayAfter) === 'running';
734
735
  const pluginsAfter = await runHermes(bin, home, ['plugins', 'list', '--json']);
735
736
  const before = JSON.parse(pluginsBefore);
736
737
  const after = JSON.parse(pluginsAfter);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hivemagnus/magnus-agent",
3
- "version": "1.1.4",
3
+ "version": "1.1.7",
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.4",
61
+ "@hivemagnus/magnus-contracts": "1.1.7",
62
62
  "@hono/node-server": "^2.1.5",
63
63
  "dotenv": "^18.0.9",
64
64
  "hono": "^4.13.16",