@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
@@ -0,0 +1,69 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { Hono } from 'hono';
3
+ import { z } from 'zod';
4
+ import { getDb } from '../db/index.js';
5
+ import { HttpError } from '../middleware/error-handler.js';
6
+ import { parseBody } from '../utils/validate.js';
7
+ import { safeEqual } from '../utils/safe-equal.js';
8
+ import { logAuditEvent } from '../services/audit-service.js';
9
+ /**
10
+ * Machine routes for the fleet gateway, the separate service that holds the restricted SSH key.
11
+ * The gateway asks what a node is and records what happened. Nothing here dials a node.
12
+ */
13
+ export const fleetGatewayRouter = new Hono();
14
+ async function requireGatewayCredential(c, next) {
15
+ const expected = process.env['FLEET_GATEWAY_TOKEN_SHA256'] ?? '';
16
+ const token = c.req.header('x-fleet-gateway-token') ?? '';
17
+ if (!expected || !token || !safeEqual(createHash('sha256').update(token).digest('hex'), expected))
18
+ throw new HttpError(401, 'UNAUTHORIZED', 'Fleet gateway credential required');
19
+ await next();
20
+ }
21
+ fleetGatewayRouter.use('*', requireGatewayCredential);
22
+ const nodeId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/);
23
+ /** POST /fleet-gateway/target: inventory facts for one node. The gateway applies its own host-key approval. */
24
+ fleetGatewayRouter.post('/target', async (c) => {
25
+ const body = await parseBody(c, z.object({ nodeId }).strict());
26
+ const node = await getDb().node.findUnique({
27
+ where: { id: body.nodeId },
28
+ select: { id: true, hostname: true, ipAddress: true, status: true },
29
+ });
30
+ if (!node)
31
+ throw new HttpError(404, 'NODE_NOT_FOUND', 'Node not found');
32
+ c.header('Cache-Control', 'no-store');
33
+ return c.json({ success: true, data: { nodeId: node.id, hostname: node.hostname, ipAddress: node.ipAddress, status: node.status } });
34
+ });
35
+ const auditSchema = z.object({
36
+ action: z.enum(['terminal.session.open', 'terminal.session.close', 'terminal.session.refuse', 'terminal.host-key.approve', 'terminal.host-key.remove']),
37
+ subject: z.string().min(1).max(256),
38
+ actorId: z.string().min(1).max(256),
39
+ nodeId: nodeId.nullable(),
40
+ sessionId: z.string().regex(/^ts_[A-Za-z0-9_-]{43}$/).nullable(),
41
+ outcome: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9:._/-]{0,199}$/),
42
+ at: z.iso.datetime(),
43
+ durationMs: z.number().int().nonnegative().nullable(),
44
+ ipAddress: z.union([z.ipv4(), z.ipv6()]).nullable(),
45
+ fingerprint: z.string().regex(/^SHA256:[A-Za-z0-9+/]{43}$/).nullable(),
46
+ }).strict();
47
+ /** POST /fleet-gateway/audit: terminal metadata. The schema has no field for terminal input or output. */
48
+ fleetGatewayRouter.post('/audit', async (c) => {
49
+ const event = await parseBody(c, auditSchema);
50
+ await logAuditEvent({
51
+ action: event.action,
52
+ actorId: event.actorId,
53
+ actorType: 'user',
54
+ targetType: 'node',
55
+ targetId: event.nodeId ?? 'unspecified',
56
+ ipAddress: event.ipAddress,
57
+ details: {
58
+ subject: event.subject,
59
+ sessionHandle: event.sessionId,
60
+ outcome: event.outcome,
61
+ observedAt: event.at,
62
+ durationMs: event.durationMs,
63
+ hostKeyFingerprint: event.fingerprint,
64
+ },
65
+ });
66
+ c.header('Cache-Control', 'no-store');
67
+ return c.json({ success: true, data: { recorded: true } });
68
+ });
69
+ //# sourceMappingURL=fleet-gateway.js.map
@@ -1,4 +1,4 @@
1
- import { enqueueSecurityObservations, securityFusionLocal } from '../services/security-export.js';
1
+ import { enqueueSecurityObservations } from '../services/security-export.js';
2
2
  import { warnOnAgentVersionMismatch } from '../services/agent-version-warnings.js';
3
3
  import { recordSelfUpgradeReport } from '../services/agent-upgrade-service.js';
4
4
  import { Hono } from 'hono';
@@ -22,7 +22,6 @@ import { heartbeatHealthSchema } from '../schemas/node-responses.js';
22
22
  import { normalizeIpAddress, isLoopbackOrUnroutableIp, isUsableNodeIp } from '../utils/ip.js';
23
23
  import { AGENT_VERSION } from '../config/version.js';
24
24
  import { getHeartbeatStreamStats } from '../services/heartbeat-stream.js';
25
- import { ingestMagnusServiceHealth } from '../services/security-fusion-service.js';
26
25
  import { deriveAgentCertificateHostname, selectAgentEndpointHostname, verifyAgentCertificateHostname, } from '../utils/node-url.js';
27
26
  import { heartbeatPeerAddress } from '../utils/heartbeat-peer.js';
