@hivemagnus/magnus-server 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/.env.example +32 -0
  2. package/README.md +32 -0
  3. package/agent-version.json +1 -1
  4. package/dist/ai/TOOLS.md +1 -1
  5. package/dist/cli/args.js +3 -1
  6. package/dist/cli/env-catalog.js +13 -0
  7. package/dist/cli/migrations.js +22 -0
  8. package/dist/cli/verify-initialization.js +12 -0
  9. package/dist/cli.js +28 -0
  10. package/dist/config/console-path.js +17 -0
  11. package/dist/config/env.js +28 -0
  12. package/dist/config/features.js +3 -0
  13. package/dist/config/oathweave.js +42 -0
  14. package/dist/config/rehearsal.js +22 -0
  15. package/dist/db/generated/prisma/internal/class.js +4 -4
  16. package/dist/db/generated/prisma/internal/prismaNamespace.js +5 -0
  17. package/dist/db/generated/prisma/internal/prismaNamespaceBrowser.js +5 -0
  18. package/dist/db/generated/prisma/models/ProductInitialization.js +2 -0
  19. package/dist/db/read-retry.js +5 -1
  20. package/dist/db/seed.js +8 -0
  21. package/dist/index.js +27 -21
  22. package/dist/middleware/auth.js +21 -0
  23. package/dist/middleware/error-handler.js +10 -10
  24. package/dist/middleware/oathweave-verifier.js +23 -0
  25. package/dist/middleware/oathweave.js +115 -0
  26. package/dist/middleware/rate-limit.js +12 -3
  27. package/dist/openapi/spec.js +38 -2
  28. package/dist/queue/names.js +9 -0
  29. package/dist/queue/workers.js +9 -3
  30. package/dist/routes/assistant.js +30 -12
  31. package/dist/routes/console.js +33 -0
  32. package/dist/routes/cron.js +8 -5
  33. package/dist/routes/events-stream.js +10 -0
  34. package/dist/routes/files.js +2 -1
  35. package/dist/routes/fleet-gateway.js +69 -0
  36. package/dist/routes/hermes.js +26 -6
  37. package/dist/routes/hiveskills.js +116 -0
  38. package/dist/routes/library.js +3 -2
  39. package/dist/routes/logs.js +5 -0
  40. package/dist/routes/metrics.js +4 -3
  41. package/dist/routes/nodes.js +45 -8
  42. package/dist/routes/ops.js +24 -6
  43. package/dist/routes/profiling.js +2 -1
  44. package/dist/routes/roles.js +17 -9
  45. package/dist/routes/stats.js +8 -5
  46. package/dist/routes/terminal-ticket.js +3 -3
  47. package/dist/routes/terminal.js +2 -2
  48. package/dist/routes/webhooks.js +15 -15
  49. package/dist/sdk/node-api-client.js +11 -1
  50. package/dist/security/native-completion-registry.js +3 -1
  51. package/dist/server.js +8 -0
  52. package/dist/services/ai-assistant-service.js +32 -6
  53. package/dist/services/ai-tools.js +15 -8
  54. package/dist/services/ardent-scope-enrollment.js +4 -2
  55. package/dist/services/forecast-service.js +23 -8
  56. package/dist/services/ops-service.js +20 -25
  57. package/dist/services/prediction-service.js +2 -1
  58. package/dist/services/resource-enrollment.js +57 -4
  59. package/dist/services/secret-views.js +72 -0
  60. package/dist/services/topology-service.js +2 -1
  61. package/dist/services/webhook-security.js +10 -1
  62. package/dist/services/webhook-service.js +14 -6
  63. package/dist/utils/node-url.js +2 -1
  64. package/dist/utils/safe-errors.js +130 -0
  65. package/node_modules/@hivemagnus/magnus-oathweave/LICENSE +202 -0
  66. package/node_modules/@hivemagnus/magnus-oathweave/README.md +20 -0
  67. package/node_modules/@hivemagnus/magnus-oathweave/dist/index.d.ts +61 -0
  68. package/node_modules/@hivemagnus/magnus-oathweave/dist/index.js +148 -0
  69. package/node_modules/@hivemagnus/magnus-oathweave/package.json +54 -0
  70. package/package.json +7 -3
  71. package/prisma/migrations/20261105010000_base_initialization/magnus-impact.json +1 -0
  72. package/prisma/migrations/20261105010000_base_initialization/migration.sql +5 -0
  73. package/prisma/schema.prisma +9 -0
@@ -24,13 +24,25 @@ import { Hono } from 'hono';
24
24
  import { streamSSE } from 'hono/streaming';
25
25
  import { authMiddleware, requireRole } from '../middleware/auth.js';
26
26
  import { HttpError } from '../middleware/error-handler.js';
27
- import { chat, chatStream, listConversations, getConversationMessages, getUnreadAiMessageCount, markAiMessagesRead, markConversationRead, archiveConversation, deleteConversation, getHeartbeatPrompt, updateHeartbeatPrompt, resetHeartbeatPrompt, listOllamaModels, getModelContextLength, getAiSettings, updateAiSettings, getModelProfiles, updateModelProfiles, approveToolCall, rejectToolCall, createConversationShare, forkConversation, getSharedConversation, } from '../services/ai-assistant-service.js';
27
+ import { clientErrorMessage } from '../utils/safe-errors.js';
28
+ import { chat, chatStream, listConversations, getConversationMessages, getUnreadAiMessageCount, markAiMessagesRead, markConversationRead, archiveConversation, deleteConversation, getHeartbeatPrompt, updateHeartbeatPrompt, resetHeartbeatPrompt, listOllamaModels, getModelContextLength, getAiSettings, updateAiSettings, getModelProfiles, updateModelProfiles, approveToolCall, rejectToolCall, createConversationShare, forkConversation, getSharedConversation, conversationOwners, getConversationAccess, } from '../services/ai-assistant-service.js';
28
29
  import { executeAiTool } from '../services/ai-tools.js';
