@hivemagnus/magnus-server 2.0.0 → 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 +32 -0
- package/README.md +32 -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 +13 -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 +28 -0
- package/dist/config/features.js +3 -0
- 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 +5 -0
- package/dist/db/generated/prisma/internal/prismaNamespaceBrowser.js +5 -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 +27 -21
- 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 +38 -2
- package/dist/queue/names.js +9 -0
- package/dist/queue/workers.js +9 -3
- package/dist/routes/assistant.js +30 -12
- 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/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/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 +11 -1
- package/dist/security/native-completion-registry.js +3 -1
- package/dist/server.js +8 -0
- 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/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/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/20261105010000_base_initialization/magnus-impact.json +1 -0
- package/prisma/migrations/20261105010000_base_initialization/migration.sql +5 -0
- package/prisma/schema.prisma +9 -0
package/dist/routes/nodes.js
CHANGED
|
@@ -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
|
|
120
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
|
691
|
+
const safeNode = nodeForRole(updated, getUser(c).role);
|
|
655
692
|
return c.json({
|
|
656
693
|
success: true,
|
|
657
694
|
data: {
|
package/dist/routes/ops.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
220
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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)) }));
|
package/dist/routes/profiling.js
CHANGED
|
@@ -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) => {
|
package/dist/routes/roles.js
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
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:
|
|
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
|
|
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) => {
|
package/dist/routes/stats.js
CHANGED
|
@@ -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
|
|
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,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
|
|
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
|
}
|