@camstack/server 1.1.76 → 1.2.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.
@@ -0,0 +1,444 @@
1
+ "use strict";
2
+ /**
3
+ * Agent HTTP server -- lightweight Fastify instance for agent status,
4
+ * process management, config editing, and static UI serving.
5
+ */
6
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
7
+ if (k2 === undefined) k2 = k;
8
+ var desc = Object.getOwnPropertyDescriptor(m, k);
9
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
10
+ desc = { enumerable: true, get: function() { return m[k]; } };
11
+ }
12
+ Object.defineProperty(o, k2, desc);
13
+ }) : (function(o, m, k, k2) {
14
+ if (k2 === undefined) k2 = k;
15
+ o[k2] = m[k];
16
+ }));
17
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
18
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
19
+ }) : function(o, v) {
20
+ o["default"] = v;
21
+ });
22
+ var __importStar = (this && this.__importStar) || (function () {
23
+ var ownKeys = function(o) {
24
+ ownKeys = Object.getOwnPropertyNames || function (o) {
25
+ var ar = [];
26
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
27
+ return ar;
28
+ };
29
+ return ownKeys(o);
30
+ };
31
+ return function (mod) {
32
+ if (mod && mod.__esModule) return mod;
33
+ var result = {};
34
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
35
+ __setModuleDefault(result, mod);
36
+ return result;
37
+ };
38
+ })();
39
+ var __importDefault = (this && this.__importDefault) || function (mod) {
40
+ return (mod && mod.__esModule) ? mod : { "default": mod };
41
+ };
42
+ Object.defineProperty(exports, "__esModule", { value: true });
43
+ exports.pickIpv4 = void 0;
44
+ exports.compareVersions = compareVersions;
45
+ exports.readUiDistCandidate = readUiDistCandidate;
46
+ exports.pickUiDist = pickUiDist;
47
+ exports.resolveUiDistDir = resolveUiDistDir;
48
+ exports.getRegistryNodes = getRegistryNodes;
49
+ exports.resolveHubEndpoint = resolveHubEndpoint;
50
+ exports.mapDiscoveredNodes = mapDiscoveredNodes;
51
+ exports.createAgentHttpServer = createAgentHttpServer;
52
+ exports.startAgentHttpServer = startAgentHttpServer;
53
+ const fs = __importStar(require("node:fs"));
54
+ const path = __importStar(require("node:path"));
55
+ const fastify_1 = __importDefault(require("fastify"));
56
+ const derive_hub_url_js_1 = require("./derive-hub-url.js");
57
+ Object.defineProperty(exports, "pickIpv4", { enumerable: true, get: function () { return derive_hub_url_js_1.pickIpv4; } });
58
+ const agent_http_auth_js_1 = require("./agent-http-auth.js");
59
+ /** Dotted-numeric version compare; `null` sorts lowest. */
60
+ function compareVersions(a, b) {
61
+ if (a === null && b === null)
62
+ return 0;
63
+ if (a === null)
64
+ return -1;
65
+ if (b === null)
66
+ return 1;
67
+ const pa = a.split('.').map((s) => Number.parseInt(s, 10) || 0);
68
+ const pb = b.split('.').map((s) => Number.parseInt(s, 10) || 0);
69
+ const len = Math.max(pa.length, pb.length);
70
+ for (let i = 0; i < len; i++) {
71
+ const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
72
+ if (diff !== 0)
73
+ return diff;
74
+ }
75
+ return 0;
76
+ }
77
+ /**
78
+ * Read an agent-ui package dir (`<base>/package.json` + `<base>/dist/index.html`)
79
+ * into a candidate. Returns `null` when the dist is absent/unusable.
80
+ */
81
+ function readUiDistCandidate(baseDir) {
82
+ const dir = path.join(baseDir, 'dist');
83
+ if (!fs.existsSync(path.join(dir, 'index.html')))
84
+ return null;
85
+ let version = null;
86
+ try {
87
+ const parsed = JSON.parse(fs.readFileSync(path.join(baseDir, 'package.json'), 'utf-8'));
88
+ version = typeof parsed.version === 'string' ? parsed.version : null;
89
+ }
90
+ catch {
91
+ version = null;
92
+ }
93
+ return { dir, version };
94
+ }
95
+ /**
96
+ * Pick between the dataDir-installed copy and the app-bundled copy: the
97
+ * NEWER version wins; on a tie the installed copy wins (it is the one
98
+ * `camstack deploy` updates).
99
+ *
100
+ * Rationale: bootstrap addon install is seed-only (first boot), so a
101
+ * packaged desktop app that ships a newer agent-ui in its resources would
102
+ * otherwise keep serving the UI frozen at whatever version was first
103
+ * installed (observed live: an app at v1.1.27 serving the v1.1.1 UI).
104
+ */
105
+ function pickUiDist(installed, bundled) {
106
+ if (installed && bundled) {
107
+ return compareVersions(bundled.version, installed.version) > 0 ? bundled.dir : installed.dir;
108
+ }
109
+ return installed?.dir ?? bundled?.dir ?? null;
110
+ }
111
+ function resolveUiDistDir(dataDir, bundledAddonsDir,
112
+ // Workspace / npm-package sibling — dev checkouts and Docker images where
113
+ // @camstack/addon-agent-ui sits next to @camstack/agent in node_modules.
114
+ // Always version-matched to the agent, so it keeps top priority.
115
+ // Injectable so tests are independent of the workspace checkout.
116
+ siblingDistDir = path.resolve(__dirname, '../../addon-agent-ui/dist')) {
117
+ if (fs.existsSync(path.join(siblingDistDir, 'index.html')))
118
+ return siblingDistDir;
119
+ const installed = readUiDistCandidate(path.join(dataDir, 'addons', '@camstack', 'addon-agent-ui'));
120
+ const bundled = bundledAddonsDir
121
+ ? readUiDistCandidate(path.join(bundledAddonsDir, 'addon-agent-ui'))
122
+ : null;
123
+ const picked = pickUiDist(installed, bundled);
124
+ if (picked)
125
+ return picked;
126
+ const legacy = path.join(dataDir, 'agent-ui');
127
+ if (fs.existsSync(path.join(legacy, 'index.html')))
128
+ return legacy;
129
+ return null;
130
+ }
131
+ function readConfigFile(configPath) {
132
+ if (!fs.existsSync(configPath))
133
+ return {};
134
+ try {
135
+ return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
136
+ }
137
+ catch {
138
+ return {};
139
+ }
140
+ }
141
+ function writeConfigFile(configPath, data) {
142
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
143
+ fs.writeFileSync(configPath, JSON.stringify(data, null, 2), 'utf-8');
144
+ }
145
+ /** Live Moleculer registry snapshot — reused by agent-bootstrap's Option A. */
146
+ function getRegistryNodes(broker) {
147
+ try {
148
+ const registry = broker.registry;
149
+ return registry?.getNodeList?.({ onlyAvailable: true }) ?? [];
150
+ }
151
+ catch {
152
+ return [];
153
+ }
154
+ }
155
+ /**
156
+ * A human-readable, reachable address for the hub node as the agent actually
157
+ * sees it in the Moleculer registry. Used to fill in the effective address
158
+ * even in UDP-discovery mode where no address was manually configured.
159
+ */
160
+ function resolveHubEndpoint(nodes) {
161
+ const hub = nodes.find((n) => n.id === 'hub');
162
+ if (!hub)
163
+ return null;
164
+ return (0, derive_hub_url_js_1.pickIpv4)(hub.ipList) ?? hub.hostname ?? null;
165
+ }
166
+ /**
167
+ * Map raw registry nodes (minus self) to the UI-facing shape: the hub carries
168
+ * its real hostname so the UI can render "hostname (hub)" instead of the bare
169
+ * `hub` node id.
170
+ */
171
+ function mapDiscoveredNodes(nodes, selfId) {
172
+ return nodes
173
+ .filter((n) => n.id !== selfId)
174
+ .map((n) => ({ id: n.id, hostname: n.hostname ?? n.id, isHub: n.id === 'hub' }));
175
+ }
176
+ /**
177
+ * Derive the hub connection state from broker registry + config when no
178
+ * external getter is wired. Does NOT detect `secret-mismatch` — that
179
+ * requires the bootstrap to inject `getHubConnectionState`.
180
+ */
181
+ function deriveHubConnectionState(nodes, discoveryMode) {
182
+ const hubInRegistry = nodes.some((n) => n.id === 'hub');
183
+ if (hubInRegistry)
184
+ return 'connected';
185
+ if (discoveryMode)
186
+ return 'searching';
187
+ return 'disconnected';
188
+ }
189
+ /**
190
+ * Read the current effective config from the persisted file.
191
+ * This is the single source of truth -- always reflects what the UI wrote.
192
+ */
193
+ function getEffectiveConfig(configPath, nodeId) {
194
+ const raw = readConfigFile(configPath);
195
+ return {
196
+ name: typeof raw.name === 'string' ? raw.name : nodeId,
197
+ hubAddress: typeof raw.hubAddress === 'string' ? raw.hubAddress : null,
198
+ hasSecret: typeof raw.secret === 'string' && raw.secret.length > 0,
199
+ };
200
+ }
201
+ // ---------------------------------------------------------------------------
202
+ // Factory
203
+ // ---------------------------------------------------------------------------
204
+ async function createAgentHttpServer(getBroker, config) {
205
+ const app = (0, fastify_1.default)({ logger: false });
206
+ const cors = await Promise.resolve().then(() => __importStar(require('@fastify/cors')));
207
+ await app.register(cors.default);
208
+ // -- Auth gate (port-4444 hardening, mirrors the hub's /health work) --
209
+ // Loopback (Electron renderer, in-container probes) always passes;
210
+ // remote callers need `Authorization: Bearer <cluster secret>`. While
211
+ // the agent is UNPAIRED (no secret configured) only the pairing surface
212
+ // is reachable remotely. Registered as an onRequest hook over the /api
213
+ // + /health/details prefixes so any FUTURE route is gated fail-closed.
214
+ const currentSecret = () => {
215
+ const raw = readConfigFile(config.configPath);
216
+ return typeof raw.secret === 'string' && raw.secret.length > 0 ? raw.secret : null;
217
+ };
218
+ app.addHook('onRequest', async (req, reply) => {
219
+ const pathOnly = req.url.split('?')[0] ?? req.url;
220
+ const isProtected = pathOnly.startsWith('/api/') || pathOnly.startsWith('/health/');
221
+ if (!isProtected)
222
+ return;
223
+ const secret = currentSecret();
224
+ const authInput = {
225
+ remoteAddress: req.socket.remoteAddress ?? undefined,
226
+ ...(typeof req.headers.authorization === 'string'
227
+ ? { authorization: req.headers.authorization }
228
+ : {}),
229
+ };
230
+ if ((0, agent_http_auth_js_1.isAuthorizedAgentRequest)(authInput, secret))
231
+ return;
232
+ if (secret === null && (0, agent_http_auth_js_1.isPairingRequest)(req.method, pathOnly))
233
+ return;
234
+ return reply.status(401).send({ ok: false, error: 'unauthorized' });
235
+ });
236
+ // -- Health ---------------------------------------------------------
237
+ // PUBLIC probe: liveness only — `{ok}`, no nodeId/version/topology (a
238
+ // public endpoint must not enumerate the deployment; same policy as the
239
+ // hub's /health). The detailed shape lives on `$agent.health` (Moleculer
240
+ // action) for the hub, and on the AUTHENTICATED /health/details below
241
+ // for external monitors.
242
+ app.get('/health', async (_req, reply) => {
243
+ try {
244
+ await getBroker().call('$agent.health');
245
+ return { ok: true };
246
+ }
247
+ catch {
248
+ return reply.status(503).send({ ok: false });
249
+ }
250
+ });
251
+ // AUTHENTICATED detailed health — the payload /health used to return.
252
+ app.get('/health/details', async (_req, reply) => {
253
+ try {
254
+ const result = await getBroker().call('$agent.health');
255
+ return result;
256
+ }
257
+ catch (err) {
258
+ const message = err instanceof Error ? err.message : String(err);
259
+ return reply.status(503).send({
260
+ ok: false,
261
+ nodeId: config.nodeId,
262
+ error: message,
263
+ });
264
+ }
265
+ });
266
+ // -- Agent status (enriched with connection state) ------------------
267
+ app.get('/api/agent/status', async () => {
268
+ const broker = getBroker();
269
+ const eff = getEffectiveConfig(config.configPath, config.nodeId);
270
+ const nodes = getRegistryNodes(broker);
271
+ const hubConnected = nodes.some((n) => n.id === 'hub');
272
+ const discoveryMode = !eff.hubAddress;
273
+ const hubConnectionState = config.getHubConnectionState
274
+ ? config.getHubConnectionState()
275
+ : deriveHubConnectionState(nodes, discoveryMode);
276
+ // The address the agent actually reaches the hub at (from the Moleculer
277
+ // registry) — lets the UI show the real endpoint even under UDP discovery
278
+ // where `eff.hubAddress` is null.
279
+ const resolvedHubAddress = resolveHubEndpoint(nodes);
280
+ // Version of the Electron wrapper (injected by the desktop app); absent
281
+ // when the agent runs headless / in Docker.
282
+ const appVersion = process.env['CAMSTACK_AGENT_APP_VERSION'] ?? null;
283
+ const discoveredNodes = mapDiscoveredNodes(nodes, broker.nodeID);
284
+ try {
285
+ const status = (await broker.call('$agent.status'));
286
+ return {
287
+ ...status,
288
+ name: eff.name,
289
+ hubAddress: eff.hubAddress,
290
+ resolvedHubAddress,
291
+ appVersion,
292
+ hubConnected,
293
+ hubConnectionState,
294
+ discoveryMode,
295
+ hasSecret: eff.hasSecret,
296
+ discoveredNodes,
297
+ };
298
+ }
299
+ catch {
300
+ return {
301
+ nodeId: config.nodeId,
302
+ name: eff.name,
303
+ hubAddress: eff.hubAddress,
304
+ resolvedHubAddress,
305
+ appVersion,
306
+ hubConnected,
307
+ hubConnectionState,
308
+ discoveryMode,
309
+ hasSecret: eff.hasSecret,
310
+ discoveredNodes,
311
+ addons: [],
312
+ localIps: [],
313
+ };
314
+ }
315
+ });
316
+ // -- Processes ------------------------------------------------------
317
+ app.get('/api/agent/processes', async () => {
318
+ try {
319
+ return await getBroker().call('$process.list');
320
+ }
321
+ catch {
322
+ return [];
323
+ }
324
+ });
325
+ // -- Addon restart --------------------------------------------------
326
+ app.post('/api/agent/addon/restart', async (req, reply) => {
327
+ const addonId = req.body?.addonId;
328
+ if (!addonId)
329
+ return reply.status(400).send({ error: 'addonId required' });
330
+ return getBroker().call('$agent.restart', { addonId });
331
+ });
332
+ // -- Process restart ------------------------------------------------
333
+ app.post('/api/agent/process/restart', async (req, reply) => {
334
+ const name = req.body?.name;
335
+ if (!name)
336
+ return reply.status(400).send({ error: 'name required' });
337
+ return getBroker().call('$process.restart', { name });
338
+ });
339
+ // -- Config read (always from file -- single source of truth) -------
340
+ app.get('/api/agent/config', async () => {
341
+ const persisted = readConfigFile(config.configPath);
342
+ const eff = getEffectiveConfig(config.configPath, config.nodeId);
343
+ return {
344
+ nodeId: config.nodeId,
345
+ name: eff.name,
346
+ hubAddress: eff.hubAddress,
347
+ hasSecret: eff.hasSecret,
348
+ configPath: config.configPath,
349
+ dataDir: config.dataDir,
350
+ // Include all persisted fields except raw secret
351
+ ...Object.fromEntries(Object.entries(persisted).filter(([k]) => k !== 'secret')),
352
+ };
353
+ });
354
+ // -- Config write (merge-patch + persist) ---------------------------
355
+ // Fastify (not Express) natively awaits async route handlers and serializes
356
+ // the returned value / catches rejections, so an async handler is correct here.
357
+ // eslint-disable-next-line oxc/no-async-endpoint-handlers
358
+ app.post('/api/agent/config', async (req) => {
359
+ const patch = req.body ?? {};
360
+ const existing = readConfigFile(config.configPath);
361
+ const merged = { ...existing, ...patch };
362
+ writeConfigFile(config.configPath, merged);
363
+ // Apply name change immediately (no reconnect needed)
364
+ if (typeof patch.name === 'string' && patch.name.trim()) {
365
+ try {
366
+ await getBroker().call('$agent.rename', { name: patch.name.trim() });
367
+ console.log(`[Agent] Name changed to "${patch.name.trim()}"`);
368
+ }
369
+ catch {
370
+ /* best-effort */
371
+ }
372
+ }
373
+ // Only flag reconnect when a connection-affecting field actually
374
+ // CHANGED. The form posts every visible field on every Save, so
375
+ // `patch.hubAddress !== undefined` was always true and produced
376
+ // false-positive "Restart required" prompts on no-op saves.
377
+ const reconnectRelevant = ['hubAddress', 'secret'];
378
+ const needsReconnect = reconnectRelevant.some((k) => Object.prototype.hasOwnProperty.call(patch, k) && patch[k] !== existing[k]);
379
+ return {
380
+ success: true,
381
+ restartRequired: needsReconnect,
382
+ };
383
+ });
384
+ // -- Agent reconnect (applies new hub/secret config) ----------------
385
+ app.post('/api/agent/restart', async () => {
386
+ if (!config.onReconnect) {
387
+ return { success: false, message: 'Reconnect not available' };
388
+ }
389
+ console.log('[Agent] Reconnect requested from UI');
390
+ void config.onReconnect().catch((err) => {
391
+ console.error('[Agent] Reconnect failed:', err);
392
+ });
393
+ return { success: true, message: 'Agent reconnecting...' };
394
+ });
395
+ // -- Discovered nodes -----------------------------------------------
396
+ app.get('/api/agent/discovered-nodes', async () => {
397
+ const b = getBroker();
398
+ const nodes = getRegistryNodes(b);
399
+ return mapDiscoveredNodes(nodes, b.nodeID);
400
+ });
401
+ // -- Static file serving (agent-ui) ---------------------------------
402
+ const uiDir = resolveUiDistDir(config.dataDir, process.env['CAMSTACK_BUNDLED_ADDONS_DIR']);
403
+ if (uiDir) {
404
+ const fastifyStatic = await Promise.resolve().then(() => __importStar(require('@fastify/static')));
405
+ await app.register(fastifyStatic.default, {
406
+ root: uiDir,
407
+ prefix: '/',
408
+ wildcard: false,
409
+ // MUST stay true: the SPA-fallback below uses `reply.sendFile`, which
410
+ // only exists when the plugin decorates Reply. With `false` every
411
+ // fallback request 500'd with "reply.sendFile is not a function".
412
+ decorateReply: true,
413
+ });
414
+ app.setNotFoundHandler(async (req, reply) => {
415
+ if (req.url.startsWith('/api/') || req.url.startsWith('/health')) {
416
+ return reply.status(404).send({ error: 'Not found' });
417
+ }
418
+ // Never serve index.html to an asset request: a stale index.html
419
+ // referencing missing hashed assets must fail VISIBLY (404 in the
420
+ // renderer console) instead of feeding HTML to a module script,
421
+ // which renders as a silent blank page.
422
+ if (req.url.startsWith('/assets/')) {
423
+ console.warn(`[Agent] UI asset not found (stale index.html?): ${req.url}`);
424
+ return reply.status(404).send({ error: 'Asset not found' });
425
+ }
426
+ return reply.type('text/html').sendFile('index.html');
427
+ });
428
+ console.log(`[Agent] UI served from: ${uiDir}`);
429
+ }
430
+ return app;
431
+ }
432
+ // ---------------------------------------------------------------------------
433
+ // Start helper
434
+ // ---------------------------------------------------------------------------
435
+ async function startAgentHttpServer(getBroker, config) {
436
+ try {
437
+ const app = await createAgentHttpServer(getBroker, config);
438
+ await app.listen({ port: config.port, host: '0.0.0.0' });
439
+ console.log(`[Agent] HTTP server: http://localhost:${config.port}`);
440
+ }
441
+ catch (err) {
442
+ console.warn(`[Agent] HTTP server failed to start on port ${config.port}:`, err);
443
+ }
444
+ }