29
30
  import { generateInsights, listPinnedInsights, pinInsight, unpinInsight } from '../services/ai-insights-service.js';
30
31
  import { exportConversation } from '../services/ai-export-service.js';
31
32
  import { clearScratchpad, listScratchpadEntries } from '../services/ai-scratchpad-service.js';
32
33
  export const assistantRouter = new Hono();
33
34
  assistantRouter.use('*', authMiddleware);
35
+ const ownersOf = (c) => conversationOwners(c.get('user'));
36
+ /**
37
+ * A conversation belongs to whoever started it. ADMIN gets no exception, because no
38
+ * console feature reads other people's conversations. Someone else's answers 404,
39
+ * so a caller cannot tell it exists.
40
+ */
41
+ async function requireConversation(c, id, level = 'own') {
42
+ const access = await getConversationAccess(id, ownersOf(c));
43
+ if (!access || (level === 'own' && access !== 'owner'))
44
+ throw new HttpError(404, 'NOT_FOUND', 'Conversation not found');
45
+ }
34
46
  // ── Send a chat message ──
35
47
  assistantRouter.post('/chat', requireRole('ADMIN', 'OPERATOR'), async (c) => {
36
48
  const body = await c.req.json();
@@ -44,6 +56,8 @@ assistantRouter.post('/chat', requireRole('ADMIN', 'OPERATOR'), async (c) => {
44
56
  ctxParts.push(`You are the AI assistant for the "${body.controlNodeName}" Control Node (ID: ${body.controlNodeId ?? 'unknown'}). All fleet data you access is from this control node's database. When the user asks about "the fleet" or "nodes", they mean the nodes managed by this control node. Your tools (like getFleetStatus, getNodeHealth, etc.) query this control node's data directly — they do NOT require a fleet ID parameter.`);
45
57
  controllerContext = ctxParts.join('\n');
46
58
  }
59
+ if (body.conversationId)
60
+ await requireConversation(c, body.conversationId);
47
61
  const user = c.get('user');
48
62
  const result = await chat({
49
63
  message: body.message,
@@ -67,28 +81,23 @@ assistantRouter.get('/conversations', async (c) => {
67
81
  status: c.req.query('status') ?? 'active',
68
82
  page: parseInt(c.req.query('page') ?? '1', 10),
69
83
  pageSize: parseInt(c.req.query('pageSize') ?? '20', 10),
84
+ owners: ownersOf(c),
70
85
  });
71
86
  return c.json({ success: true, data: result });
72
87
  });
73
88
  // ── Get conversation messages ──
74
89
  assistantRouter.get('/conversations/:id', async (c) => {
75
90
  const id = c.req.param('id');
91
+ await requireConversation(c, id, 'read');
76
92
  const result = await getConversationMessages(id, {
77
93
  page: parseInt(c.req.query('page') ?? '1', 10),
78
94
  pageSize: parseInt(c.req.query('pageSize') ?? '50', 10),
79
95
  });
80
- if (result.total === 0) {
81
- // Check if conversation exists
82
- const { getDb } = await import('../db/index.js');
83
- const db = getDb();
84
- const conv = await db.aiConversation.findUnique({ where: { id } });
85
- if (!conv)
86
- throw new HttpError(404, 'NOT_FOUND', 'Conversation not found');
87
- }
88
96
  return c.json({ success: true, data: result });
89
97
  });
90
98
  assistantRouter.post('/conversations/:id/fork', requireRole('ADMIN', 'OPERATOR'), async (c) => {
91
99
  const id = c.req.param('id');
100
+ await requireConversation(c, id, 'read');
92
101
  const body = (await c.req.json().catch(() => ({})));
93
102
  const user = c.get('user');
94
103
  try {
@@ -107,6 +116,7 @@ assistantRouter.post('/conversations/:id/fork', requireRole('ADMIN', 'OPERATOR')
107
116
  });
108
117
  assistantRouter.post('/conversations/:id/share', requireRole('ADMIN', 'OPERATOR'), async (c) => {
109
118
  const id = c.req.param('id');
119
+ await requireConversation(c, id);
110
120
  const body = (await c.req.json().catch(() => ({})));
111
121
  const user = c.get('user');
112
122
  try {
@@ -123,6 +133,7 @@ assistantRouter.post('/conversations/:id/share', requireRole('ADMIN', 'OPERATOR'
123
133
  }
124
134
  });
125
135
  assistantRouter.get('/conversations/:id/scratchpad', async (c) => {
136
+ await requireConversation(c, c.req.param('id'), 'read');
126
137
  const entries = listScratchpadEntries(c.req.param('id')).map((entry) => ({
127
138
  name: entry.name,
128
139
  source: entry.source,
@@ -134,6 +145,7 @@ assistantRouter.get('/conversations/:id/scratchpad', async (c) => {
134
145
  return c.json({ success: true, data: { entries } });
135
146
  });
136
147
  assistantRouter.delete('/conversations/:id/scratchpad', requireRole('ADMIN', 'OPERATOR'), async (c) => {
148
+ await requireConversation(c, c.req.param('id'));
137
149
  clearScratchpad(c.req.param('id'));
138
150
  return c.json({ success: true });
139
151
  });
@@ -147,12 +159,14 @@ assistantRouter.get('/shares/:token', async (c) => {
147
159
  // ── Mark conversation as read ──
148
160
  assistantRouter.post('/conversations/:id/read', async (c) => {
149
161
  const id = c.req.param('id');
162
+ await requireConversation(c, id, 'read');
150
163
  const count = await markConversationRead(id);
151
164
  return c.json({ success: true, data: { marked: count } });
152
165
  });
153
166
  // ── Archive a conversation ──
154
167
  assistantRouter.post('/conversations/:id/archive', requireRole('ADMIN', 'OPERATOR'), async (c) => {
155
168
  const id = c.req.param('id');
169
+ await requireConversation(c, id);
156
170
  try {
157
171
  await archiveConversation(id);
158
172
  return c.json({ success: true });
@@ -163,7 +177,7 @@ assistantRouter.post('/conversations/:id/archive', requireRole('ADMIN', 'OPERATO
163
177
  });
164
178
  // ── Get unread message count ──
165
179
  assistantRouter.get('/unread', async (c) => {
166
- const count = await getUnreadAiMessageCount();
180
+ const count = await getUnreadAiMessageCount(ownersOf(c));
167
181
  return c.json({ success: true, data: { unread: count } });
168
182
  });
169
183
  // ── Mark specific messages as read ──
@@ -172,12 +186,13 @@ assistantRouter.post('/messages/read', async (c) => {
172
186
  if (!body.messageIds?.length) {
173
187
  throw new HttpError(400, 'VALIDATION_ERROR', 'messageIds array is required');
174
188
  }
175
- const count = await markAiMessagesRead(body.messageIds);
189
+ const count = await markAiMessagesRead(body.messageIds, ownersOf(c));
176
190
  return c.json({ success: true, data: { marked: count } });
177
191
  });
178
192
  // ── Delete a conversation permanently ──
179
193
  assistantRouter.delete('/conversations/:id', requireRole('ADMIN', 'OPERATOR'), async (c) => {
180
194
  const id = c.req.param('id');
195
+ await requireConversation(c, id);
181
196
  try {
182
197
  await deleteConversation(id);
183
198
  return c.json({ success: true });
@@ -236,6 +251,8 @@ assistantRouter.post('/chat/stream', requireRole('ADMIN', 'OPERATOR'), async (c)
236
251
  ctxParts.push(`You are the AI assistant for the "${body.controlNodeName}" Control Node (ID: ${body.controlNodeId ?? 'unknown'}).`);
237
252
  controllerContext = ctxParts.join('\n');
238
253
  }
254
+ if (body.conversationId)
255
+ await requireConversation(c, body.conversationId);
239
256
  const user = c.get('user');
240
257
  return streamSSE(c, async (stream) => {
241
258
  try {
@@ -261,7 +278,7 @@ assistantRouter.post('/chat/stream', requireRole('ADMIN', 'OPERATOR'), async (c)
261
278
  catch (err) {
262
279
  await stream.writeSSE({
263
280
  event: 'error',
264
- data: JSON.stringify({ type: 'error', error: err instanceof Error ? err.message : 'Stream failed' }),
281
+ data: JSON.stringify({ type: 'error', error: clientErrorMessage(err, 'Stream failed') }),
265
282
  });
266
283
  }
267
284
  });
@@ -352,6 +369,7 @@ assistantRouter.post('/messages/:id/confirm', requireRole('ADMIN', 'OPERATOR'),
352
369
  // ── Conversation export ──
353
370
  assistantRouter.get('/conversations/:id/export', async (c) => {
354
371
  const id = c.req.param('id');
372
+ await requireConversation(c, id, 'read');
355
373
  const format = (c.req.query('format') ?? 'markdown');
356
374
  if (!['markdown', 'json', 'csv'].includes(format)) {
357
375
  throw new HttpError(400, 'VALIDATION_ERROR', 'format must be markdown, json, or csv');
@@ -0,0 +1,33 @@
1
+ import { Hono } from 'hono';
2
+ import { authMiddleware, getUser } from '../middleware/auth.js';
3
+ import { AGENT_VERSION, CONTROL_API_VERSION } from '../config/version.js';
4
+ import { featureFlags } from '../config/features.js';
5
+ import { securityConsoleUrl } from '../services/security-export.js';
6
+ export const consoleRouter = new Hono();
7
+ consoleRouter.use('*', authMiddleware);
8
+ /**
9
+ * GET /console/session
10
+ * What a browser console may show this caller. A static page has no server of its
11
+ * own to read the environment, so this answers what the dashboard derives from
12
+ * server-side checks and build-time public values.
13
+ */
14
+ consoleRouter.get('/session', (c) => {
15
+ const user = getUser(c);
16
+ c.header('Cache-Control', 'no-store');
17
+ return c.json({
18
+ success: true,
19
+ data: {
20
+ userId: user.userId,
21
+ role: user.role,
22
+ capabilities: {
23
+ hermes: featureFlags().hermes,
24
+ // Whether to offer the terminal. The gateway still verifies everything, including step-up.
25
+ terminal: featureFlags().fleetGateway && user.terminal === true,
26
+ agentVersion: AGENT_VERSION,
27
+ magnusVersion: CONTROL_API_VERSION,
28
+ securityConsoleUrl: securityConsoleUrl() ?? null,
29
+ },
30
+ },
31
+ });
32
+ });
33
+ //# sourceMappingURL=console.js.map
@@ -2,6 +2,7 @@ import { managedCronRouter } from './managed-cron.js';
2
2
  import { canStageLegacyCronForEnrollment, cronMutationMode, finalizeManagedCronRevocation, installManagedCron, registerManagedCron, reconcileForeignSchedulerSource, revokeManagedCron, stageLegacyCronForEnrollment, updateAndRegisterManagedCron } from '../services/managed-cron.js';
3
3
  import { managedCronRegistrationSchema, schedulerSourceSettlementSchema } from '../schemas/managed-cron.js';
4
4
  import { Hono } from 'hono';
5
+ import { cronJobForRole, mergeEnvironmentEdit, nodeForRole } from '../services/secret-views.js';
5
6
  import { getDb } from '../db/index.js';
6
7
  import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
7
8
  import { logAuditEvent, sanitizeDetails } from '../services/audit-service.js';
@@ -68,7 +69,8 @@ cronRouter.get('/', async (c) => {
68
69
  orderBy: { name: 'asc' },
69
70
  include: { node: { select: { name: true } } },
70
71
  });
71
- return c.json({ success: true, data: jobs });
72
+ const viewer = getUser(c).role;
73
+ return c.json({ success: true, data: jobs.map((job) => cronJobForRole(job, viewer)) });
72
74
  });
73
75
  // ── Get single cron job ──
74
76
  cronRouter.get('/:cronId', async (c) => {
@@ -79,7 +81,7 @@ cronRouter.get('/:cronId', async (c) => {
79
81
  });
80
82
  if (!job)
81
83
  throw new HttpError(404, 'NOT_FOUND', 'Cron job not found');
82
- return c.json({ success: true, data: job });
84
+ return c.json({ success: true, data: cronJobForRole(job, getUser(c).role) });
83
85
  });
84
86
  // ── Create cron job ──
85
87
  cronRouter.post('/', requireRole('ADMIN', 'OPERATOR'), async (c) => {
@@ -136,7 +138,7 @@ cronRouter.post('/', requireRole('ADMIN', 'OPERATOR'), async (c) => {
136
138
  timeoutSeconds: body.timeoutSeconds ?? 600,
137
139
  });
138
140
  }
139
- return c.json({ success: true, data: job }, 201);
141
+ return c.json({ success: true, data: cronJobForRole(job, user.role) }, 201);
140
142
  });
141
143
  cronRouter.post('/scheduler-sources/reconcile', requireRole('ADMIN'), async (c) => {
142
144
  const assertion = await parseBody(c, schedulerSourceSettlementSchema);
@@ -209,7 +211,7 @@ cronRouter.patch('/:cronId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
209
211
  ...(body.command && { command: body.command }),
210
212
  ...(body.args && { args: JSON.stringify(body.args) }),
211
213
  ...(body.workingDirectory !== undefined && { workingDirectory: body.workingDirectory }),
212
- ...(body.environment !== undefined && { environment: JSON.stringify(sanitizeDetails(body.environment)) }),
214
+ ...(body.environment !== undefined && { environment: JSON.stringify(sanitizeDetails(user.role === 'ADMIN' ? body.environment : mergeEnvironmentEdit(initial.environment, body.environment))) }),
213
215
  ...(nextStatus && { status: nextStatus }),
214
216
  ...(body.timeoutSeconds && { timeoutSeconds: body.timeoutSeconds }),
215
217
  };
@@ -245,7 +247,8 @@ cronRouter.patch('/:cronId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
245
247
  timeoutSeconds: job.timeoutSeconds,
246
248
  });
247
249
  })();
248
- return c.json({ success: true, data: job });
250
+ // The agent connection above needs the node; its token must never leave the server.
251
+ return c.json({ success: true, data: { ...cronJobForRole(job, user.role), node: nodeForRole(job.node, user.role) } });
249
252
  });
250
253
  // ── Delete cron job ──
251
254
  cronRouter.delete('/:cronId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
@@ -1,5 +1,6 @@
1
1
  import { Hono } from 'hono';
2
2
  import { authMiddleware } from '../middleware/auth.js';
3
+ import { watchOathweaveStream } from '../middleware/oathweave.js';
3
4
  import { eventBus } from '../services/event-bus.js';
4
5
  export const eventsStreamRouter = new Hono();
5
6
  eventsStreamRouter.use('*', authMiddleware);
@@ -19,10 +20,12 @@ eventsStreamRouter.get('/', async (c) => {
19
20
  let keepAlive;
20
21
  let unsubscribe;
21
22
  let closed = false;
23
+ let stopWatching;
22
24
  // Idempotent cleanup for every exit path: client abort, stream cancel, or a
23
25
  // disconnect that happened while the subscription was still being set up.
24
26
  const cleanup = () => {
25
27
  closed = true;
28
+ stopWatching?.();
26
29
  if (keepAlive)
27
30
  clearInterval(keepAlive);
28
31
  keepAlive = undefined;
@@ -42,6 +45,13 @@ eventsStreamRouter.get('/', async (c) => {
42
45
  // Stream closed
43
46
  }
44
47
  };
48
+ stopWatching = watchOathweaveStream(c.req.header('Authorization'), () => {
49
+ cleanup();
50
+ try {
51
+ controller.close();
52
+ }
53
+ catch { /* already closed */ }
54
+ });
45
55
  // Send initial connection event
46
56
  send('connected', { timestamp: new Date().toISOString() });
47
57
  // Keep-alive every 30s
@@ -1,3 +1,4 @@
1
+ import { clientErrorMessage } from '../utils/safe-errors.js';
1
2
  import { Hono } from 'hono';
2
3
  import { getDb } from '../db/index.js';
3
4
  import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
@@ -133,7 +134,7 @@ filesRouter.post('/update', requireRole('ADMIN', 'OPERATOR'), async (c) => {
133
134
  return { ...task, status: 'completed' };
134
135
  }
135
136
  catch (err) {
136
- const message = err instanceof Error ? err.message : String(err);
137
+ const message = clientErrorMessage(err, 'File update failed');
137
138
  await db.filePatchTask.update({
138
139
  where: { id: task.id },
139
140
  data: { status: 'failed', error: message },
@@ -0,0 +1,69 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { Hono } from 'hono';
3
+ import { z } from 'zod';
4
+ import { getDb } from '../db/index.js';
5
+ import { HttpError } from '../middleware/error-handler.js';
6
+ import { parseBody } from '../utils/validate.js';
7
+ import { safeEqual } from '../utils/safe-equal.js';
8
+ import { logAuditEvent } from '../services/audit-service.js';
9
+ /**
10
+ * Machine routes for the fleet gateway, the separate service that holds the restricted SSH key.
11
+ * The gateway asks what a node is and records what happened. Nothing here dials a node.
12
+ */
13
+ export const fleetGatewayRouter = new Hono();
14
+ async function requireGatewayCredential(c, next) {
15
+ const expected = process.env['FLEET_GATEWAY_TOKEN_SHA256'] ?? '';
16
+ const token = c.req.header('x-fleet-gateway-token') ?? '';
17
+ if (!expected || !token || !safeEqual(createHash('sha256').update(token).digest('hex'), expected))
18
+ throw new HttpError(401, 'UNAUTHORIZED', 'Fleet gateway credential required');
19
+ await next();
20
+ }
21
+ fleetGatewayRouter.use('*', requireGatewayCredential);
22
+ const nodeId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/);
23
+ /** POST /fleet-gateway/target: inventory facts for one node. The gateway applies its own host-key approval. */
24
+ fleetGatewayRouter.post('/target', async (c) => {
25
+ const body = await parseBody(c, z.object({ nodeId }).strict());
26
+ const node = await getDb().node.findUnique({
27
+ where: { id: body.nodeId },
28
+ select: { id: true, hostname: true, ipAddress: true, status: true },
29
+ });
30
+ if (!node)
31
+ throw new HttpError(404, 'NODE_NOT_FOUND', 'Node not found');
32
+ c.header('Cache-Control', 'no-store');
33
+ return c.json({ success: true, data: { nodeId: node.id, hostname: node.hostname, ipAddress: node.ipAddress, status: node.status } });
34
+ });
35
+ const auditSchema = z.object({
36
+ action: z.enum(['terminal.session.open', 'terminal.session.close', 'terminal.session.refuse', 'terminal.host-key.approve', 'terminal.host-key.remove']),
37
+ subject: z.string().min(1).max(256),
38
+ actorId: z.string().min(1).max(256),
39
+ nodeId: nodeId.nullable(),
40
+ sessionId: z.string().regex(/^ts_[A-Za-z0-9_-]{43}$/).nullable(),
41
+ outcome: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9:._/-]{0,199}$/),
42
+ at: z.iso.datetime(),
43
+ durationMs: z.number().int().nonnegative().nullable(),
44
+ ipAddress: z.union([z.ipv4(), z.ipv6()]).nullable(),
45
+ fingerprint: z.string().regex(/^SHA256:[A-Za-z0-9+/]{43}$/).nullable(),
46
+ }).strict();
47
+ /** POST /fleet-gateway/audit: terminal metadata. The schema has no field for terminal input or output. */
48
+ fleetGatewayRouter.post('/audit', async (c) => {
49
+ const event = await parseBody(c, auditSchema);
50
+ await logAuditEvent({
51
+ action: event.action,
52
+ actorId: event.actorId,
53
+ actorType: 'user',
54
+ targetType: 'node',
55
+ targetId: event.nodeId ?? 'unspecified',
56
+ ipAddress: event.ipAddress,
57
+ details: {
58
+ subject: event.subject,
59
+ sessionHandle: event.sessionId,
60
+ outcome: event.outcome,
61
+ observedAt: event.at,
62
+ durationMs: event.durationMs,
63
+ hostKeyFingerprint: event.fingerprint,
64
+ },
65
+ });
66
+ c.header('Cache-Control', 'no-store');
67
+ return c.json({ success: true, data: { recorded: true } });
68
+ });
69
+ //# sourceMappingURL=fleet-gateway.js.map
@@ -1,5 +1,6 @@
1
1
  import { Hono } from 'hono';
2
- import { authMiddleware } from '../middleware/auth.js';
2
+ import { authMiddleware, getUser } from '../middleware/auth.js';
3
+ import { hermesProfileForRole } from '../services/secret-views.js';
3
4
  import { HttpError } from '../middleware/error-handler.js';
4
5
  import { hermesJobListQuerySchema } from '../schemas/hermes.js';
5
6
  import { getHermesApiServerStatus, getHermesInstallation, getHermesJob, listHermesInstallations, listHermesJobs, listHermesProfiles, } from '../services/hermes-service.js';
@@ -13,21 +14,40 @@ function requiredParam(c, name) {
13
14
  }
14
15
  // Read-only inventory/status endpoints remain available because they use the
15
16
  // Control database and never open SSH connections.
16
- hermesRouter.get('/installations', async (c) => c.json({ success: true, data: await listHermesInstallations() }));
17
+ function installationForRole(installation, role) {
18
+ return installation.profiles
19
+ ? { ...installation, profiles: installation.profiles.map((profile) => hermesProfileForRole(profile, role)) }
20
+ : installation;
21
+ }
22
+ hermesRouter.get('/installations', async (c) => {
23
+ const role = getUser(c).role;
24
+ return c.json({ success: true, data: (await listHermesInstallations()).map((installation) => installationForRole(installation, role)) });
25
+ });
17
26
  hermesRouter.get('/installations/:id', async (c) => {
18
27
  const data = await getHermesInstallation(requiredParam(c, 'id'));
19
28
  if (!data)
20
29
  throw new HttpError(404, 'NOT_FOUND', 'Hermes installation not found');
21
- return c.json({ success: true, data });
30
+ return c.json({ success: true, data: installationForRole(data, getUser(c).role) });
22
31
  });
23
32
  hermesRouter.get('/installations/:id/profiles', async (c) => {
24
33
  const installation = await getHermesInstallation(requiredParam(c, 'id'));
25
34
  if (!installation)
26
35
  throw new HttpError(404, 'NOT_FOUND', 'Hermes installation not found');
27
- return c.json({ success: true, data: await listHermesProfiles(installation.id) });
36
+ const role = getUser(c).role;
37
+ return c.json({ success: true, data: (await listHermesProfiles(installation.id)).map((profile) => hermesProfileForRole(profile, role)) });
38
+ });
39
+ hermesRouter.get('/profiles', async (c) => {
40
+ const role = getUser(c).role;
41
+ return c.json({ success: true, data: (await listHermesProfiles()).map((profile) => hermesProfileForRole(profile, role)) });
42
+ });
43
+ hermesRouter.get('/profiles/:id/api-server/status', async (c) => {
44
+ const { keyFingerprint, ...status } = await getHermesApiServerStatus(requiredParam(c, 'id'));
45
+ return c.json({ success: true, data: {
46
+ ...status,
47
+ ...(getUser(c).role === 'ADMIN' ? { keyFingerprint } : {}),
48
+ keyConfigured: Boolean(keyFingerprint),
49
+ } });
28
50
  });
29
- hermesRouter.get('/profiles', async (c) => c.json({ success: true, data: await listHermesProfiles() }));
30
- hermesRouter.get('/profiles/:id/api-server/status', async (c) => c.json({ success: true, data: await getHermesApiServerStatus(requiredParam(c, 'id')) }));
31
51
  hermesRouter.get('/jobs', async (c) => {
32
52
  const parsed = hermesJobListQuerySchema.safeParse({
33
53
  status: c.req.query('status'),
@@ -0,0 +1,116 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { Hono } from 'hono';
3
+ import { z } from 'zod';
4
+ import { HiveSkillsTargetPostureSchema, HiveSkillsPublicationSchema } from '@hivemagnus/magnus-contracts';
5
+ import { getDb } from '../db/index.js';
6
+ import { HttpError } from '../middleware/error-handler.js';
7
+ import { safeEqual } from '../utils/safe-equal.js';
8
+ import { getNodeManagerSecret } from '../utils/env.js';
9
+ import { getNodeBaseUrl } from '../utils/node-url.js';
10
+ import { NodeApiClient } from '../sdk/node-api-client.js';
11
+ import { logAuditEvent } from '../services/audit-service.js';
12
+ import { httpIdentity } from '../security/managed-http.js';
13
+ const slug = z.string().regex(/^[a-z0-9][a-z0-9-]{1,62}$/);
14
+ export const skillsExecutorConfig = z.record(slug, z.object({ tokenSha256: z.string().regex(/^[a-f0-9]{64}$/), targets: z.record(slug, z.object({ nodeId: z.string().min(1).max(128), agentTarget: slug }).strict()) }).strict());
15
+ export const skillJobRequest = z.object({ target: slug, publicationSha256: z.string().regex(/^[a-f0-9]{64}$/), manifest: HiveSkillsPublicationSchema, approvedBy: z.string().min(1).max(200) }).strict();
16
+ export const hiveskillsRouter = new Hono();
17
+ function authorize(header) {
18
+ let raw;
19
+ try {
20
+ raw = JSON.parse(process.env['MAGNUS_HIVESKILLS_DEPLOYMENTS_JSON'] ?? '{}');
21
+ }
22
+ catch {
23
+ throw new HttpError(503, 'HIVESKILLS_UNCONFIGURED', 'Invalid Skills executor configuration');
24
+ }
25
+ const configured = skillsExecutorConfig.safeParse(raw);
26
+ if (!configured.success)
27
+ throw new HttpError(503, 'HIVESKILLS_UNCONFIGURED', 'Invalid Skills executor configuration');
28
+ if (!header?.startsWith('Bearer ') || header.length < 39 || /[\r\n]/.test(header))
29
+ throw new HttpError(401, 'UNAUTHORIZED', 'Endpoint-scoped Skills credential required');
30
+ const digest = createHash('sha256').update(header.slice(7)).digest('hex');
31
+ for (const [registryId, entry] of Object.entries(configured.data))
32
+ if (safeEqual(entry.tokenSha256, digest))
33
+ return { registryId, ...entry };
34
+ throw new HttpError(401, 'UNAUTHORIZED', 'Endpoint-scoped Skills credential required');
35
+ }
36
+ function commandId(registryId, id) { if (!z.uuid().safeParse(id).success)
37
+ throw new HttpError(400, 'VALIDATION_ERROR', 'Job UUID required'); return 'hiveskills-' + createHash('sha256').update(registryId + ':' + id).digest('hex'); }
38
+ async function targetClient(registry, target) {
39
+ const binding = registry.targets[target];
40
+ if (!binding)
41
+ throw new HttpError(403, 'FORBIDDEN', 'Target is outside this registry executor grant');
42
+ const node = await getDb().node.findUnique({ where: { id: binding.nodeId } });
43
+ if (!node || node.status === 'DECOMMISSIONED')
44
+ throw new HttpError(409, 'TARGET_UNAVAILABLE', 'Configured target is unavailable');
45
+ return { binding, node, client: new NodeApiClient({ baseUrl: getNodeBaseUrl(node.ipAddress, node.port), resourceNodeId: node.id, resourceActorId: 'service:hiveskills:' + registry.registryId, nodeToken: getNodeManagerSecret() }) };
46
+ }
47
+ async function observed(registry, target, id) {
48
+ const { node, client } = await targetClient(registry, target), jobId = commandId(registry.registryId, id);
49
+ const row = await getDb().remoteCommand.findUnique({ where: { id: jobId } });
50
+ if (!row)
51
+ throw new HttpError(404, 'NOT_FOUND', 'Job has not been admitted');
52
+ if (row.nodeId !== node.id || row.createdBy !== 'service:hiveskills:' + registry.registryId)
53
+ throw new HttpError(409, 'JOB_BINDING_CONFLICT', 'Job belongs to another target');
54
+ const lease = await getDb().commandResourceLease.findUnique({ where: { commandId: jobId } });
55
+ if (!lease)
56
+ return { jobId, status: 'unknown', result: 'Missing durable ownership; review required' };
57
+ try {
58
+ const receipt = await client.getCommandReceipt(jobId, { nodeFqdn: node.hostname, requestSha256: lease.requestSha256 });
59
+ if (receipt.state === 'completed')
60
+ return { jobId, status: receipt.result.exitCode === 0 ? 'completed' : 'failed', result: receipt.result.exitCode === 0 ? 'Exact approved publication synchronized' : 'Node completed with a bounded failure' };
61
+ return { jobId, status: receipt.state === 'running' ? 'running' : 'unknown', result: 'Original operation retained; never replayed' };
62
+ }
63
+ catch {
64
+ return { jobId, status: 'unknown', result: 'Receipt unavailable; original operation retained' };
65
+ }
66
+ }
67
+ hiveskillsRouter.get('/targets/:target', async (c) => {
68
+ const registry = authorize(c.req.header('authorization')), target = slug.safeParse(c.req.param('target'));
69
+ if (!target.success)
70
+ throw new HttpError(400, 'VALIDATION_ERROR', 'Exact logical target required');
71
+ const { binding, node, client } = await targetClient(registry, target.data), enrollment = await getDb().resourceEnrollment.findUnique({ where: { nodeId: node.id } });
72
+ const base = { registryId: registry.registryId, target: target.data, nodeId: node.id, agentTarget: binding.agentTarget };
73
+ if (enrollment?.state !== 'enrolled' || enrollment.nodeFqdn !== node.hostname || node.status !== 'ONLINE' || !node.lastHeartbeatAt || Date.now() - node.lastHeartbeatAt.getTime() > 120000 || await getDb().commandResourceLease.count({ where: { nodeId: node.id, state: { in: ['reserved', 'running', 'unknown'] } } }))
74
+ return c.json({ ...base, ready: false, reason: 'Node enrollment, health or retained ownership requires operator review' });
75
+ try {
76
+ const reply = await client.hiveSkillsTargetPosture(binding.agentTarget, registry.registryId), posture = HiveSkillsTargetPostureSchema.parse(reply.data);
77
+ if (!reply.success || posture.registryId !== registry.registryId || posture.target !== binding.agentTarget)
78
+ throw new Error('Native target binding differs');
79
+ return c.json({ ...base, ready: posture.ready, reason: posture.reason, configurationSha256: posture.configurationSha256, clientSha256: posture.clientSha256 });
80
+ }
81
+ catch {
82
+ return c.json({ ...base, ready: false, reason: 'Native target posture unavailable' });
83
+ }
84
+ });
85
+ hiveskillsRouter.get('/jobs/:id', async (c) => { const registry = authorize(c.req.header('authorization')); const target = slug.safeParse(c.req.query('target')); if (!target.success)
86
+ throw new HttpError(400, 'VALIDATION_ERROR', 'Exact target required'); return c.json(await observed(registry, target.data, c.req.param('id'))); });
87
+ hiveskillsRouter.post('/jobs/:id', async (c) => {
88
+ const registry = authorize(c.req.header('authorization')), parsed = skillJobRequest.safeParse(await c.req.json().catch(() => null));
89
+ if (!parsed.success)
90
+ throw new HttpError(400, 'VALIDATION_ERROR', 'Exact approved Skills publication required');
91
+ const body = parsed.data, jobId = commandId(registry.registryId, c.req.param('id'));
92
+ if (createHash('sha256').update(JSON.stringify(body.manifest)).digest('hex') !== body.publicationSha256)
93
+ throw new HttpError(409, 'PUBLICATION_CONFLICT', 'Publication digest differs');
94
+ const { binding, node, client } = await targetClient(registry, body.target);
95
+ const request = { target: binding.agentTarget, registryId: registry.registryId, publicationSha256: body.publicationSha256, manifest: body.manifest };
96
+ const prior = await getDb().remoteCommand.findUnique({ where: { id: jobId } });
97
+ if (prior) {
98
+ const identity = httpIdentity(jobId, 'POST', '/hiveskills/sync', JSON.stringify(request), 120);
99
+ if (prior.nodeId !== node.id || prior.createdBy !== 'service:hiveskills:' + registry.registryId || prior.command !== identity.command || prior.args !== JSON.stringify(identity.args))
100
+ throw new HttpError(409, 'JOB_BINDING_CONFLICT', 'Existing job has another target or publication');
101
+ return c.json(await observed(registry, body.target, c.req.param('id')));
102
+ }
103
+ await logAuditEvent({ action: 'skill.push', actorId: 'service:hiveskills:' + registry.registryId, targetType: 'node', targetId: node.id, details: { jobId, target: body.target, publicationSha256: body.publicationSha256, approvedBy: body.approvedBy, outcome: 'requested' } });
104
+ try {
105
+ const result = await client.syncHiveSkills(request, jobId);
106
+ await logAuditEvent({ action: 'skill.push', actorId: 'service:hiveskills:' + registry.registryId, targetType: 'node', targetId: node.id, details: { jobId, target: body.target, publicationSha256: body.publicationSha256, approvedBy: body.approvedBy, commandSha256: result.evidence.commandSha256 } });
107
+ return c.json({ jobId, status: result.response.success ? 'completed' : 'failed', result: result.response.success ? 'Exact approved publication synchronized' : 'Node completed with a bounded failure' });
108
+ }
109
+ catch {
110
+ const row = await getDb().remoteCommand.findUnique({ where: { id: jobId } });
111
+ if (!row)
112
+ return c.json({ jobId, status: 'not_admitted', result: 'No operation was admitted; resolve target readiness and prepare a new preview' });
113
+ return c.json(await observed(registry, body.target, c.req.param('id')));
114
+ }
115
+ });
116
+ //# sourceMappingURL=hiveskills.js.map
@@ -1,3 +1,4 @@
1
+ import { clientErrorMessage } from '../utils/safe-errors.js';
1
2
  import { Hono } from 'hono';
2
3
  import { getDb } from '../db/index.js';
3
4
  import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
@@ -373,7 +374,7 @@ libraryRouter.post('/deploy', requireRole('ADMIN', 'OPERATOR'), async (c) => {
373
374
  nodeId: node.id,
374
375
  nodeName: node.name,
375
376
  status: 'failed',
376
- error: err instanceof Error ? err.message : 'Unknown error',
377
+ error: clientErrorMessage(err, 'Unknown error'),
377
378
  };
378
379
  }
379
380
  }));
@@ -383,7 +384,7 @@ libraryRouter.post('/deploy', requireRole('ADMIN', 'OPERATOR'), async (c) => {
383
384
  nodeId: 'unknown',
384
385
  nodeName: 'unknown',
385
386
  status: 'failed',
386
- error: r.reason instanceof Error ? r.reason.message : 'Unknown error',
387
+ error: clientErrorMessage(r.reason, 'Unknown error'),
387
388
  });
388
389
  await logAuditEvent({
389
390
  action: 'library.deploy',
@@ -1,6 +1,7 @@
1
1
  import { Hono } from 'hono';
2
2
  import { createHmac, timingSafeEqual } from 'node:crypto';
3
3
  import { streamSSE } from 'hono/streaming';
4
+ import { watchOathweaveStream } from '../middleware/oathweave.js';
4
5
  import { getDb } from '../db/index.js';
5
6
  import { authMiddleware } from '../middleware/auth.js';
6
7
  import { sanitizeDetails } from '../services/audit-service.js';
@@ -157,9 +158,13 @@ logsRouter.get('/stream', (c) => {
157
158
  id: String(eventId++),
158
159
  });
159
160
  });
161
+ const stopWatching = watchOathweaveStream(c.req.header('Authorization'), () => {
162
+ void stream.close();
163
+ });
160
164
  // Wait until the client disconnects
161
165
  stream.onAbort(() => {
162
166
  clearInterval(heartbeatTimer);
167
+ stopWatching();
163
168
  unsubscribe();
164
169
  });
165
170
  // Keep the stream open — the onAbort callback will clean up
@@ -3,6 +3,7 @@ import { getDb } from '../db/index.js';
3
3
  import { getRedis } from '../queue/redis.js';
4
4
  import { getHeartbeatStreamStats } from '../services/heartbeat-stream.js';
5
5
  import { safeEqual } from '../utils/safe-equal.js';
6
+ import { SENTINEL_NODE_IDS } from '../utils/sentinel-nodes.js';
6
7
  export const metricsRouter = new Hono();
7
8
  // ── In-process counters ──
8
9
  const counters = {
@@ -115,10 +116,10 @@ metricsRouter.get('/', async (c) => {
115
116
  try {
116
117
  const db = getDb();
117
118
  const [nodeCount, onlineNodes, pendingCommands, staleNodes, versions, oldestQueued] = await Promise.all([
118
- db.node.count(),
119
- db.node.count({ where: { status: 'ONLINE' } }),
119
+ db.node.count({ where: { id: { notIn: SENTINEL_NODE_IDS } } }),
120
+ db.node.count({ where: { id: { notIn: SENTINEL_NODE_IDS }, status: 'ONLINE' } }),
120
121
  db.remoteCommand.count({ where: { status: { in: ['PENDING', 'QUEUED'] } } }),
121
- db.node.count({ where: { lastHeartbeatAt: { lt: new Date(Date.now() - 120_000) } } }),
122
+ db.node.count({ where: { id: { notIn: SENTINEL_NODE_IDS }, lastHeartbeatAt: { lt: new Date(Date.now() - 120_000) } } }),
122
123
  db.agent.groupBy({ by: ['version'], _count: true }),
123
124
  db.remoteCommand.findFirst({ where: { status: { in: ['PENDING', 'QUEUED'] } }, orderBy: { createdAt: 'asc' }, select: { createdAt: true } }),
124
125
  ]);