@hivemagnus/magnus-server 1.1.13 → 2.1.0

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 (89) hide show
  1. package/.env.example +37 -0
  2. package/README.md +36 -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 +16 -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 +32 -5
  12. package/dist/config/features.js +4 -1
  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 +17 -1
  17. package/dist/db/generated/prisma/internal/prismaNamespaceBrowser.js +17 -1
  18. package/dist/db/generated/prisma/models/CindertraceResponse.js +2 -0
  19. package/dist/db/generated/prisma/models/ProductInitialization.js +2 -0
  20. package/dist/db/read-retry.js +5 -1
  21. package/dist/db/seed.js +8 -0
  22. package/dist/index.js +28 -37
  23. package/dist/middleware/auth.js +21 -0
  24. package/dist/middleware/error-handler.js +10 -10
  25. package/dist/middleware/oathweave-verifier.js +23 -0
  26. package/dist/middleware/oathweave.js +115 -0
  27. package/dist/middleware/rate-limit.js +12 -3
  28. package/dist/openapi/spec.js +45 -4
  29. package/dist/queue/names.js +9 -0
  30. package/dist/queue/workers.js +9 -68
  31. package/dist/routes/assistant.js +30 -12
  32. package/dist/routes/cindertrace-response.js +183 -0
  33. package/dist/routes/console.js +33 -0
  34. package/dist/routes/cron.js +8 -5
  35. package/dist/routes/events-stream.js +10 -0
  36. package/dist/routes/files.js +2 -1
  37. package/dist/routes/fleet-gateway.js +69 -0
  38. package/dist/routes/heartbeat.js +1 -6
  39. package/dist/routes/hermes.js +26 -6
  40. package/dist/routes/hiveskills.js +116 -0
  41. package/dist/routes/library.js +3 -2
  42. package/dist/routes/logs.js +5 -0
  43. package/dist/routes/metrics.js +4 -3
  44. package/dist/routes/nodes.js +45 -8
  45. package/dist/routes/ops.js +24 -6
  46. package/dist/routes/profiling.js +2 -1
  47. package/dist/routes/roles.js +17 -9
  48. package/dist/routes/security-fusion.js +15 -600
  49. package/dist/routes/stats.js +8 -5
  50. package/dist/routes/terminal-ticket.js +3 -3
  51. package/dist/routes/terminal.js +2 -2
  52. package/dist/routes/webhooks.js +15 -15
  53. package/dist/sdk/node-api-client.js +23 -1
  54. package/dist/security/cindertrace-response.js +14 -0
  55. package/dist/security/native-completion-registry.js +14 -0
  56. package/dist/server.js +11 -14
  57. package/dist/services/ai-assistant-service.js +32 -6
  58. package/dist/services/ai-tools.js +15 -8
  59. package/dist/services/ardent-scope-enrollment.js +4 -2
  60. package/dist/services/command-resource-leases.js +2 -4
  61. package/dist/services/forecast-service.js +23 -8
  62. package/dist/services/ops-service.js +20 -25
  63. package/dist/services/prediction-service.js +2 -1
  64. package/dist/services/resource-enrollment.js +57 -4
  65. package/dist/services/secret-views.js +72 -0
  66. package/dist/services/security-export.js +4 -6
  67. package/dist/services/topology-service.js +2 -1
  68. package/dist/services/webhook-security.js +10 -1
  69. package/dist/services/webhook-service.js +14 -6
  70. package/dist/utils/node-url.js +2 -1
  71. package/dist/utils/safe-errors.js +130 -0
  72. package/node_modules/@hivemagnus/magnus-oathweave/LICENSE +202 -0
  73. package/node_modules/@hivemagnus/magnus-oathweave/README.md +20 -0
  74. package/node_modules/@hivemagnus/magnus-oathweave/dist/index.d.ts +61 -0
  75. package/node_modules/@hivemagnus/magnus-oathweave/dist/index.js +148 -0
  76. package/node_modules/@hivemagnus/magnus-oathweave/package.json +54 -0
  77. package/package.json +7 -3
  78. package/prisma/migrations/20261103000000_cindertrace_response_bridge/migration-impact.json +128 -0
  79. package/prisma/migrations/20261103000000_cindertrace_response_bridge/migration.sql +6 -0
  80. package/prisma/migrations/20261105010000_base_initialization/magnus-impact.json +1 -0
  81. package/prisma/migrations/20261105010000_base_initialization/migration.sql +5 -0
  82. package/prisma/schema.prisma +22 -0
  83. package/dist/services/security-fusion-actions.js +0 -22
  84. package/dist/services/security-fusion-evidence-integrity.js +0 -144
  85. package/dist/services/security-fusion-ingest.js +0 -101
  86. package/dist/services/security-fusion-outbox.js +0 -185
  87. package/dist/services/security-fusion-receipt-chain.js +0 -134
  88. package/dist/services/security-fusion-service.js +0 -751
  89. package/dist/services/security-fusion-verification.js +0 -50