28
27
  import { authMiddleware } from '../middleware/auth.js';
@@ -490,10 +489,6 @@ heartbeatRouter.post('/', async (c) => {
490
489
  await enqueueSecurityObservations(tx, nodeId, existing?.name ?? fqdn ?? nodeId, (h.services ?? []), snapshot.id, snapshot.timestamp);
491
490
  return snapshot;
492
491
  });
493
- if (securityFusionLocal())
494
- void ingestMagnusServiceHealth(nodeId, existing?.name ?? fqdn ?? nodeId, (h.services ?? [])).catch((err) => {
495
- console.error(`[security-fusion] heartbeat conversion failed for ${nodeId}:`, err instanceof Error ? err.message : err);
496
- });
497
492
  // Evaluate health against alert rules (fire-and-forget)
498
493
  const alertNodeName = existing?.name ?? fqdn ?? nodeId;
499
494
  evaluateHealth(nodeId, alertNodeName, {
@@ -1,5 +1,6 @@
1
1
  import { Hono } from 'hono';
2
- import { authMiddleware } from '../middleware/auth.js';
2
+ import { authMiddleware, getUser } from '../middleware/auth.js';
3
+ import { hermesProfileForRole } from '../services/secret-views.js';
3
4
  import { HttpError } from '../middleware/error-handler.js';
4
5
  import { hermesJobListQuerySchema } from '../schemas/hermes.js';
5
6
  import { getHermesApiServerStatus, getHermesInstallation, getHermesJob, listHermesInstallations, listHermesJobs, listHermesProfiles, } from '../services/hermes-service.js';
@@ -13,21 +14,40 @@ function requiredParam(c, name) {
13
14
  }
14
15
  // Read-only inventory/status endpoints remain available because they use the
15
16
  // Control database and never open SSH connections.
16
- hermesRouter.get('/installations', async (c) => c.json({ success: true, data: await listHermesInstallations() }));
17
+ function installationForRole(installation, role) {
18
+ return installation.profiles
19
+ ? { ...installation, profiles: installation.profiles.map((profile) => hermesProfileForRole(profile, role)) }
20
+ : installation;
21
+ }
22
+ hermesRouter.get('/installations', async (c) => {
23
+ const role = getUser(c).role;
24
+ return c.json({ success: true, data: (await listHermesInstallations()).map((installation) => installationForRole(installation, role)) });
25
+ });
17
26
  hermesRouter.get('/installations/:id', async (c) => {
18
27
  const data = await getHermesInstallation(requiredParam(c, 'id'));
19
28
  if (!data)
20
29
  throw new HttpError(404, 'NOT_FOUND', 'Hermes installation not found');
21
- return c.json({ success: true, data });
30
+ return c.json({ success: true, data: installationForRole(data, getUser(c).role) });
22
31
  });
23
32
  hermesRouter.get('/installations/:id/profiles', async (c) => {
24
33
  const installation = await getHermesInstallation(requiredParam(c, 'id'));
25
34
  if (!installation)
26
35
  throw new HttpError(404, 'NOT_FOUND', 'Hermes installation not found');
27
- return c.json({ success: true, data: await listHermesProfiles(installation.id) });
36
+ const role = getUser(c).role;
37
+ return c.json({ success: true, data: (await listHermesProfiles(installation.id)).map((profile) => hermesProfileForRole(profile, role)) });
38
+ });
39
+ hermesRouter.get('/profiles', async (c) => {
40
+ const role = getUser(c).role;
41
+ return c.json({ success: true, data: (await listHermesProfiles()).map((profile) => hermesProfileForRole(profile, role)) });
42
+ });
43
+ hermesRouter.get('/profiles/:id/api-server/status', async (c) => {
44
+ const { keyFingerprint, ...status } = await getHermesApiServerStatus(requiredParam(c, 'id'));
45
+ return c.json({ success: true, data: {
46
+ ...status,
47
+ ...(getUser(c).role === 'ADMIN' ? { keyFingerprint } : {}),
48
+ keyConfigured: Boolean(keyFingerprint),
49
+ } });
28
50
  });
29
- hermesRouter.get('/profiles', async (c) => c.json({ success: true, data: await listHermesProfiles() }));
30
- hermesRouter.get('/profiles/:id/api-server/status', async (c) => c.json({ success: true, data: await getHermesApiServerStatus(requiredParam(c, 'id')) }));
31
51
  hermesRouter.get('/jobs', async (c) => {
32
52
  const parsed = hermesJobListQuerySchema.safeParse({
33
53
  status: c.req.query('status'),
@@ -0,0 +1,116 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { Hono } from 'hono';
3
+ import { z } from 'zod';
4
+ import { HiveSkillsTargetPostureSchema, HiveSkillsPublicationSchema } from '@hivemagnus/magnus-contracts';
5
+ import { getDb } from '../db/index.js';
6
+ import { HttpError } from '../middleware/error-handler.js';
7
+ import { safeEqual } from '../utils/safe-equal.js';
8
+ import { getNodeManagerSecret } from '../utils/env.js';
9
+ import { getNodeBaseUrl } from '../utils/node-url.js';
10
+ import { NodeApiClient } from '../sdk/node-api-client.js';
11
+ import { logAuditEvent } from '../services/audit-service.js';
12
+ import { httpIdentity } from '../security/managed-http.js';
13
+ const slug = z.string().regex(/^[a-z0-9][a-z0-9-]{1,62}$/);
14
+ export const skillsExecutorConfig = z.record(slug, z.object({ tokenSha256: z.string().regex(/^[a-f0-9]{64}$/), targets: z.record(slug, z.object({ nodeId: z.string().min(1).max(128), agentTarget: slug }).strict()) }).strict());
15
+ export const skillJobRequest = z.object({ target: slug, publicationSha256: z.string().regex(/^[a-f0-9]{64}$/), manifest: HiveSkillsPublicationSchema, approvedBy: z.string().min(1).max(200) }).strict();
16
+ export const hiveskillsRouter = new Hono();
17
+ function authorize(header) {
18
+ let raw;
19
+ try {
20
+ raw = JSON.parse(process.env['MAGNUS_HIVESKILLS_DEPLOYMENTS_JSON'] ?? '{}');
21
+ }
22
+ catch {
23
+ throw new HttpError(503, 'HIVESKILLS_UNCONFIGURED', 'Invalid Skills executor configuration');
24
+ }
25
+ const configured = skillsExecutorConfig.safeParse(raw);
26
+ if (!configured.success)
27
+ throw new HttpError(503, 'HIVESKILLS_UNCONFIGURED', 'Invalid Skills executor configuration');
28
+ if (!header?.startsWith('Bearer ') || header.length < 39 || /[\r\n]/.test(header))
29
+ throw new HttpError(401, 'UNAUTHORIZED', 'Endpoint-scoped Skills credential required');
30
+ const digest = createHash('sha256').update(header.slice(7)).digest('hex');
31
+ for (const [registryId, entry] of Object.entries(configured.data))
32
+ if (safeEqual(entry.tokenSha256, digest))
33
+ return { registryId, ...entry };
34
+ throw new HttpError(401, 'UNAUTHORIZED', 'Endpoint-scoped Skills credential required');
35
+ }
36
+ function commandId(registryId, id) { if (!z.uuid().safeParse(id).success)
37
+ throw new HttpError(400, 'VALIDATION_ERROR', 'Job UUID required'); return 'hiveskills-' + createHash('sha256').update(registryId + ':' + id).digest('hex'); }
38
+ async function targetClient(registry, target) {
39
+ const binding = registry.targets[target];
40
+ if (!binding)
41
+ throw new HttpError(403, 'FORBIDDEN', 'Target is outside this registry executor grant');
42
+ const node = await getDb().node.findUnique({ where: { id: binding.nodeId } });
43
+ if (!node || node.status === 'DECOMMISSIONED')
44
+ throw new HttpError(409, 'TARGET_UNAVAILABLE', 'Configured target is unavailable');
45
+ return { binding, node, client: new NodeApiClient({ baseUrl: getNodeBaseUrl(node.ipAddress, node.port), resourceNodeId: node.id, resourceActorId: 'service:hiveskills:' + registry.registryId, nodeToken: getNodeManagerSecret() }) };
46
+ }
47
+ async function observed(registry, target, id) {
48
+ const { node, client } = await targetClient(registry, target), jobId = commandId(registry.registryId, id);
49
+ const row = await getDb().remoteCommand.findUnique({ where: { id: jobId } });
50
+ if (!row)
51
+ throw new HttpError(404, 'NOT_FOUND', 'Job has not been admitted');
52
+ if (row.nodeId !== node.id || row.createdBy !== 'service:hiveskills:' + registry.registryId)
53
+ throw new HttpError(409, 'JOB_BINDING_CONFLICT', 'Job belongs to another target');
54
+ const lease = await getDb().commandResourceLease.findUnique({ where: { commandId: jobId } });
55
+ if (!lease)
56
+ return { jobId, status: 'unknown', result: 'Missing durable ownership; review required' };
57
+ try {
58
+ const receipt = await client.getCommandReceipt(jobId, { nodeFqdn: node.hostname, requestSha256: lease.requestSha256 });
59
+ if (receipt.state === 'completed')
60
+ return { jobId, status: receipt.result.exitCode === 0 ? 'completed' : 'failed', result: receipt.result.exitCode === 0 ? 'Exact approved publication synchronized' : 'Node completed with a bounded failure' };
61
+ return { jobId, status: receipt.state === 'running' ? 'running' : 'unknown', result: 'Original operation retained; never replayed' };
62
+ }
63
+ catch {
64
+ return { jobId, status: 'unknown', result: 'Receipt unavailable; original operation retained' };
65
+ }
66
+ }
67
+ hiveskillsRouter.get('/targets/:target', async (c) => {
68
+ const registry = authorize(c.req.header('authorization')), target = slug.safeParse(c.req.param('target'));
69
+ if (!target.success)
70
+ throw new HttpError(400, 'VALIDATION_ERROR', 'Exact logical target required');
71
+ const { binding, node, client } = await targetClient(registry, target.data), enrollment = await getDb().resourceEnrollment.findUnique({ where: { nodeId: node.id } });
72
+ const base = { registryId: registry.registryId, target: target.data, nodeId: node.id, agentTarget: binding.agentTarget };
73
+ if (enrollment?.state !== 'enrolled' || enrollment.nodeFqdn !== node.hostname || node.status !== 'ONLINE' || !node.lastHeartbeatAt || Date.now() - node.lastHeartbeatAt.getTime() > 120000 || await getDb().commandResourceLease.count({ where: { nodeId: node.id, state: { in: ['reserved', 'running', 'unknown'] } } }))
74
+ return c.json({ ...base, ready: false, reason: 'Node enrollment, health or retained ownership requires operator review' });
75
+ try {
76
+ const reply = await client.hiveSkillsTargetPosture(binding.agentTarget, registry.registryId), posture = HiveSkillsTargetPostureSchema.parse(reply.data);
77
+ if (!reply.success || posture.registryId !== registry.registryId || posture.target !== binding.agentTarget)
78
+ throw new Error('Native target binding differs');
79
+ return c.json({ ...base, ready: posture.ready, reason: posture.reason, configurationSha256: posture.configurationSha256, clientSha256: posture.clientSha256 });
80
+ }
81
+ catch {
82
+ return c.json({ ...base, ready: false, reason: 'Native target posture unavailable' });
83
+ }
84
+ });
85
+ hiveskillsRouter.get('/jobs/:id', async (c) => { const registry = authorize(c.req.header('authorization')); const target = slug.safeParse(c.req.query('target')); if (!target.success)
86
+ throw new HttpError(400, 'VALIDATION_ERROR', 'Exact target required'); return c.json(await observed(registry, target.data, c.req.param('id'))); });
87
+ hiveskillsRouter.post('/jobs/:id', async (c) => {
88
+ const registry = authorize(c.req.header('authorization')), parsed = skillJobRequest.safeParse(await c.req.json().catch(() => null));
89
+ if (!parsed.success)
90
+ throw new HttpError(400, 'VALIDATION_ERROR', 'Exact approved Skills publication required');
91
+ const body = parsed.data, jobId = commandId(registry.registryId, c.req.param('id'));
92
+ if (createHash('sha256').update(JSON.stringify(body.manifest)).digest('hex') !== body.publicationSha256)
93
+ throw new HttpError(409, 'PUBLICATION_CONFLICT', 'Publication digest differs');
94
+ const { binding, node, client } = await targetClient(registry, body.target);
95
+ const request = { target: binding.agentTarget, registryId: registry.registryId, publicationSha256: body.publicationSha256, manifest: body.manifest };
96
+ const prior = await getDb().remoteCommand.findUnique({ where: { id: jobId } });
97
+ if (prior) {
98
+ const identity = httpIdentity(jobId, 'POST', '/hiveskills/sync', JSON.stringify(request), 120);
99
+ if (prior.nodeId !== node.id || prior.createdBy !== 'service:hiveskills:' + registry.registryId || prior.command !== identity.command || prior.args !== JSON.stringify(identity.args))
100
+ throw new HttpError(409, 'JOB_BINDING_CONFLICT', 'Existing job has another target or publication');
101
+ return c.json(await observed(registry, body.target, c.req.param('id')));
102
+ }
103
+ await logAuditEvent({ action: 'skill.push', actorId: 'service:hiveskills:' + registry.registryId, targetType: 'node', targetId: node.id, details: { jobId, target: body.target, publicationSha256: body.publicationSha256, approvedBy: body.approvedBy, outcome: 'requested' } });
104
+ try {
105
+ const result = await client.syncHiveSkills(request, jobId);
106
+ await logAuditEvent({ action: 'skill.push', actorId: 'service:hiveskills:' + registry.registryId, targetType: 'node', targetId: node.id, details: { jobId, target: body.target, publicationSha256: body.publicationSha256, approvedBy: body.approvedBy, commandSha256: result.evidence.commandSha256 } });
107
+ return c.json({ jobId, status: result.response.success ? 'completed' : 'failed', result: result.response.success ? 'Exact approved publication synchronized' : 'Node completed with a bounded failure' });
108
+ }
109
+ catch {
110
+ const row = await getDb().remoteCommand.findUnique({ where: { id: jobId } });
111
+ if (!row)
112
+ return c.json({ jobId, status: 'not_admitted', result: 'No operation was admitted; resolve target readiness and prepare a new preview' });
113
+ return c.json(await observed(registry, body.target, c.req.param('id')));
114
+ }
115
+ });
116
+ //# sourceMappingURL=hiveskills.js.map
@@ -1,3 +1,4 @@
1
+ import { clientErrorMessage } from '../utils/safe-errors.js';
1
2
  import { Hono } from 'hono';
2
3
  import { getDb } from '../db/index.js';
3
4
  import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
@@ -373,7 +374,7 @@ libraryRouter.post('/deploy', requireRole('ADMIN', 'OPERATOR'), async (c) => {
373
374
  nodeId: node.id,
374
375
  nodeName: node.name,
375
376
  status: 'failed',
376
- error: err instanceof Error ? err.message : 'Unknown error',
377
+ error: clientErrorMessage(err, 'Unknown error'),
377
378
  };
378
379
  }
379
380
  }));
@@ -383,7 +384,7 @@ libraryRouter.post('/deploy', requireRole('ADMIN', 'OPERATOR'), async (c) => {
383
384
  nodeId: 'unknown',
384
385
  nodeName: 'unknown',
385
386
  status: 'failed',
386
- error: r.reason instanceof Error ? r.reason.message : 'Unknown error',
387
+ error: clientErrorMessage(r.reason, 'Unknown error'),
387
388
  });
388
389
  await logAuditEvent({
389
390
  action: 'library.deploy',
@@ -1,6 +1,7 @@
1
1
  import { Hono } from 'hono';
2
2
  import { createHmac, timingSafeEqual } from 'node:crypto';
3
3
  import { streamSSE } from 'hono/streaming';
4
+ import { watchOathweaveStream } from '../middleware/oathweave.js';
4
5
  import { getDb } from '../db/index.js';
5
6
  import { authMiddleware } from '../middleware/auth.js';
6
7
  import { sanitizeDetails } from '../services/audit-service.js';
@@ -157,9 +158,13 @@ logsRouter.get('/stream', (c) => {
157
158
  id: String(eventId++),
158
159
  });
159
160
  });
161
+ const stopWatching = watchOathweaveStream(c.req.header('Authorization'), () => {
162
+ void stream.close();
163
+ });
160
164
  // Wait until the client disconnects
161
165
  stream.onAbort(() => {
162
166
  clearInterval(heartbeatTimer);
167
+ stopWatching();
163
168
  unsubscribe();
164
169
  });
165
170
  // Keep the stream open — the onAbort callback will clean up
@@ -3,6 +3,7 @@ import { getDb } from '../db/index.js';
3
3
  import { getRedis } from '../queue/redis.js';
4
4
  import { getHeartbeatStreamStats } from '../services/heartbeat-stream.js';
5
5
  import { safeEqual } from '../utils/safe-equal.js';
6
+ import { SENTINEL_NODE_IDS } from '../utils/sentinel-nodes.js';
6
7
  export const metricsRouter = new Hono();
7
8
  // ── In-process counters ──
8
9
  const counters = {
@@ -115,10 +116,10 @@ metricsRouter.get('/', async (c) => {
115
116
  try {
116
117
  const db = getDb();
117
118
  const [nodeCount, onlineNodes, pendingCommands, staleNodes, versions, oldestQueued] = await Promise.all([
118
- db.node.count(),
119
- db.node.count({ where: { status: 'ONLINE' } }),
119
+ db.node.count({ where: { id: { notIn: SENTINEL_NODE_IDS } } }),
120
+ db.node.count({ where: { id: { notIn: SENTINEL_NODE_IDS }, status: 'ONLINE' } }),
120
121
  db.remoteCommand.count({ where: { status: { in: ['PENDING', 'QUEUED'] } } }),
121
- db.node.count({ where: { lastHeartbeatAt: { lt: new Date(Date.now() - 120_000) } } }),
122
+ db.node.count({ where: { id: { notIn: SENTINEL_NODE_IDS }, lastHeartbeatAt: { lt: new Date(Date.now() - 120_000) } } }),
122
123
  db.agent.groupBy({ by: ['version'], _count: true }),
123
124
  db.remoteCommand.findFirst({ where: { status: { in: ['PENDING', 'QUEUED'] } }, orderBy: { createdAt: 'asc' }, select: { createdAt: true } }),
124
125
  ]);
@@ -1,7 +1,8 @@
1
1
  import { Hono } from 'hono';
2
+ import { nodeForRole } from '../services/secret-views.js';
2
3
  import { nanoid } from 'nanoid';
3
4
  import { AGENT_VERSION } from '../config/index.js';
4
- import { getDb } from '../db/index.js';
5
+ import { getDb, Prisma } from '../db/index.js';
5
6
  import { promises as dns } from 'node:dns';
6
7
  import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
7
8
  import { logAuditEvent } from '../services/audit-service.js';
@@ -79,6 +80,36 @@ async function tryResolveHostnameToIp(hostname) {
79
80
  return null;
80
81
  }
81
82
  }
83
+ /**
84
+ * The latest snapshot of each node, in one statement, for what a list row shows. An
85
+ * indexed lookup per node avoids hydrating retained history, which a Prisma relation
86
+ * with `take: 1` would load. A node with no snapshot is simply absent.
87
+ */
88
+ async function latestHealthSummaries(nodeIds) {
89
+ if (nodeIds.length === 0)
90
+ return new Map();
91
+ const rows = await getDb().$queryRaw `
92
+ SELECT snapshot."nodeId", snapshot."timestamp", snapshot."cpuUsagePercent", snapshot."cpuCores",
93
+ snapshot."memoryTotalBytes", snapshot."memoryUsedBytes", snapshot."memoryPercent",
94
+ snapshot."swapTotalBytes", snapshot."swapUsedBytes", snapshot."disks"
95
+ FROM "nodes" AS node
96
+ INNER JOIN "health_snapshots" AS snapshot ON snapshot."id" = (
97
+ SELECT candidate."id"
98
+ FROM "health_snapshots" AS candidate
99
+ WHERE candidate."nodeId" = node."id"
100
+ ORDER BY candidate."timestamp" DESC
101
+ LIMIT 1
102
+ )
103
+ WHERE node."id" IN (${Prisma.join(nodeIds)})
104
+ `;
105
+ return new Map(rows.map(({ nodeId, memoryTotalBytes, memoryUsedBytes, swapTotalBytes, swapUsedBytes, ...row }) => [nodeId, {
106
+ ...row,
107
+ memoryTotalBytes: Number(memoryTotalBytes),
108
+ memoryUsedBytes: Number(memoryUsedBytes),
109
+ swapTotalBytes: Number(swapTotalBytes),
110
+ swapUsedBytes: Number(swapUsedBytes),
111
+ }]));
112
+ }
82
113
  export const nodesRouter = new Hono();
83
114
  // All routes require auth
84
115
  nodesRouter.use('*', authMiddleware);
@@ -116,11 +147,15 @@ nodesRouter.get('/', async (c) => {
116
147
  db.node.count({ where }),
117
148
  ]));
118
149
  // Strip token from API response — never expose node secrets to the frontend
119
- const safeNodes = nodes.map(({ token: _token, ...rest }) => rest);
120
- const readiness = await readinessOfNodes(safeNodes);
150
+ const viewer = getUser(c).role;
151
+ const safeNodes = nodes.map((node) => nodeForRole(node, viewer));
152
+ const [readiness, health] = await Promise.all([
153
+ readinessOfNodes(safeNodes),
154
+ withReadRetry('nodes list health', () => latestHealthSummaries(safeNodes.map((node) => node.id))),
155
+ ]);
121
156
  return c.json({
122
157
  success: true,
123
- data: safeNodes.map(node => ({ ...node, upgradeReadiness: readiness.get(node.id) ?? null })),
158
+ data: safeNodes.map(node => ({ ...node, upgradeReadiness: readiness.get(node.id) ?? null, health: health.get(node.id) ?? null })),
124
159
  meta: { total, page, pageSize, hasMore: page * pageSize < total },
125
160
  });
126
161
  });
@@ -495,7 +530,7 @@ nodesRouter.get('/:nodeId', async (c) => {
495
530
  });
496
531
  if (!node)
497
532
  throw new HttpError(404, 'NOT_FOUND', 'Node not found');
498
- const { token: _token, ...safeNode } = node;
533
+ const safeNode = nodeForRole(node, getUser(c).role);
499
534
  const readiness = await readinessOfNodes([safeNode]);
500
535
  return c.json({ success: true, data: { ...safeNode, upgradeReadiness: readiness.get(safeNode.id) ?? null } });
501
536
  });
@@ -535,6 +570,8 @@ nodesRouter.post('/register', requireRole('ADMIN', 'OPERATOR'), async (c) => {
535
570
  targetId: node.id,
536
571
  details: { name: body.name, hostname: body.hostname },
537
572
  });
573
+ // The only response that ever carries this token: no cache may keep it.
574
+ c.header('Cache-Control', 'no-store');
538
575
  return c.json({
539
576
  success: true,
540
577
  data: {
@@ -570,7 +607,7 @@ nodesRouter.patch('/:nodeId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
570
607
  throw new HttpError(404, 'NOT_FOUND', 'Node not found');
571
608
  throw err;
572
609
  });
573
- const { token: _token, ...safeNode } = node;
610
+ const safeNode = nodeForRole(node, getUser(c).role);
574
611
  await logAuditEvent({
575
612
  action: 'node.update',
576
613
  actorId: user.userId,
@@ -621,7 +658,7 @@ nodesRouter.post('/:nodeId/repair-address', requireRole('ADMIN', 'OPERATOR'), as
621
658
  const ipChanged = resolvedIp !== node.ipAddress;
622
659
  const hostnameChanged = targetHostname !== node.hostname;
623
660
  if (!ipChanged && !hostnameChanged) {
624
- const { token: _token, ...safeNode } = node;
661
+ const safeNode = nodeForRole(node, getUser(c).role);
625
662
  return c.json({
626
663
  success: true,
627
664
  data: {
@@ -651,7 +688,7 @@ nodesRouter.post('/:nodeId/repair-address', requireRole('ADMIN', 'OPERATOR'), as
651
688
  source,
652
689
  },
653
690
  });
654
- const { token: _token, ...safeNode } = updated;
691
+ const safeNode = nodeForRole(updated, getUser(c).role);
655
692
  return c.json({
656
693
  success: true,
657
694
  data: {
@@ -2,6 +2,8 @@ import { applicationProbeContractDigest, getApplicationProbeCollectorStatus } fr
2
2
  import { lessonSchema, publishLesson, lessonSuggestions, supersedeLesson } from '../services/incident-lessons.js';
3
3
  import { exportIncidentEvidence } from '../services/incident-evidence.js';
4
4
  import { getNodeManagerSecret } from '../utils/env.js';
5
+ import { getNodeBaseUrl } from '../utils/node-url.js';
6
+ import { describeTransportFailure } from '../utils/safe-errors.js';
5
7
  import { Hono } from 'hono';
6
8
  import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
7
9
  import { HttpError } from '../middleware/error-handler.js';
@@ -20,7 +22,8 @@ import { loadRecoveryExerciseAssessmentEvidence } from '../services/recovery-exe
20
22
  export const opsRouter = new Hono();
21
23
  opsRouter.use('*', authMiddleware);
22
24
  opsRouter.get('/dependency-health', async (c) => c.json({ success: true, data: await getDependencyHealth() }));
23
- opsRouter.get('/readiness', async (c) => c.json({ success: true, data: await getFleetReadiness() }));
25
+ opsRouter.get('/readiness', async (c) => c.json({ success: true,
26
+ data: await getFleetReadiness({ includeSecretFingerprints: getUser(c).role === 'ADMIN' }) }));
24
27
  opsRouter.get('/timeline', async (c) => {
25
28
  const limit = Math.min(Number(c.req.query('limit') ?? '80'), 200);
26
29
  return c.json({ success: true, data: await getFleetTimeline(limit) });
@@ -212,15 +215,30 @@ opsRouter.post('/maintenance/:id/cancel', requireRole('ADMIN', 'OPERATOR'), asyn
212
215
  });
213
216
  opsRouter.get('/incidents/:id/evidence', requireRole('ADMIN', 'OPERATOR'), async (c) => c.json({ success: true, data: await exportIncidentEvidence(c.req.param('id')) }));
214
217
  opsRouter.get('/storage-evidence/:nodeId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
215
- const node = await getDb().node.findUnique({ where: { id: c.req.param('nodeId') }, select: { ipAddress: true, port: true } });
218
+ const nodeId = c.req.param('nodeId');
219
+ const node = await getDb().node.findUnique({ where: { id: nodeId }, select: { name: true, ipAddress: true, port: true } });
216
220
  const mount = c.req.query('mount');
217
221
  if (!node || !mount || mount.length > 4096)
218
222
  return c.json({ success: false, error: { message: 'Valid node and mount required' } }, 400);
219
- const host = node.ipAddress.includes(':') ? `[${node.ipAddress}]` : node.ipAddress;
220
- const response = await fetch(`https://${host}:${node.port}/api/v1/storage-evidence?${new URLSearchParams({ mount })}`, { headers: { 'x-node-token': getNodeManagerSecret() }, signal: AbortSignal.timeout(15000), redirect: 'error' });
223
+ const audit = (success) => logAuditEvent({ action: 'storage.investigate', actorId: getUser(c).userId, targetType: 'node', targetId: nodeId, details: { operation: 'storage-evidence', readOnly: true, success } });
224
+ let response;
225
+ try {
226
+ response = await fetch(`${getNodeBaseUrl(node.ipAddress, node.port)}/api/v1/storage-evidence?${new URLSearchParams({ mount })}`, { headers: { 'x-node-token': getNodeManagerSecret() }, signal: AbortSignal.timeout(15000), redirect: 'error' });
227
+ }
228
+ catch (error) {
229
+ await audit(false);
230
+ throw new HttpError(502, 'NODE_UNREACHABLE', `Could not reach the agent on ${node.name}: ${describeTransportFailure(error)}. Check that the agent is running and that this server can reach its port.`);
231
+ }
221
232
  const { boundedJson } = await import('../services/application-probes.js');
222
- const body = await boundedJson(response);
223
- await logAuditEvent({ action: 'storage.investigate', actorId: getUser(c).userId, targetType: 'node', targetId: c.req.param('nodeId'), details: { operation: 'storage-evidence', readOnly: true, success: response.ok } });
233
+ let body;
234
+ try {
235
+ body = await boundedJson(response);
236
+ }
237
+ catch {
238
+ await audit(false);
239
+ throw new HttpError(502, 'NODE_BAD_RESPONSE', `The agent on ${node.name} answered HTTP ${response.status} with no readable storage report. Check that it is running a version that supports storage evidence.`);
240
+ }
241
+ await audit(response.ok);
224
242
  return c.json(body, response.ok ? 200 : 502);
225
243
  });
226
244
  opsRouter.get('/lessons', requireRole('ADMIN', 'OPERATOR'), async (c) => c.json({ success: true, data: await lessonSuggestions((c.req.query('query') ?? '').slice(0, 500)) }));
@@ -3,6 +3,7 @@ import { authMiddleware, requireRole } from '../middleware/auth.js';
3
3
  import { getDb } from '../db/index.js';
4
4
  import { HttpError } from '../middleware/error-handler.js';
5
5
  import { getNodeManagerSecret } from '../utils/env.js';
6
+ import { SENTINEL_NODE_IDS } from '../utils/sentinel-nodes.js';
6
7
  export const profilingRouter = new Hono();
7
8
  profilingRouter.use('*', authMiddleware);
8
9
  /**
@@ -49,7 +50,7 @@ profilingRouter.get('/:nodeId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
49
50
  profilingRouter.get('/', requireRole('ADMIN', 'OPERATOR'), async (c) => {
50
51
  const db = getDb();
51
52
  const nodes = await db.node.findMany({
52
- where: { status: { notIn: ['DECOMMISSIONED', 'OFFLINE'] } },
53
+ where: { id: { notIn: SENTINEL_NODE_IDS }, status: { notIn: ['DECOMMISSIONED', 'OFFLINE'] } },
53
54
  select: { id: true, name: true, ipAddress: true, port: true },
54
55
  });
55
56
  const results = await Promise.allSettled(nodes.map(async (node) => {
@@ -1,4 +1,5 @@
1
1
  import { Hono } from 'hono';
2
+ import { mergeEnvironmentEdit } from '../services/secret-views.js';
2
3
  import { getDb } from '../db/index.js';
3
4
  import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
4
5
  import { logAuditEvent } from '../services/audit-service.js';
@@ -11,17 +12,23 @@ import { serializeStringArray, serializeRecord, parseStringArray, parseStringRec
11
12
  export const rolesRouter = new Hono();
12
13
  rolesRouter.use('*', authMiddleware);
13
14
  /** Transform a DB role record into a clean API response. */
14
- function formatRole(role) {
15
- return {
16
- ...role,
15
+ function formatRole(role, viewer) {
16
+ // Values in envConfig are written to hosts and may be secrets: below ADMIN a
17
+ // role shows its key names only.
18
+ const { envConfig: rawEnvConfig, ...rest } = role;
19
+ const envConfig = parseStringRecord(rawEnvConfig);
20
+ const formatted = {
21
+ ...rest,
17
22
  libraryFileIds: parseStringArray(role.libraryFileIds),
18
23
  skillNames: parseStringArray(role.skillNames),
19
24
  aptPackages: parseStringArray(role.aptPackages),
20
25
  npmPackages: parseStringArray(role.npmPackages),
21
- envConfig: parseStringRecord(role.envConfig),
26
+ ...(viewer === 'ADMIN' ? { envConfig } : {}),
27
+ envConfigKeys: Object.keys(envConfig).sort(),
22
28
  dockerVolumes: parseStringArray(role.dockerVolumes),
23
29
  firewallAllowRules: parseStringArray(role.firewallAllowRules),
24
30
  };
31
+ return formatted;
25
32
  }
26
33
  /** Transform a DB role application record. */
27
34
  function formatApplication(app) {
@@ -39,7 +46,8 @@ rolesRouter.get('/', async (c) => {
39
46
  _count: { select: { nodes: true } },
40
47
  },
41
48
  });
42
- const data = roles.map((r) => formatRole(r));
49
+ const viewer = getUser(c).role;
50
+ const data = roles.map((r) => formatRole(r, viewer));
43
51
  return c.json({ success: true, data });
44
52
  });
45
53
  // ─── Get single role ───
@@ -55,7 +63,7 @@ rolesRouter.get('/:roleId', async (c) => {
55
63
  });
56
64
  if (!role)
57
65
  throw new HttpError(404, 'NOT_FOUND', 'Role not found');
58
- return c.json({ success: true, data: formatRole(role) });
66
+ return c.json({ success: true, data: formatRole(role, getUser(c).role) });
59
67
  });
60
68
  // ─── Create role ───
61
69
  rolesRouter.post('/', requireRole('ADMIN', 'OPERATOR'), async (c) => {
@@ -105,7 +113,7 @@ rolesRouter.post('/', requireRole('ADMIN', 'OPERATOR'), async (c) => {
105
113
  targetId: role.id,
106
114
  details: { name: role.name, status: role.status },
107
115
  });
108
- return c.json({ success: true, data: formatRole(role) }, 201);
116
+ return c.json({ success: true, data: formatRole(role, user.role) }, 201);
109
117
  });
110
118
  // ─── Update role ───
111
119
  rolesRouter.patch('/:roleId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
@@ -131,7 +139,7 @@ rolesRouter.patch('/:roleId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
131
139
  ...(body.skillNames !== undefined && { skillNames: serializeStringArray(body.skillNames) }),
132
140
  ...(body.aptPackages !== undefined && { aptPackages: serializeStringArray(body.aptPackages) }),
133
141
  ...(body.npmPackages !== undefined && { npmPackages: serializeStringArray(body.npmPackages) }),
134
- ...(body.envConfig !== undefined && { envConfig: serializeRecord(body.envConfig) }),
142
+ ...(body.envConfig !== undefined && { envConfig: serializeRecord(user.role === 'ADMIN' ? body.envConfig : mergeEnvironmentEdit(existing.envConfig, body.envConfig)) }),
135
143
  ...(body.dockerEnabled !== undefined && { dockerEnabled: body.dockerEnabled }),
136
144
  ...(body.dockerNonRoot !== undefined && { dockerNonRoot: body.dockerNonRoot }),
137
145
  ...(body.dockerImage !== undefined && { dockerImage: body.dockerImage }),
@@ -149,7 +157,7 @@ rolesRouter.patch('/:roleId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
149
157
  targetId: roleId,
150
158
  details: { name: body.name, status: body.status },
151
159
  });
152
- return c.json({ success: true, data: formatRole(role) });
160
+ return c.json({ success: true, data: formatRole(role, user.role) });
153
161
  });
154
162
  // ─── Delete role ───
155
163
  rolesRouter.delete('/:roleId', requireRole('ADMIN'), async (c) => {