@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
@@ -1,7 +1,8 @@
1
1
  import { Hono } from 'hono';
2
+ import { nodeForRole } from '../services/secret-views.js';
2
3
  import { nanoid } from 'nanoid';
3
4
  import { AGENT_VERSION } from '../config/index.js';
4
- import { getDb } from '../db/index.js';
5
+ import { getDb, Prisma } from '../db/index.js';
5
6
  import { promises as dns } from 'node:dns';
6
7
  import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
7
8
  import { logAuditEvent } from '../services/audit-service.js';
@@ -79,6 +80,36 @@ async function tryResolveHostnameToIp(hostname) {
79
80
  return null;
80
81
  }
81
82
  }
83
+ /**
84
+ * The latest snapshot of each node, in one statement, for what a list row shows. An
85
+ * indexed lookup per node avoids hydrating retained history, which a Prisma relation
86
+ * with `take: 1` would load. A node with no snapshot is simply absent.
87
+ */
88
+ async function latestHealthSummaries(nodeIds) {
89
+ if (nodeIds.length === 0)
90
+ return new Map();
91
+ const rows = await getDb().$queryRaw `
92
+ SELECT snapshot."nodeId", snapshot."timestamp", snapshot."cpuUsagePercent", snapshot."cpuCores",
93
+ snapshot."memoryTotalBytes", snapshot."memoryUsedBytes", snapshot."memoryPercent",
94
+ snapshot."swapTotalBytes", snapshot."swapUsedBytes", snapshot."disks"
95
+ FROM "nodes" AS node
96
+ INNER JOIN "health_snapshots" AS snapshot ON snapshot."id" = (
97
+ SELECT candidate."id"
98
+ FROM "health_snapshots" AS candidate
99
+ WHERE candidate."nodeId" = node."id"
100
+ ORDER BY candidate."timestamp" DESC
101
+ LIMIT 1
102
+ )
103
+ WHERE node."id" IN (${Prisma.join(nodeIds)})
104
+ `;
105
+ return new Map(rows.map(({ nodeId, memoryTotalBytes, memoryUsedBytes, swapTotalBytes, swapUsedBytes, ...row }) => [nodeId, {
106
+ ...row,
107
+ memoryTotalBytes: Number(memoryTotalBytes),
108
+ memoryUsedBytes: Number(memoryUsedBytes),
109
+ swapTotalBytes: Number(swapTotalBytes),
110
+ swapUsedBytes: Number(swapUsedBytes),
111
+ }]));
112
+ }
82
113
  export const nodesRouter = new Hono();
83
114
  // All routes require auth
84
115
  nodesRouter.use('*', authMiddleware);
@@ -116,11 +147,15 @@ nodesRouter.get('/', async (c) => {
116
147
  db.node.count({ where }),
117
148
  ]));
118
149
  // Strip token from API response — never expose node secrets to the frontend
119
- const safeNodes = nodes.map(({ token: _token, ...rest }) => rest);
120
- const readiness = await readinessOfNodes(safeNodes);
150
+ const viewer = getUser(c).role;
151
+ const safeNodes = nodes.map((node) => nodeForRole(node, viewer));
152
+ const [readiness, health] = await Promise.all([
153
+ readinessOfNodes(safeNodes),
154
+ withReadRetry('nodes list health', () => latestHealthSummaries(safeNodes.map((node) => node.id))),
155
+ ]);
121
156
  return c.json({
122
157
  success: true,
123
- data: safeNodes.map(node => ({ ...node, upgradeReadiness: readiness.get(node.id) ?? null })),
158
+ data: safeNodes.map(node => ({ ...node, upgradeReadiness: readiness.get(node.id) ?? null, health: health.get(node.id) ?? null })),
124
159
  meta: { total, page, pageSize, hasMore: page * pageSize < total },
125
160
  });
126
161
  });
@@ -495,7 +530,7 @@ nodesRouter.get('/:nodeId', async (c) => {
495
530
  });
496
531
  if (!node)
497
532
  throw new HttpError(404, 'NOT_FOUND', 'Node not found');
498
- const { token: _token, ...safeNode } = node;
533
+ const safeNode = nodeForRole(node, getUser(c).role);
499
534
  const readiness = await readinessOfNodes([safeNode]);
500
535
  return c.json({ success: true, data: { ...safeNode, upgradeReadiness: readiness.get(safeNode.id) ?? null } });
501
536
  });
@@ -535,6 +570,8 @@ nodesRouter.post('/register', requireRole('ADMIN', 'OPERATOR'), async (c) => {
535
570
  targetId: node.id,
536
571
  details: { name: body.name, hostname: body.hostname },
537
572
  });
