@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.
- package/.env.example +37 -0
- package/README.md +36 -0
- package/agent-version.json +1 -1
- package/dist/ai/TOOLS.md +1 -1
- package/dist/cli/args.js +3 -1
- package/dist/cli/env-catalog.js +16 -0
- package/dist/cli/migrations.js +22 -0
- package/dist/cli/verify-initialization.js +12 -0
- package/dist/cli.js +28 -0
- package/dist/config/console-path.js +17 -0
- package/dist/config/env.js +32 -5
- package/dist/config/features.js +4 -1
- package/dist/config/oathweave.js +42 -0
- package/dist/config/rehearsal.js +22 -0
- package/dist/db/generated/prisma/internal/class.js +4 -4
- package/dist/db/generated/prisma/internal/prismaNamespace.js +17 -1
- package/dist/db/generated/prisma/internal/prismaNamespaceBrowser.js +17 -1
- package/dist/db/generated/prisma/models/CindertraceResponse.js +2 -0
- package/dist/db/generated/prisma/models/ProductInitialization.js +2 -0
- package/dist/db/read-retry.js +5 -1
- package/dist/db/seed.js +8 -0
- package/dist/index.js +28 -37
- package/dist/middleware/auth.js +21 -0
- package/dist/middleware/error-handler.js +10 -10
- package/dist/middleware/oathweave-verifier.js +23 -0
- package/dist/middleware/oathweave.js +115 -0
- package/dist/middleware/rate-limit.js +12 -3
- package/dist/openapi/spec.js +45 -4
- package/dist/queue/names.js +9 -0
- package/dist/queue/workers.js +9 -68
- package/dist/routes/assistant.js +30 -12
- package/dist/routes/cindertrace-response.js +183 -0
- package/dist/routes/console.js +33 -0
- package/dist/routes/cron.js +8 -5
- package/dist/routes/events-stream.js +10 -0
- package/dist/routes/files.js +2 -1
- package/dist/routes/fleet-gateway.js +69 -0
- package/dist/routes/heartbeat.js +1 -6
- package/dist/routes/hermes.js +26 -6
- package/dist/routes/hiveskills.js +116 -0
- package/dist/routes/library.js +3 -2
- package/dist/routes/logs.js +5 -0
- package/dist/routes/metrics.js +4 -3
- package/dist/routes/nodes.js +45 -8
- package/dist/routes/ops.js +24 -6
- package/dist/routes/profiling.js +2 -1
- package/dist/routes/roles.js +17 -9
- package/dist/routes/security-fusion.js +15 -600
- package/dist/routes/stats.js +8 -5
- package/dist/routes/terminal-ticket.js +3 -3
- package/dist/routes/terminal.js +2 -2
- package/dist/routes/webhooks.js +15 -15
- package/dist/sdk/node-api-client.js +23 -1
- package/dist/security/cindertrace-response.js +14 -0
- package/dist/security/native-completion-registry.js +14 -0
- package/dist/server.js +11 -14
- package/dist/services/ai-assistant-service.js +32 -6
- package/dist/services/ai-tools.js +15 -8
- package/dist/services/ardent-scope-enrollment.js +4 -2
- package/dist/services/command-resource-leases.js +2 -4
- package/dist/services/forecast-service.js +23 -8
- package/dist/services/ops-service.js +20 -25
- package/dist/services/prediction-service.js +2 -1
- package/dist/services/resource-enrollment.js +57 -4
- package/dist/services/secret-views.js +72 -0
- package/dist/services/security-export.js +4 -6
- package/dist/services/topology-service.js +2 -1
- package/dist/services/webhook-security.js +10 -1
- package/dist/services/webhook-service.js +14 -6
- package/dist/utils/node-url.js +2 -1
- package/dist/utils/safe-errors.js +130 -0
- package/node_modules/@hivemagnus/magnus-oathweave/LICENSE +202 -0
- package/node_modules/@hivemagnus/magnus-oathweave/README.md +20 -0
- package/node_modules/@hivemagnus/magnus-oathweave/dist/index.d.ts +61 -0
- package/node_modules/@hivemagnus/magnus-oathweave/dist/index.js +148 -0
- package/node_modules/@hivemagnus/magnus-oathweave/package.json +54 -0
- package/package.json +7 -3
- package/prisma/migrations/20261103000000_cindertrace_response_bridge/migration-impact.json +128 -0
- package/prisma/migrations/20261103000000_cindertrace_response_bridge/migration.sql +6 -0
- package/prisma/migrations/20261105010000_base_initialization/magnus-impact.json +1 -0
- package/prisma/migrations/20261105010000_base_initialization/migration.sql +5 -0
- package/prisma/schema.prisma +22 -0
- package/dist/services/security-fusion-actions.js +0 -22
- package/dist/services/security-fusion-evidence-integrity.js +0 -144
- package/dist/services/security-fusion-ingest.js +0 -101
- package/dist/services/security-fusion-outbox.js +0 -185
- package/dist/services/security-fusion-receipt-chain.js +0 -134
- package/dist/services/security-fusion-service.js +0 -751
- 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
|
|
7
|
-
*
|
|
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;
|
|
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
|
package/dist/routes/terminal.js
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
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 });
|
package/dist/routes/webhooks.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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' ?
|
|
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
|
|
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 =
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
}),
|