@@ -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,27 @@ 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
+ }
315
+ async securityResponseCapable() {
316
+ const reply = await this.api.get('ping', { timeout: 5000, retry: 0 }).json();
317
+ return reply.success === true && reply.data?.features?.securityResponse === 1;
318
+ }
319
+ async securityResponse(body, commandId) {
320
+ if (!await this.securityResponseCapable())
321
+ throw new HttpError(409, 'RESPONSE_DISABLED', 'Target has not enabled its managed response adapter');
322
+ const result = await this.mutateWithEvidence('POST', 'security-response', body, 120000, commandId, undefined, true);
323
+ if (!result.evidence)
324
+ throw new Error('Response requires a signed managed receipt');
325
+ return { response: result.response, evidence: result.evidence };
326
+ }
305
327
  async collectHealth() {
306
328
  return this.api.get('health/collect').json();
307
329
  }
@@ -0,0 +1,14 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ export function bridgeSignature(secret, method, path, timestamp, nonce, body) {
3
+ if (secret.length < 32)
4
+ throw new Error('Dedicated bridge secret is required');
5
+ return createHmac('sha256', secret)
6
+ .update(`cindertrace.magnus-response.v1\n${method}\n${path}\n${timestamp}\n${nonce}\n${body}`)
7
+ .digest('hex');
8
+ }
9
+ export function equalSignature(a, b) {
10
+ return (/^[a-f0-9]{64}$/.test(a) &&
11
+ /^[a-f0-9]{64}$/.test(b) &&
12
+ timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex')));
13
+ }
14
+ //# sourceMappingURL=cindertrace-response.js.map
@@ -2,6 +2,10 @@ 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' },
7
+ { id: 'security-response', method: 'POST', pathMatcher: { kind: 'exact', path: '/security-response' },
8
+ completionMode: 'buffered-synchronous-http', timeoutCeilingSeconds: 120, resourceClassifier: 'node-filesystem' },
5
9
  { id: 'file-write', method: 'POST', pathMatcher: { kind: 'exact', path: '/files/write' },
6
10
  completionMode: 'buffered-synchronous-http', timeoutCeilingSeconds: 3600, resourceClassifier: 'node-filesystem' },
7
11
  { id: 'skill-install', method: 'POST', pathMatcher: { kind: 'exact', path: '/skills/install' },
@@ -43,4 +47,14 @@ export function nativeCompletionResourceKey(descriptor, nodeId) {
43
47
  throw new Error('Native completion resource cannot be classified');
44
48
  return descriptor.resourceClassifier === 'node-ardent' ? `node:${nodeId}:ardent` : `node:${nodeId}`;
45
49
  }
50
+ /** Preserve settlement of pre-extraction leases without granting the new response boundary retroactively. */
51
+ export function knownNativeCompletionIdentity(version, sha256, descriptorId) {
52
+ if (version !== 1 || !descriptorId)
53
+ return false;
54
+ const descriptor = NATIVE_COMPLETION_REGISTRY.descriptors.find(item => item.id === descriptorId);
55
+ if (!descriptor)
56
+ return false;
57
+ return sha256 === NATIVE_COMPLETION_REGISTRY_SHA256 || (descriptorId !== 'hiveskills-sync' && sha256 === 'e1303b169387f536c257e686eb95a29c1ff9b09e26fc38ac0d71d6c0daf9eb55') || (descriptorId !== 'security-response' && descriptorId !== 'hiveskills-sync'
58
+ && sha256 === 'b0f05a1eac40dba262ec5ff0cc0de8d2b7c9f119a6a2d7ed3a00d94dcb6b1d3d');
59
+ }
46
60
  //# sourceMappingURL=native-completion-registry.js.map
package/dist/server.js CHANGED
@@ -1,8 +1,8 @@
1
+ import { cindertraceResponseRouter } from './routes/cindertrace-response.js';
1
2
  import { resourceLeasesRouter } from './routes/resource-leases.js';
2
3
  import { sharedResourceLeasesRouter } from './routes/shared-resource-leases.js';
3
4
  import { securityBackupRouter } from './routes/security-backup.js';
4
5
  import { securityIntegrationRouter } from './routes/security-integration.js';
5
- import { securityConsoleUrl, securityFusionMode } from './services/security-export.js';
6
6
  import { Hono } from 'hono';
7
7
  import { cors } from 'hono/cors';
8
8
  import { logger } from 'hono/logger';
@@ -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);
@@ -85,6 +90,7 @@ app.use('/api/v1/*', requestLogger);
85
90
  app.use('/api/v1/security-fusion/ingest/events', bodyLimitMiddleware(Math.min(Math.max(Number(process.env['SECURITY_FUSION_INGEST_MAX_BYTES']) || 262_144, 1), 262_144)));
86
91
  app.use('/api/v1/security-fusion/ingest/source-status', bodyLimitMiddleware(32_768));
87
92
  app.route('/api/v1/security-integration/backups', securityBackupRouter);
93
+ app.use('/api/v1/security-integration/response/*', bodyLimitMiddleware(16_384));
88
94
  app.use('/api/v1/*', async (c, next) => {
89
95
  const limit = c.req.path === '/api/v1/hermes-registry/capture' || c.req.path === '/api/v1/hermes-registry/revisions'
90
96
  ? 4_500_000 : Number(process.env['API_BODY_LIMIT_BYTES'] ?? 1_048_576);
@@ -117,6 +123,7 @@ v1.route('/shared-resource-leases', sharedResourceLeasesRouter);
117
123
  v1.route('/cron', cronRouter);
118
124
  v1.route('/files', filesRouter);
119
125
  v1.route('/skills', skillsRouter);
126
+ v1.route('/hiveskills', hiveskillsRouter);
120
127
  v1.route('/logs', logsRouter);
121
128
  v1.route('/health', healthRouter);
122
129
  v1.route('/audit', auditRouter);
@@ -133,6 +140,7 @@ v1.route('/system/updates', systemUpdatesRouter);
133
140
  v1.route('/agents/upgrade', agentUpgradesRouter);
134
141
  v1.route('/scan', scanRouter);
135
142
  v1.route('/terminal', terminalTicketRouter);
143
+ v1.route('/fleet-gateway', fleetGatewayRouter);
136
144
  v1.route('/users', usersRouter);
137
145
  v1.route('/auth', authRouter);
138
146
  v1.route('/webhooks', webhooksRouter);
@@ -157,22 +165,11 @@ v1.route('/ai-schedules', aiScheduleRouter);
157
165
  v1.route('/profiling', profilingRouter);
158
166
  v1.route('/assistant', assistantRouter);
159
167
  v1.route('/events/stream', eventsStreamRouter);
168
+ v1.route('/console', consoleRouter);
160
169
  v1.route('/ops', opsRouter);
161
170
  v1.route('/mcp', mcpRouter);
171
+ v1.route('/security-integration/response', cindertraceResponseRouter);
162
172
  v1.route('/security-integration', securityIntegrationRouter);
163
- v1.use('/security-fusion/*', async (c, next) => {
164
- const mode = securityFusionMode();
165
- if (mode === 'remote') {
166
- const url = securityConsoleUrl();
167
- return c.json({ success: false, error: { code: 'SECURITY_FUSION_MOVED',
168
- message: 'Security Fusion is served by the external security console', ...(url ? { url } : {}) } }, 410);
169
- }
170
- if (mode === 'off') {
171
- return c.json({ success: false, error: { code: 'SECURITY_FUSION_DISABLED',
172
- message: 'Security Fusion is disabled; set SECURITY_FUSION_MODE=local or remote to enable it' } }, 404);
173
- }
174
- await next();
175
- });
176
173
  v1.route('/security-fusion', securityFusionRouter);
177
174
  app.route('/api/v1', v1);
178
175
  // ── API documentation (Swagger UI + OpenAPI spec) ──
@@ -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
  }
@@ -3,7 +3,7 @@ import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
3
3
  import { z } from 'zod';
4
4
  import { getDb } from '../db/index.js';
5
5
  import { HttpError } from '../middleware/error-handler.js';
6
- import { apiDrainAttestationSchema, apiDrainAttestationSigningPayload, canonical, dashboardEnrollmentSecret, drainReceiptSchema, enrollmentSigningPayload } from './resource-enrollment.js';
6
+ import { apiDrainAttestationSchema, apiDrainAttestationSigningPayload, canonical, assertDashboardStillAbsent, dashboardReceiptKey, drainReceiptSchema, enrollmentSigningPayload } from './resource-enrollment.js';
7
7
  import { lockResourceNode, resourceNodeIdentity } from './resource-node-lock.js';
8
8
  import { reconciliationSecret } from './resource-participant-work.js';
9
9
  import { RESULT_SIGNING_SECRET } from '../utils/env.js';
@@ -52,8 +52,10 @@ async function receiptHashes(tx, nodeId, nodeFqdn, epoch, barrierId) {
52
52
  fail();
53
53
  }
54
54
  const { signature, ...unsigned } = receipt;
55
+ if (participant === 'dashboard')
56
+ await assertDashboardStillAbsent(tx, nodeId);
55
57
  const key = participant === 'api' ? reconciliationSecret()
56
- : participant === 'dashboard' ? dashboardEnrollmentSecret() : RESULT_SIGNING_SECRET;
58
+ : participant === 'dashboard' ? dashboardReceiptKey() : RESULT_SIGNING_SECRET;
57
59
  const payload = participant === 'api'
58
60
  ? apiDrainAttestationSigningPayload(unsigned)
59
61
  : enrollmentSigningPayload(unsigned);
@@ -17,7 +17,7 @@ import { RESULT_SIGNING_SECRET, RESULT_SIGNING_PREVIOUS_SECRET } from '../utils/
17
17
  import { getNodeBaseUrl } from '../utils/node-url.js';
18
18
  import { COMMAND_COMPLETION_REGISTRY, COMMAND_COMPLETION_REGISTRY_SHA256, commandCompletionDescriptor } from '../security/command-completion-registry.js';
19
19
  import { MANAGED_HTTP_COMMAND } from '../security/managed-http.js';
20
- import { NATIVE_COMPLETION_REGISTRY, NATIVE_COMPLETION_REGISTRY_SHA256, nativeCompletionDescriptor, nativeCompletionResourceKey } from '../security/native-completion-registry.js';
20
+ import { NATIVE_COMPLETION_REGISTRY, NATIVE_COMPLETION_REGISTRY_SHA256, nativeCompletionDescriptor, nativeCompletionResourceKey, knownNativeCompletionIdentity } from '../security/native-completion-registry.js';
21
21
  import { logAuditEvent } from './audit-service.js';
22
22
  const HELD = ['reserved', 'running', 'unknown'];
23
23
  export class ResourceLeaseConflict extends HttpError {
@@ -382,9 +382,7 @@ export async function settleResourceResult(commandId, result) {
382
382
  && row.completionRegistrySha256 === COMMAND_COMPLETION_REGISTRY_SHA256
383
383
  && COMMAND_COMPLETION_REGISTRY.descriptors.some(descriptor => descriptor.id === row.completionDescriptorId);
384
384
  const native = row.completionClass === 'native-http'
385
- && row.completionRegistryVersion === NATIVE_COMPLETION_REGISTRY.version
386
- && row.completionRegistrySha256 === NATIVE_COMPLETION_REGISTRY_SHA256
387
- && NATIVE_COMPLETION_REGISTRY.descriptors.some(descriptor => descriptor.id === row.completionDescriptorId);
385
+ && knownNativeCompletionIdentity(row.completionRegistryVersion, row.completionRegistrySha256, row.completionDescriptorId);
388
386
  const scheduler = row.completionClass === 'managed-scheduler'
389
387
  && /^[a-f0-9]{64}$/.test(row.completionOccurrenceId ?? '')
390
388
  && /^[a-f0-9]{64}$/.test(row.completionSpecSha256 ?? '')
@@ -1,4 +1,5 @@
1
1
  import { getDb } from '../db/index.js';
2
+ import { SENTINEL_NODE_IDS } from '../utils/sentinel-nodes.js';
2
3
  function linearRegression(points) {
3
4
  const n = points.length;
4
5
  if (n < 2)
@@ -10,13 +11,17 @@ function linearRegression(points) {
10
11
  const denom = n * sumX2 - sumX ** 2;
11
12
  if (denom === 0)
12
13
  return { slope: 0, intercept: sumY / n, r2: 0 };
13
- const slope = (n * sumXY - sumX * sumY) / denom;
14
- const intercept = (sumY - slope * sumX) / n;
15
14
  // R² for confidence
16
15
  const yMean = sumY / n;
17
16
  const ssTot = points.reduce((a, p) => a + (p.y - yMean) ** 2, 0);
17
+ // Identical samples have no trend. The formula below would still answer with a
18
+ // rounding residue, which reads as a tiny growth rate at full confidence.
18
19
  if (ssTot === 0)
19
- return { slope, intercept, r2: 1 };
20
+ return { slope: 0, intercept: yMean, r2: 1 };
21
+ const slope = (n * sumXY - sumX * sumY) / denom;
22
+ const intercept = (sumY - slope * sumX) / n;
23
+ if (!Number.isFinite(slope))
24
+ return { slope: 0, intercept: yMean, r2: 0 };
20
25
  const ssRes = points.reduce((a, p) => a + (p.y - (slope * p.x + intercept)) ** 2, 0);
21
26
  const r2 = 1 - ssRes / ssTot;
22
27
  return { slope, intercept, r2 };
@@ -26,6 +31,10 @@ const MS_PER_DAY = 86_400_000;
26
31
  const MIN_DATA_POINTS = 3;
27
32
  const MIN_FORECAST_CONFIDENCE = 0.25;
28
33
  const DEFAULT_LOOKBACK_DAYS = 30;
34
+ /** Samples closer together than this are a burst, not a growth trend. */
35
+ const MIN_TREND_SPAN_MS = 60 * 60 * 1000;
36
+ /** A fill date further out than this says nothing useful, and far enough out it is not a valid Date at all. */
37
+ const MAX_FORECAST_DAYS = 36_500;
29
38
  const SYSTEM_MOUNT_PREFIXES = ['/boot', '/snap', '/var/lib/docker/', '/var/lib/containers/'];
30
39
  const READ_ONLY_FILESYSTEMS = new Set(['iso9660', 'squashfs']);
31
40
  function isForecastableVolume(disk) {
@@ -103,15 +112,20 @@ export async function forecastDisk(nodeId, lookbackDays = DEFAULT_LOOKBACK_DAYS)
103
112
  x: (s.timestamp - t0) / MS_PER_DAY,
104
113
  y: s.usedBytes,
105
114
  }));
106
- const reg = linearRegression(points);
115
+ const reg = latest.timestamp - t0 >= MIN_TREND_SPAN_MS
116
+ ? linearRegression(points)
117
+ : { slope: 0, intercept: latest.usedBytes, r2: 0 };
107
118
  const growthRatePerDay = reg.slope; // bytes per day
108
119
  let estimatedFullDate = null;
109
120
  let daysUntilFull = null;
110
121
  if (growthRatePerDay > 0 && reg.r2 >= MIN_FORECAST_CONFIDENCE) {
111
- const availableBytes = totalBytes - latest.usedBytes;
112
- daysUntilFull = Math.ceil(availableBytes / growthRatePerDay);
113
- const fullDate = new Date(Date.now() + daysUntilFull * MS_PER_DAY);
114
- estimatedFullDate = fullDate.toISOString().split('T')[0] ?? null;
122
+ const availableBytes = Math.max(0, totalBytes - latest.usedBytes);
123
+ const days = Math.ceil(availableBytes / growthRatePerDay);
124
+ if (days <= MAX_FORECAST_DAYS) {
125
+ daysUntilFull = days;
126
+ const fullDate = new Date(Date.now() + days * MS_PER_DAY);
127
+ estimatedFullDate = fullDate.toISOString().split('T')[0] ?? null;
128
+ }
115
129
  }
116
130
  forecasts.push({
117
131
  mountPoint,
@@ -129,6 +143,7 @@ export async function forecastDisk(nodeId, lookbackDays = DEFAULT_LOOKBACK_DAYS)
129
143
  export async function forecastAtRisk(daysThreshold = 30) {
130
144
  const db = getDb();
131
145
  const nodes = await db.node.findMany({
146
+ where: { id: { notIn: SENTINEL_NODE_IDS } },
132
147
  select: { id: true, hostname: true },
133
148
  });
134
149
  const results = [];
@@ -9,6 +9,8 @@ import { getRateLimitHealth } from '../middleware/rate-limit.js';
9
9
  import { listHermesInstallations } from './hermes-service.js';
10
10
  import { getHealthRetentionCutoffs, getHealthRetentionPolicy, getRetentionPolicyReadiness } from './health-retention.js';
11
11
  import { getNotificationRetentionPolicy } from './notification-retention.js';
12
+ import { SENTINEL_NODE_IDS } from '../utils/sentinel-nodes.js';
13
+ import { databaseFailure, describeTransportFailure } from '../utils/safe-errors.js';
12
14
  const STALE_THRESHOLD_MS = Number(process.env['STALE_NODE_THRESHOLD_MS'] ?? 120_000);
13
15
  function fingerprint(value) {
14
16
  if (!value)
@@ -24,26 +26,10 @@ async function timed(fn) {
24
26
  catch (err) {
25
27
  return {
26
28
  latencyMs: Math.round((performance.now() - started) * 100) / 100,
27
- error: err instanceof Error ? err.message : String(err),
29
+ error: databaseFailure(err)?.message ?? describeTransportFailure(err),
28
30
  };
29
31
  }
30
32
  }
31
- function describeDependencyError(error) {
32
- if (!(error instanceof Error))
33
- return String(error);
34
- const cause = error.cause;
35
- if (cause && typeof cause === 'object') {
36
- const code = 'code' in cause && typeof cause.code === 'string' ? cause.code : undefined;
37
- const message = 'message' in cause && typeof cause.message === 'string' ? cause.message : undefined;
38
- if (code && message)
39
- return `${error.message} (${code}: ${message})`;
40
- if (code)
41
- return `${error.message} (${code})`;
42
- if (message)
43
- return `${error.message} (${message})`;
44
- }
45
- return error.message;
46
- }
47
33
  export function resolveRouterHealthUrl(routerUrl, explicitHealthUrl) {
48
34
  if (explicitHealthUrl)
49
35
  return explicitHealthUrl;
@@ -70,7 +56,7 @@ export async function checkHttp(name, url, timeoutMs = 2500) {
70
56
  return {
71
57
  name,
72
58
  status: 'warn',
73
- message: describeDependencyError(error),
59
+ message: describeTransportFailure(error),
74
60
  latencyMs: Math.round((performance.now() - started) * 100) / 100,
75
61
  };
76
62
  }
@@ -143,10 +129,17 @@ export async function getDependencyHealth() {
143
129
  const status = failCount > 0 ? 'unhealthy' : warnCount > 0 ? 'degraded' : 'healthy';
144
130
  return { status, checks, generatedAt: new Date().toISOString() };
145
131
  }
146
- export async function getFleetReadiness() {
132
+ /**
133
+ * Fleet readiness. Secret fingerprints are opt-in, and only an ADMIN response
134
+ * asks for them: every other caller, including assistant tools and MCP, sees
135
+ * whether a node's credential is current without the value that identifies it.
136
+ */
137
+ export async function getFleetReadiness(options = {}) {
138
+ const includeSecretFingerprints = options.includeSecretFingerprints === true;
147
139
  const db = getDb();
148
140
  const [nodes, latestHealthSnapshots] = await withReadRetry('fleet readiness', () => Promise.all([
149
141
  db.node.findMany({
142
+ where: { id: { notIn: SENTINEL_NODE_IDS } },
150
143
  orderBy: { name: 'asc' },
151
144
  include: {
152
145
  agent: true,
@@ -171,10 +164,12 @@ export async function getFleetReadiness() {
171
164
  return {
172
165
  generatedAt: new Date().toISOString(),
173
166
  thresholds: { staleNodeMs: STALE_THRESHOLD_MS },
174
- expectedFingerprints: {
175
- nodeManagerSecret: fingerprint(getNodeManagerSecret()),
176
- apiSecret: fingerprint(API_SECRET),
177
- },
167
+ ...(includeSecretFingerprints ? {
168
+ expectedFingerprints: {
169
+ nodeManagerSecret: fingerprint(getNodeManagerSecret()),
170
+ apiSecret: fingerprint(API_SECRET),
171
+ },
172
+ } : {}),
178
173
  nodes: nodes.map((node) => {
179
174
  const heartbeatAgeMs = node.lastHeartbeatAt ? now - node.lastHeartbeatAt.getTime() : null;
180
175
  const stale = heartbeatAgeMs === null || heartbeatAgeMs > STALE_THRESHOLD_MS;
@@ -217,9 +212,9 @@ export async function getFleetReadiness() {
217
212
  lastDetectedAt: hermes.lastDetectedAt?.toISOString() ?? null,
218
213
  } : null,
219
214
  secretFingerprint: {
220
- expectedNodeManagerSecret,
221
- observedNodeManagerSecret,
215
+ ...(includeSecretFingerprints ? { expectedNodeManagerSecret, observedNodeManagerSecret } : {}),
222
216
  match: observedNodeManagerSecret === null ? 'unknown' : observedNodeManagerSecret === expectedNodeManagerSecret ? 'match' : 'mismatch',
217
+ state: typeof metadata['nodeManagerSecretState'] === 'string' ? metadata['nodeManagerSecretState'] : 'unknown',
223
218
  },
224
219
  };
225
220
  }),