573
+ // The only response that ever carries this token: no cache may keep it.
574
+ c.header('Cache-Control', 'no-store');
538
575
  return c.json({
539
576
  success: true,
540
577
  data: {
@@ -570,7 +607,7 @@ nodesRouter.patch('/:nodeId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
570
607
  throw new HttpError(404, 'NOT_FOUND', 'Node not found');
571
608
  throw err;
572
609
  });
573
- const { token: _token, ...safeNode } = node;
610
+ const safeNode = nodeForRole(node, getUser(c).role);
574
611
  await logAuditEvent({
575
612
  action: 'node.update',
576
613
  actorId: user.userId,
@@ -621,7 +658,7 @@ nodesRouter.post('/:nodeId/repair-address', requireRole('ADMIN', 'OPERATOR'), as
621
658
  const ipChanged = resolvedIp !== node.ipAddress;
622
659
  const hostnameChanged = targetHostname !== node.hostname;
623
660
  if (!ipChanged && !hostnameChanged) {
624
- const { token: _token, ...safeNode } = node;
661
+ const safeNode = nodeForRole(node, getUser(c).role);
625
662
  return c.json({
626
663
  success: true,
627
664
  data: {
@@ -651,7 +688,7 @@ nodesRouter.post('/:nodeId/repair-address', requireRole('ADMIN', 'OPERATOR'), as
651
688
  source,
652
689
  },
653
690
  });
654
- const { token: _token, ...safeNode } = updated;
691
+ const safeNode = nodeForRole(updated, getUser(c).role);
655
692
  return c.json({
656
693
  success: true,
657
694
  data: {
@@ -2,6 +2,8 @@ import { applicationProbeContractDigest, getApplicationProbeCollectorStatus } fr
2
2
  import { lessonSchema, publishLesson, lessonSuggestions, supersedeLesson } from '../services/incident-lessons.js';
3
3
  import { exportIncidentEvidence } from '../services/incident-evidence.js';
4
4
  import { getNodeManagerSecret } from '../utils/env.js';
5
+ import { getNodeBaseUrl } from '../utils/node-url.js';
6
+ import { describeTransportFailure } from '../utils/safe-errors.js';
5
7
  import { Hono } from 'hono';
6
8
  import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
7
9
  import { HttpError } from '../middleware/error-handler.js';
@@ -20,7 +22,8 @@ import { loadRecoveryExerciseAssessmentEvidence } from '../services/recovery-exe
20
22
  export const opsRouter = new Hono();
21
23
  opsRouter.use('*', authMiddleware);
22
24
  opsRouter.get('/dependency-health', async (c) => c.json({ success: true, data: await getDependencyHealth() }));
23
- opsRouter.get('/readiness', async (c) => c.json({ success: true, data: await getFleetReadiness() }));
25
+ opsRouter.get('/readiness', async (c) => c.json({ success: true,
26
+ data: await getFleetReadiness({ includeSecretFingerprints: getUser(c).role === 'ADMIN' }) }));
24
27
  opsRouter.get('/timeline', async (c) => {
25
28
  const limit = Math.min(Number(c.req.query('limit') ?? '80'), 200);
26
29
  return c.json({ success: true, data: await getFleetTimeline(limit) });
@@ -212,15 +215,30 @@ opsRouter.post('/maintenance/:id/cancel', requireRole('ADMIN', 'OPERATOR'), asyn
212
215
  });
213
216
  opsRouter.get('/incidents/:id/evidence', requireRole('ADMIN', 'OPERATOR'), async (c) => c.json({ success: true, data: await exportIncidentEvidence(c.req.param('id')) }));
214
217
  opsRouter.get('/storage-evidence/:nodeId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
215
- const node = await getDb().node.findUnique({ where: { id: c.req.param('nodeId') }, select: { ipAddress: true, port: true } });
218
+ const nodeId = c.req.param('nodeId');
219
+ const node = await getDb().node.findUnique({ where: { id: nodeId }, select: { name: true, ipAddress: true, port: true } });
216
220
  const mount = c.req.query('mount');
217
221
  if (!node || !mount || mount.length > 4096)
218
222
  return c.json({ success: false, error: { message: 'Valid node and mount required' } }, 400);
219
- const host = node.ipAddress.includes(':') ? `[${node.ipAddress}]` : node.ipAddress;
220
- const response = await fetch(`https://${host}:${node.port}/api/v1/storage-evidence?${new URLSearchParams({ mount })}`, { headers: { 'x-node-token': getNodeManagerSecret() }, signal: AbortSignal.timeout(15000), redirect: 'error' });
223
+ const audit = (success) => logAuditEvent({ action: 'storage.investigate', actorId: getUser(c).userId, targetType: 'node', targetId: nodeId, details: { operation: 'storage-evidence', readOnly: true, success } });
224
+ let response;
225
+ try {
226
+ response = await fetch(`${getNodeBaseUrl(node.ipAddress, node.port)}/api/v1/storage-evidence?${new URLSearchParams({ mount })}`, { headers: { 'x-node-token': getNodeManagerSecret() }, signal: AbortSignal.timeout(15000), redirect: 'error' });
227
+ }
228
+ catch (error) {
229
+ await audit(false);
230
+ throw new HttpError(502, 'NODE_UNREACHABLE', `Could not reach the agent on ${node.name}: ${describeTransportFailure(error)}. Check that the agent is running and that this server can reach its port.`);
231
+ }
221
232
  const { boundedJson } = await import('../services/application-probes.js');
222
- const body = await boundedJson(response);
223
- await logAuditEvent({ action: 'storage.investigate', actorId: getUser(c).userId, targetType: 'node', targetId: c.req.param('nodeId'), details: { operation: 'storage-evidence', readOnly: true, success: response.ok } });
233
+ let body;
234
+ try {
235
+ body = await boundedJson(response);
236
+ }
237
+ catch {
238
+ await audit(false);
239
+ throw new HttpError(502, 'NODE_BAD_RESPONSE', `The agent on ${node.name} answered HTTP ${response.status} with no readable storage report. Check that it is running a version that supports storage evidence.`);
240
+ }
241
+ await audit(response.ok);
224
242
  return c.json(body, response.ok ? 200 : 502);
225
243
  });
226
244
  opsRouter.get('/lessons', requireRole('ADMIN', 'OPERATOR'), async (c) => c.json({ success: true, data: await lessonSuggestions((c.req.query('query') ?? '').slice(0, 500)) }));
@@ -3,6 +3,7 @@ import { authMiddleware, requireRole } from '../middleware/auth.js';
3
3
  import { getDb } from '../db/index.js';
4
4
  import { HttpError } from '../middleware/error-handler.js';
5
5
  import { getNodeManagerSecret } from '../utils/env.js';
6
+ import { SENTINEL_NODE_IDS } from '../utils/sentinel-nodes.js';
6
7
  export const profilingRouter = new Hono();
7
8
  profilingRouter.use('*', authMiddleware);
8
9
  /**
@@ -49,7 +50,7 @@ profilingRouter.get('/:nodeId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
49
50
  profilingRouter.get('/', requireRole('ADMIN', 'OPERATOR'), async (c) => {
50
51
  const db = getDb();
51
52
  const nodes = await db.node.findMany({
52
- where: { status: { notIn: ['DECOMMISSIONED', 'OFFLINE'] } },
53
+ where: { id: { notIn: SENTINEL_NODE_IDS }, status: { notIn: ['DECOMMISSIONED', 'OFFLINE'] } },
53
54
  select: { id: true, name: true, ipAddress: true, port: true },
54
55
  });
55
56
  const results = await Promise.allSettled(nodes.map(async (node) => {
@@ -1,4 +1,5 @@
1
1
  import { Hono } from 'hono';
2
+ import { mergeEnvironmentEdit } from '../services/secret-views.js';
2
3
  import { getDb } from '../db/index.js';
3
4
  import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
4
5
  import { logAuditEvent } from '../services/audit-service.js';
@@ -11,17 +12,23 @@ import { serializeStringArray, serializeRecord, parseStringArray, parseStringRec
11
12
  export const rolesRouter = new Hono();
12
13
  rolesRouter.use('*', authMiddleware);
13
14
  /** Transform a DB role record into a clean API response. */
14
- function formatRole(role) {
15
- return {
16
- ...role,
15
+ function formatRole(role, viewer) {
16
+ // Values in envConfig are written to hosts and may be secrets: below ADMIN a
17
+ // role shows its key names only.
18
+ const { envConfig: rawEnvConfig, ...rest } = role;
19
+ const envConfig = parseStringRecord(rawEnvConfig);
20
+ const formatted = {
21
+ ...rest,
17
22
  libraryFileIds: parseStringArray(role.libraryFileIds),
18
23
  skillNames: parseStringArray(role.skillNames),
19
24
  aptPackages: parseStringArray(role.aptPackages),
20
25
  npmPackages: parseStringArray(role.npmPackages),
21
- envConfig: parseStringRecord(role.envConfig),
26
+ ...(viewer === 'ADMIN' ? { envConfig } : {}),
27
+ envConfigKeys: Object.keys(envConfig).sort(),
22
28
  dockerVolumes: parseStringArray(role.dockerVolumes),
23
29
  firewallAllowRules: parseStringArray(role.firewallAllowRules),
24
30
  };
31
+ return formatted;
25
32
  }
26
33
  /** Transform a DB role application record. */
27
34
  function formatApplication(app) {
@@ -39,7 +46,8 @@ rolesRouter.get('/', async (c) => {
39
46
  _count: { select: { nodes: true } },
40
47
  },
41
48
  });
42
- const data = roles.map((r) => formatRole(r));
49
+ const viewer = getUser(c).role;
50
+ const data = roles.map((r) => formatRole(r, viewer));
43
51
  return c.json({ success: true, data });
44
52
  });
45
53
  // ─── Get single role ───
@@ -55,7 +63,7 @@ rolesRouter.get('/:roleId', async (c) => {
55
63
  });
56
64
  if (!role)
57
65
  throw new HttpError(404, 'NOT_FOUND', 'Role not found');
58
- return c.json({ success: true, data: formatRole(role) });
66
+ return c.json({ success: true, data: formatRole(role, getUser(c).role) });
59
67
  });
60
68
  // ─── Create role ───
61
69
  rolesRouter.post('/', requireRole('ADMIN', 'OPERATOR'), async (c) => {
@@ -105,7 +113,7 @@ rolesRouter.post('/', requireRole('ADMIN', 'OPERATOR'), async (c) => {
105
113
  targetId: role.id,
106
114
  details: { name: role.name, status: role.status },
107
115
  });
108
- return c.json({ success: true, data: formatRole(role) }, 201);
116
+ return c.json({ success: true, data: formatRole(role, user.role) }, 201);
109
117
  });
110
118
  // ─── Update role ───
111
119
  rolesRouter.patch('/:roleId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
@@ -131,7 +139,7 @@ rolesRouter.patch('/:roleId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
131
139
  ...(body.skillNames !== undefined && { skillNames: serializeStringArray(body.skillNames) }),
132
140
  ...(body.aptPackages !== undefined && { aptPackages: serializeStringArray(body.aptPackages) }),
133
141
  ...(body.npmPackages !== undefined && { npmPackages: serializeStringArray(body.npmPackages) }),
134
- ...(body.envConfig !== undefined && { envConfig: serializeRecord(body.envConfig) }),
142
+ ...(body.envConfig !== undefined && { envConfig: serializeRecord(user.role === 'ADMIN' ? body.envConfig : mergeEnvironmentEdit(existing.envConfig, body.envConfig)) }),
135
143
  ...(body.dockerEnabled !== undefined && { dockerEnabled: body.dockerEnabled }),
136
144
  ...(body.dockerNonRoot !== undefined && { dockerNonRoot: body.dockerNonRoot }),
137
145
  ...(body.dockerImage !== undefined && { dockerImage: body.dockerImage }),
@@ -149,7 +157,7 @@ rolesRouter.patch('/:roleId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
149
157
  targetId: roleId,
150
158
  details: { name: body.name, status: body.status },
151
159
  });
152
- return c.json({ success: true, data: formatRole(role) });
160
+ return c.json({ success: true, data: formatRole(role, user.role) });
153
161
  });
154
162
  // ─── Delete role ───
155
163
  rolesRouter.delete('/:roleId', requireRole('ADMIN'), async (c) => {
@@ -3,7 +3,10 @@ import { getDb } from '../db/index.js';
3
3
  import { authMiddleware } from '../middleware/auth.js';
4
4
  import { queryAuditEvents } from '../services/audit-service.js';
5
5
  import { getCodexFleetPosture } from '../services/codex-service.js';
6
+ import { SENTINEL_NODE_IDS } from '../utils/sentinel-nodes.js';
6
7
  export const statsRouter = new Hono();
8
+ // Sentinel records exist only to satisfy log foreign keys; they are not fleet members.
9
+ const realNode = { id: { notIn: SENTINEL_NODE_IDS } };
7
10
  statsRouter.use('*', authMiddleware);
8
11
  /**
9
12
  * GET /stats/overview
@@ -12,11 +15,11 @@ statsRouter.use('*', authMiddleware);
12
15
  statsRouter.get('/overview', async (c) => {
13
16
  const db = getDb();
14
17
  const [totalNodes, onlineNodeRecords, offlineNodes, degradedNodes, provisioningNodes, totalCommands, runningCommands, pendingCommands, totalCronJobs, activeCronJobs, recentCommands, auditResult,] = await Promise.all([
15
- db.node.count(),
16
- db.node.findMany({ where: { status: 'ONLINE' }, select: { id: true } }),
17
- db.node.count({ where: { status: 'OFFLINE' } }),
18
- db.node.count({ where: { status: 'DEGRADED' } }),
19
- db.node.count({ where: { status: 'PROVISIONING' } }),
18
+ db.node.count({ where: realNode }),
19
+ db.node.findMany({ where: { ...realNode, status: 'ONLINE' }, select: { id: true } }),
20
+ db.node.count({ where: { ...realNode, status: 'OFFLINE' } }),
21
+ db.node.count({ where: { ...realNode, status: 'DEGRADED' } }),
22
+ db.node.count({ where: { ...realNode, status: 'PROVISIONING' } }),
20
23
  db.remoteCommand.count(),
21
24
  db.remoteCommand.count({ where: { status: 'RUNNING' } }),
22
25
  db.remoteCommand.count({ where: { status: 'PENDING' } }),
@@ -3,13 +3,13 @@ import { authMiddleware, requireRole } from '../middleware/auth.js';
3
3
  export const terminalTicketRouter = new Hono();
4
4
  terminalTicketRouter.use('*', authMiddleware);
5
5
  terminalTicketRouter.use('*', requireRole('ADMIN', 'OPERATOR'));
6
- /** The password-backed Control API terminal is retired; use the dashboard's
7
- * dedicated restricted key and inventory-pinned terminal transport. */
6
+ /** The password-backed Control API terminal is retired. The web terminal lives in the separate
7
+ * fleet gateway, so this process never holds the restricted SSH key. */
8
8
  terminalTicketRouter.post('/ticket', (c) => c.json({
9
9
  success: false,
10
10
  error: {
11
11
  code: 'TERMINAL_RETIRED',
12
- message: 'Control API terminal tickets are retired; use the dashboard terminal.',
12
+ message: 'Control API terminal tickets are retired; the fleet gateway carries the web terminal.',
13
13
  },
14
14
  }, 410));
15
15
  //# sourceMappingURL=terminal-ticket.js.map
@@ -1,8 +1,8 @@
1
1
  import { WebSocketServer } from 'ws';
2
2
  /**
3
3
  * The former Control API terminal used retired reusable SSH passwords. Keep a
4
- * deterministic upgrade rejection so old clients fail closed while the
5
- * dashboard's separately scoped, host-key-pinned terminal remains available.
4
+ * deterministic upgrade rejection so old clients fail closed. The web terminal
5
+ * lives in the fleet gateway, so this process never holds the restricted SSH key.
6
6
  */
7
7
  export function setupTerminalWebSocket(server) {
8
8
  const wss = new WebSocketServer({ noServer: true });
@@ -3,7 +3,7 @@ import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
3
3
  import { logAuditEvent } from '../services/audit-service.js';
4
4
  import { HttpError } from '../middleware/error-handler.js';
5
5
  import { listWebhookEndpoints, getWebhookEndpoint, createWebhookEndpoint, updateWebhookEndpoint, deleteWebhookEndpoint, listDeliveries, testWebhookEndpoint, dispatchWebhook, redeliverWebhookDelivery, } from '../services/webhook-service.js';
6
- import { validateWebhookTarget } from '../services/webhook-security.js';
6
+ import { validateWebhookTarget, WebhookOriginNotAllowedError } from '../services/webhook-security.js';
7
7
  /** Audit only the target origin: webhook paths and queries often embed tokens. */
8
8
  function webhookOrigin(url) {
9
9
  try {
@@ -13,6 +13,17 @@ function webhookOrigin(url) {
13
13
  return 'invalid';
14
14
  }
15
15
  }
16
+ /** Refused targets stay indistinguishable, so this cannot probe DNS or internal addresses. Only the allowlist is explained. */
17
+ async function assertApprovedTarget(url) {
18
+ try {
19
+ await validateWebhookTarget(url);
20
+ }
21
+ catch (error) {
22
+ if (error instanceof WebhookOriginNotAllowedError)
23
+ throw new HttpError(400, 'VALIDATION_ERROR', error.message);
24
+ throw new HttpError(400, 'VALIDATION_ERROR', 'Webhook target is not an approved public HTTPS endpoint');
25
+ }
26
+ }
16
27
  export const webhooksRouter = new Hono();
17
28
  // All webhook routes require ADMIN role
18
29
  webhooksRouter.use('*', authMiddleware);
@@ -35,12 +46,7 @@ webhooksRouter.post('/', async (c) => {
35
46
  if (!body.name || !body.url) {
36
47
  throw new HttpError(400, 'VALIDATION_ERROR', 'name and url are required');
37
48
  }
38
- try {
39
- await validateWebhookTarget(body.url);
40
- }
41
- catch {
42
- throw new HttpError(400, 'VALIDATION_ERROR', 'Webhook target is not an approved public HTTPS endpoint');
43
- }
49
+ await assertApprovedTarget(body.url);
44
50
  const user = c.get('user');
45
51
  const endpoint = await createWebhookEndpoint({
46
52
  ...body,
@@ -61,14 +67,8 @@ webhooksRouter.patch('/:id', async (c) => {
61
67
  if (!existing)
62
68
  throw new HttpError(404, 'NOT_FOUND', 'Webhook endpoint not found');
63
69
  const body = await c.req.json();
64
- if (body.url !== undefined) {
65
- try {
66
- await validateWebhookTarget(body.url);
67
- }
68
- catch {
69
- throw new HttpError(400, 'VALIDATION_ERROR', 'Webhook target is not an approved public HTTPS endpoint');
70
- }
71
- }
70
+ if (body.url !== undefined)
71
+ await assertApprovedTarget(body.url);
72
72
  const endpoint = await updateWebhookEndpoint(id, body);
73
73
  // Record which fields changed, never secret or header values.
74
74
  await logAuditEvent({
@@ -1,4 +1,5 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
+ import { assertLiveExecution } from '../config/rehearsal.js';
2
3
  import { MANAGED_CLAIM_HEADER, credentialMutationIdentity, httpIdentity, httpReceiptBody, managedHttpRequestSchema } from '../security/managed-http.js';
3
4
  import { nativeCompletionDescriptor, nativeCompletionResourceKey } from '../security/native-completion-registry.js';
4
5
  import { COMMAND_COMPLETION_REGISTRY, COMMAND_COMPLETION_REGISTRY_SHA256, commandCompletionDescriptor } from '../security/command-completion-registry.js';
@@ -201,7 +202,7 @@ export class NodeApiClient {
201
202
  headers: {
202
203
  'x-node-token': options.nodeToken,
203
204
  },
204
- hooks: { afterResponse: [rejectUnprivilegedAgent] },
205
+ hooks: { beforeRequest: [() => assertLiveExecution()], afterResponse: [rejectUnprivilegedAgent] },
205
206
  });
206
207
  }
207
208
  /** All finite native mutations use the same central owner and node execution slot. */
@@ -302,6 +303,15 @@ export class NodeApiClient {
302
303
  return { response: result.response, evidence: result.evidence };
303
304
  }
304
305
  // ── Health ──
306
+ async hiveSkillsTargetPosture(target, registryId) {
307
+ return this.api.get('hiveskills/targets/' + encodeURIComponent(target), { searchParams: { registryId }, retry: 0 }).json();
308
+ }
309
+ async syncHiveSkills(body, commandId) {
310
+ const result = await this.mutateWithEvidence('POST', 'hiveskills/sync', body, 120000, commandId, undefined, true);
311
+ if (!result.evidence)
312
+ throw new Error('Skills execution requires an enrolled node and signed receipt');
313
+ return { response: result.response, evidence: result.evidence };
314
+ }
305
315
  async securityResponseCapable() {
306
316
  const reply = await this.api.get('ping', { timeout: 5000, retry: 0 }).json();
307
317
  return reply.success === true && reply.data?.features?.securityResponse === 1;
@@ -2,6 +2,8 @@ import { createHash } from 'node:crypto';
2
2
  export const NATIVE_COMPLETION_REGISTRY = {
3
3
  version: 1,
4
4
  descriptors: [
5
+ { id: 'hiveskills-sync', method: 'POST', pathMatcher: { kind: 'exact', path: '/hiveskills/sync' },
6
+ completionMode: 'buffered-synchronous-http', timeoutCeilingSeconds: 120, resourceClassifier: 'node-skill-catalog' },
5
7
  { id: 'security-response', method: 'POST', pathMatcher: { kind: 'exact', path: '/security-response' },
6
8
  completionMode: 'buffered-synchronous-http', timeoutCeilingSeconds: 120, resourceClassifier: 'node-filesystem' },
7
9
  { id: 'file-write', method: 'POST', pathMatcher: { kind: 'exact', path: '/files/write' },
@@ -52,7 +54,7 @@ export function knownNativeCompletionIdentity(version, sha256, descriptorId) {
52
54
  const descriptor = NATIVE_COMPLETION_REGISTRY.descriptors.find(item => item.id === descriptorId);
53
55
  if (!descriptor)
54
56
  return false;
55
- return sha256 === NATIVE_COMPLETION_REGISTRY_SHA256 || (descriptorId !== 'security-response'
57
+ return sha256 === NATIVE_COMPLETION_REGISTRY_SHA256 || (descriptorId !== 'hiveskills-sync' && sha256 === 'e1303b169387f536c257e686eb95a29c1ff9b09e26fc38ac0d71d6c0daf9eb55') || (descriptorId !== 'security-response' && descriptorId !== 'hiveskills-sync'
56
58
  && sha256 === 'b0f05a1eac40dba262ec5ff0cc0de8d2b7c9f119a6a2d7ed3a00d94dcb6b1d3d');
57
59
  }
58
60
  //# sourceMappingURL=native-completion-registry.js.map
package/dist/server.js CHANGED
@@ -17,6 +17,7 @@ import { nodesRouter } from './routes/nodes.js';
17
17
  import { commandsRouter } from './routes/commands.js';
18
18
  import { cronRouter } from './routes/cron.js';
19
19
  import { filesRouter } from './routes/files.js';
20
+ import { hiveskillsRouter } from './routes/hiveskills.js';
20
21
  import { skillsRouter } from './routes/skills.js';
21
22
  import { logsRouter } from './routes/logs.js';
22
23
  import { healthRouter } from './routes/health.js';
@@ -34,6 +35,7 @@ import { systemUpdatesRouter } from './routes/system-updates.js';
34
35
  import { agentUpgradesRouter } from './routes/agent-upgrades.js';
35
36
  import { scanRouter } from './routes/scan.js';
36
37
  import { terminalTicketRouter } from './routes/terminal-ticket.js';
38
+ import { fleetGatewayRouter } from './routes/fleet-gateway.js';
37
39
  import { usersRouter, authRouter } from './routes/users.js';
38
40
  import { docsRouter } from './routes/docs.js';
39
41
  import { webhooksRouter } from './routes/webhooks.js';
@@ -61,8 +63,11 @@ import { fleetChecksRouter } from './routes/fleet-checks.js';
61
63
  import { opsRouter } from './routes/ops.js';
62
64
  import { mcpRouter } from './routes/mcp.js';
63
65
  import { securityFusionRouter } from './routes/security-fusion.js';
66
+ import { consoleRouter } from './routes/console.js';
64
67
  import { resolveCorsConfiguration } from './config/cors.js';
68
+ import { rehearsalReadOnly } from './config/rehearsal.js';
65
69
  export const app = new Hono();
70
+ app.use('*', rehearsalReadOnly);
66
71
  // ── Global middleware ──
67
72
  app.use('*', logger());
68
73
  app.use('*', correlationId);
@@ -118,6 +123,7 @@ v1.route('/shared-resource-leases', sharedResourceLeasesRouter);
118
123
  v1.route('/cron', cronRouter);
119
124
  v1.route('/files', filesRouter);
120
125
  v1.route('/skills', skillsRouter);
126
+ v1.route('/hiveskills', hiveskillsRouter);
121
127
  v1.route('/logs', logsRouter);
122
128
  v1.route('/health', healthRouter);
123
129
  v1.route('/audit', auditRouter);
@@ -134,6 +140,7 @@ v1.route('/system/updates', systemUpdatesRouter);
134
140
  v1.route('/agents/upgrade', agentUpgradesRouter);
135
141
  v1.route('/scan', scanRouter);
136
142
  v1.route('/terminal', terminalTicketRouter);
143
+ v1.route('/fleet-gateway', fleetGatewayRouter);
137
144
  v1.route('/users', usersRouter);
138
145
  v1.route('/auth', authRouter);
139
146
  v1.route('/webhooks', webhooksRouter);
@@ -158,6 +165,7 @@ v1.route('/ai-schedules', aiScheduleRouter);
158
165
  v1.route('/profiling', profilingRouter);
159
166
  v1.route('/assistant', assistantRouter);
160
167
  v1.route('/events/stream', eventsStreamRouter);
168
+ v1.route('/console', consoleRouter);
161
169
  v1.route('/ops', opsRouter);
162
170
  v1.route('/mcp', mcpRouter);
163
171
  v1.route('/security-integration/response', cindertraceResponseRouter);
@@ -13,6 +13,7 @@ import { z } from 'zod';
13
13
  import { getDb } from '../db/index.js';
14
14
  import { executeAiTool, listAvailableTools } from './ai-tools.js';
15
15
  import { logAuditEvent } from './audit-service.js';
16
+ import { clientErrorMessage } from '../utils/safe-errors.js';
16
17
  import { autoStoreToolResult, listScratchpadEntries } from './ai-scratchpad-service.js';
17
18
  import { getLlmProvider, getLlmProviderStatuses } from './llm-providers/index.js';
18
19
  import { getOllamaThinkingCapability } from './llm-providers/ollama.js';
@@ -1625,12 +1626,37 @@ export async function chat(request) {
1625
1626
  };
1626
1627
  }
1627
1628
  // ── Conversation list / message queries ──
1629
+ /** The values of `createdBy` that mean this caller: conversations store the email, or the user ID without one. */
1630
+ export function conversationOwners(user) {
1631
+ return [...new Set([user?.email, user?.userId].filter((value) => Boolean(value)))];
1632
+ }
1633
+ /**
1634
+ * The fleet heartbeat conversation is the server's own and describes the fleet, not
1635
+ * anyone's work, and the anomaly page checks for it, so every role can read it.
1636
+ */
1637
+ const SHARED_CONVERSATION = { createdBy: 'system', type: 'heartbeat' };
1638
+ /** What a caller may read: their own conversations and the shared heartbeat. */
1639
+ function readableConversations(owners) {
1640
+ return { OR: [{ createdBy: { in: owners } }, SHARED_CONVERSATION] };
1641
+ }
1642
+ /** 'owner' may change a conversation, 'shared' may only read it, and null may not know it exists. */
1643
+ export async function getConversationAccess(conversationId, owners) {
1644
+ const conversation = await getDb().aiConversation.findUnique({
1645
+ where: { id: conversationId },
1646
+ select: { createdBy: true, type: true },
1647
+ });
1648
+ if (!conversation)
1649
+ return null;
1650
+ if (owners.includes(conversation.createdBy))
1651
+ return 'owner';
1652
+ return conversation.createdBy === SHARED_CONVERSATION.createdBy && conversation.type === SHARED_CONVERSATION.type ? 'shared' : null;
1653
+ }
1628
1654
  export async function listConversations(params) {
1629
1655
  const db = getDb();
1630
1656
  const page = params.page ?? 1;
1631
1657
  const pageSize = Math.min(params.pageSize ?? 20, 50);
1632
1658
  const skip = (page - 1) * pageSize;
1633
- const where = {};
1659
+ const where = readableConversations(params.owners);
1634
1660
  if (params.type)
1635
1661
  where['type'] = params.type;
1636
1662
  // 'all' is a sentinel meaning no status filter
@@ -1689,16 +1715,16 @@ export async function getConversationMessages(conversationId, params) {
1689
1715
  total,
1690
1716
  };
1691
1717
  }
1692
- export async function getUnreadAiMessageCount() {
1718
+ export async function getUnreadAiMessageCount(owners) {
1693
1719
  const db = getDb();
1694
1720
  return db.aiMessage.count({
1695
- where: { role: 'assistant', isRead: false },
1721
+ where: { role: 'assistant', isRead: false, conversation: readableConversations(owners) },
1696
1722
  });
1697
1723
  }
1698
- export async function markAiMessagesRead(messageIds) {
1724
+ export async function markAiMessagesRead(messageIds, owners) {
1699
1725
  const db = getDb();
1700
1726
  const result = await db.aiMessage.updateMany({
1701
- where: { id: { in: messageIds }, isRead: false },
1727
+ where: { id: { in: messageIds }, isRead: false, conversation: readableConversations(owners) },
1702
1728
  data: { isRead: true, readAt: new Date() },
1703
1729
  });
1704
1730
  return result.count;
@@ -2435,7 +2461,7 @@ export async function chatStream(request, onEvent) {
2435
2461
  catch (err) {
2436
2462
  onEvent({
2437
2463
  event: 'error',
2438
- data: { message: err instanceof Error ? err.message : 'Unknown streaming error' },
2464
+ data: { message: clientErrorMessage(err, 'Unknown streaming error') },
2439
2465
  });
2440
2466
  }
2441
2467
  }
@@ -15,11 +15,14 @@ import { logAuditEvent } from './audit-service.js';
15
15
  import { listSkills, getSkill, getSkillFile, createSkill } from './skill-service.js';
16
16
  import { NodeApiClient } from '../sdk/node-api-client.js';
17
17
  import { getNodeBaseUrl } from '../utils/node-url.js';
18
+ import { SENTINEL_NODE_IDS } from '../utils/sentinel-nodes.js';
19
+ import { clientErrorMessage } from '../utils/safe-errors.js';
18
20
  import { getNodeManagerSecret } from '../utils/env.js';
19
21
  import { correlateAnomalies, getAnomalyTimeline } from './ai-correlation-service.js';
20
22
  import { getHiveMemClient } from './hivemem-client.js';
21
23
  import { aliasScratchpadEntry, compareScratchpadSnapshots, listScratchpadEntries, setScratchpadEntry, transformScratchpadData, } from './ai-scratchpad-service.js';
22
24
  import { getDependencyHealth, getFleetReadiness, getFleetTimeline, getPredictiveMaintenance, getIncidentSummary, classifyRemediationPlan, getHermesEffectiveState, getPackageSecurityPosture, } from './ops-service.js';
25
+ import { nodeMetadataForRole } from './secret-views.js';
23
26
  import { getCodexFleetPosture } from './codex-service.js';
24
27
  const __dirname = dirname(fileURLToPath(import.meta.url));
25
28
  const WORKSPACE_ROOT = resolve(__dirname, '../..');
@@ -63,6 +66,7 @@ function parseWazuhPosture(value) {
63
66
  registerTool('getFleetStatus', async () => {
64
67
  const db = getDb();
65
68
  const nodes = await db.node.findMany({
69
+ where: { id: { notIn: SENTINEL_NODE_IDS } },
66
70
  orderBy: { name: 'asc' },
67
71
  select: {
68
72
  id: true,
@@ -168,6 +172,7 @@ registerTool('getNodeHealth', async (args) => {
168
172
  registerTool('getWazuhFleetStatus', async () => {
169
173
  const db = getDb();
170
174
  const nodes = await db.node.findMany({
175
+ where: { id: { notIn: SENTINEL_NODE_IDS } },
171
176
  orderBy: { name: 'asc' },
172
177
  select: {
173
178
  id: true,
@@ -206,6 +211,7 @@ registerTool('getWazuhFleetStatus', async () => {
206
211
  registerTool('getSystemUpdateFleetStatus', async () => {
207
212
  const db = getDb();
208
213
  const nodes = await db.node.findMany({
214
+ where: { id: { notIn: SENTINEL_NODE_IDS } },
209
215
  orderBy: { name: 'asc' },
210
216
  select: {
211
217
  id: true, name: true, hostname: true, status: true, lastHeartbeatAt: true,
@@ -569,7 +575,7 @@ registerTool('searchNodes', async (args) => {
569
575
  const status = args['status'] ? String(args['status']).toUpperCase() : undefined;
570
576
  if (!query && !status)
571
577
  return { error: 'query or status is required' };
572
- const where = {};
578
+ const where = { id: { notIn: SENTINEL_NODE_IDS } };
573
579
  if (status)
574
580
  where['status'] = status;
575
581
  if (query) {
@@ -648,10 +654,10 @@ registerTool('getSkills', async () => {
648
654
  registerTool('getDashboardStats', async () => {
649
655
  const db = getDb();
650
656
  const [totalNodes, onlineNodes, offlineNodes, degradedNodes, totalCommands, runningCommands, pendingCommands, totalCronJobs, activeCronJobs,] = await Promise.all([
651
- db.node.count(),
652
- db.node.count({ where: { status: 'ONLINE' } }),
653
- db.node.count({ where: { status: 'OFFLINE' } }),
654
- db.node.count({ where: { status: 'DEGRADED' } }),
657
+ db.node.count({ where: { id: { notIn: SENTINEL_NODE_IDS } } }),
658
+ db.node.count({ where: { id: { notIn: SENTINEL_NODE_IDS }, status: 'ONLINE' } }),
659
+ db.node.count({ where: { id: { notIn: SENTINEL_NODE_IDS }, status: 'OFFLINE' } }),
660
+ db.node.count({ where: { id: { notIn: SENTINEL_NODE_IDS }, status: 'DEGRADED' } }),
655
661
  db.remoteCommand.count(),
656
662
  db.remoteCommand.count({ where: { status: 'RUNNING' } }),
657
663
  db.remoteCommand.count({ where: { status: 'PENDING' } }),
@@ -729,7 +735,8 @@ registerTool('getNodeDetails', async (args) => {
729
735
  os: node.os,
730
736
  arch: node.arch,
731
737
  tags: node.tags ? JSON.parse(node.tags) : [],
732
- metadata: node.metadata ? JSON.parse(node.metadata) : {},
738
+ // Tool results reach a model provider and stored conversations whatever the caller's role.
739
+ metadata: node.metadata ? JSON.parse(nodeMetadataForRole(node.metadata, 'VIEWER')) : {},
733
740
  lastHeartbeatAt: node.lastHeartbeatAt?.toISOString() ?? null,
734
741
  createdAt: node.createdAt.toISOString(),
735
742
  updatedAt: node.updatedAt.toISOString(),
@@ -1182,7 +1189,7 @@ registerTool('executeNaturalCommand', async (args) => {
1182
1189
  else {
1183
1190
  // If no node specified, try to extract from intent text
1184
1191
  const nodes = await db.node.findMany({
1185
- where: { status: 'ONLINE' },
1192
+ where: { id: { notIn: SENTINEL_NODE_IDS }, status: 'ONLINE' },
1186
1193
  select: { id: true, name: true, hostname: true },
1187
1194
  });
1188
1195
  for (const n of nodes) {
@@ -1332,7 +1339,7 @@ export async function executeAiTool(call) {
1332
1339
  name: call.name,
1333
1340
  success: false,
1334
1341
  result: null,
1335
- error: err instanceof Error ? err.message : 'Tool execution failed',
1342
+ error: clientErrorMessage(err, 'Tool execution failed'),
1336
1343
  };
1337
1344
  }
1338
1345
  }