@camstack/server 1.1.76 → 1.2.1

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,93 @@
1
+ "use strict";
2
+ /**
3
+ * Slice-5, Task 6 — agent-side Moleculer service for forwarding hub-dispatched
4
+ * cap calls to the agent's forked UDS children.
5
+ *
6
+ * The hub's `CapRouteResolver` dispatches an `agent-child-forward` route by
7
+ * calling `callWithServiceDiscovery(broker, AGENT_CAP_FWD_SERVICE,
8
+ * AGENT_CAP_FWD_ACTION, params, { nodeID: agentNodeId })`. This service
9
+ * receives those calls, resolves the child locally via the agent's
10
+ * `LocalChildRegistry`, and forwards the call over UDS.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.createAgentCapDispatchService = createAgentCapDispatchService;
14
+ const system_1 = require("@camstack/system");
15
+ // ---------------------------------------------------------------------------
16
+ // ctx.params narrowing helpers
17
+ // ---------------------------------------------------------------------------
18
+ /**
19
+ * Moleculer types `ctx.params` loosely; narrow it to `AgentCapForwardParams`
20
+ * by checking the required string fields without unsafe casts.
21
+ */
22
+ function narrowParams(raw) {
23
+ if (raw === null || typeof raw !== 'object') {
24
+ throw new Error('$agent-cap-fwd.forward: invalid params — capName and method are required strings');
25
+ }
26
+ const capName = Reflect.get(raw, 'capName');
27
+ const method = Reflect.get(raw, 'method');
28
+ if (typeof capName !== 'string' || typeof method !== 'string') {
29
+ throw new Error('$agent-cap-fwd.forward: invalid params — capName and method are required strings');
30
+ }
31
+ const childId = Reflect.get(raw, 'childId');
32
+ const deviceId = Reflect.get(raw, 'deviceId');
33
+ return {
34
+ capName,
35
+ method,
36
+ args: Reflect.get(raw, 'args'),
37
+ childId: typeof childId === 'string' ? childId : undefined,
38
+ deviceId: typeof deviceId === 'number' ? deviceId : undefined,
39
+ };
40
+ }
41
+ // ---------------------------------------------------------------------------
42
+ // Factory
43
+ // ---------------------------------------------------------------------------
44
+ /**
45
+ * Creates the Moleculer `ServiceSchema` for the agent-side cap-dispatch service.
46
+ *
47
+ * @param agentNodeId The Moleculer nodeId of this agent (used in error messages).
48
+ * @param agentUdsRegistry The agent's `LocalChildRegistry` (or a compatible spy in tests).
49
+ * `LocalChildRegistry` structurally satisfies `HubLocalChildDispatcher`.
50
+ * @param inProcessLookup Optional resolver for caps hosted in the agent's OWN main process
51
+ * (core addons such as `platform-probe`, `metrics-provider` register
52
+ * their singleton providers in the agent's `CapabilityRegistry`, NOT
53
+ * as forked UDS children). Consulted ONLY when no UDS child provides
54
+ * the cap. Without it, agent in-process core caps are unroutable from
55
+ * the hub.
56
+ * @param logger Optional scoped logger; logs the no-provider path at INFO level.
57
+ */
58
+ function createAgentCapDispatchService(agentNodeId, agentUdsRegistry, inProcessLookup, logger) {
59
+ return {
60
+ name: system_1.AGENT_CAP_FWD_SERVICE,
61
+ actions: {
62
+ forward: {
63
+ handler: async (ctx) => {
64
+ const params = narrowParams(ctx.params);
65
+ const { capName, method, args, deviceId } = params;
66
+ // Resolve the child: prefer the explicitly-provided childId (hub may
67
+ // know it from its HubNodeRegistry), otherwise resolve locally.
68
+ const childId = params.childId !== undefined
69
+ ? params.childId
70
+ : agentUdsRegistry.resolveChildId(capName, deviceId);
71
+ if (childId == null) {
72
+ // No forked UDS child owns this cap. It may instead be hosted by a
73
+ // core addon running in the agent's OWN main process (platform-probe,
74
+ // metrics-provider, …) — resolve it against the in-process registry.
75
+ const ref = inProcessLookup?.(capName) ?? null;
76
+ if (ref !== null) {
77
+ return ref.invoke(method, args);
78
+ }
79
+ logger?.info(`agent ${agentNodeId}: no provider for cap "${capName}"${deviceId !== undefined ? ` (deviceId ${deviceId})` : ''}`);
80
+ throw new Error(`agent ${agentNodeId} has no provider for cap "${capName}"${deviceId !== undefined ? ` (deviceId ${deviceId})` : ''}`);
81
+ }
82
+ const input = {
83
+ capName,
84
+ method,
85
+ args,
86
+ ...(deviceId !== undefined ? { deviceId } : {}),
87
+ };
88
+ return agentUdsRegistry.callCapOnChild(childId, input);
89
+ },
90
+ },
91
+ },
92
+ };
93
+ }
@@ -0,0 +1,127 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.loadAgentConfig = loadAgentConfig;
37
+ const fs = __importStar(require("node:fs"));
38
+ const path = __importStar(require("node:path"));
39
+ const crypto = __importStar(require("node:crypto"));
40
+ const os = __importStar(require("node:os"));
41
+ /**
42
+ * Generate a stable nodeId and persist it to the config file.
43
+ *
44
+ * Resolution order (first match wins, persisted on first boot):
45
+ * 1. Existing `nodeId` in the config file — survives env var changes
46
+ * and host renames once seeded.
47
+ * 2. `CAMSTACK_NODE_ID` env var on first boot — lets dev workflows
48
+ * pin a stable, human-readable nodeId (e.g. `dev-agent-0`)
49
+ * without clobbering already-persisted random ids.
50
+ * 3. A fresh random hex (`agent-a1b2c3`) — production fallback when
51
+ * no env var is set.
52
+ *
53
+ * Seeded values get written to the config file so subsequent boots
54
+ * round-trip exactly the same identity (Moleculer nodeID, capability
55
+ * routing, persisted agentSettings — all keyed by this string).
56
+ */
57
+ function ensurePersistedNodeId(configPath, dataDir) {
58
+ const resolvedPath = path.resolve(dataDir, configPath);
59
+ let raw = {};
60
+ if (fs.existsSync(resolvedPath)) {
61
+ try {
62
+ raw = JSON.parse(fs.readFileSync(resolvedPath, 'utf-8'));
63
+ }
64
+ catch {
65
+ /* corrupt file */
66
+ }
67
+ }
68
+ if (typeof raw.nodeId === 'string' && raw.nodeId.length > 0) {
69
+ return raw.nodeId;
70
+ }
71
+ const envSeed = process.env.CAMSTACK_NODE_ID;
72
+ const id = envSeed && envSeed.length > 0 ? envSeed : `agent-${crypto.randomBytes(4).toString('hex')}`;
73
+ raw.nodeId = id;
74
+ try {
75
+ fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
76
+ fs.writeFileSync(resolvedPath, JSON.stringify(raw, null, 2), 'utf-8');
77
+ }
78
+ catch {
79
+ /* best-effort */
80
+ }
81
+ return id;
82
+ }
83
+ function loadAgentConfig(configPath, dataDirOverride) {
84
+ const filePath = configPath ?? process.env.CAMSTACK_AGENT_CONFIG ?? 'agent.json';
85
+ const dataDir = dataDirOverride ?? process.env.CAMSTACK_DATA ?? './camstack-data';
86
+ const resolvedDataDir = path.resolve(dataDir);
87
+ // Name: config file takes priority over env (allows UI rename to stick).
88
+ // Env is the initial seed, config file is the persisted override.
89
+ const envName = process.env.CAMSTACK_NODE_ID ??
90
+ process.env.CAMSTACK_AGENT_NAME ??
91
+ `${os.hostname()}-${os.arch()}`;
92
+ // Environment variables for hub connection (optional — agent starts without)
93
+ const envHubAddress = process.env.CAMSTACK_HUB_ADDRESS || undefined;
94
+ const envSecret = process.env.CAMSTACK_CLUSTER_SECRET || undefined;
95
+ const configFullPath = path.resolve(resolvedDataDir, filePath);
96
+ const nodeId = ensurePersistedNodeId(filePath, resolvedDataDir);
97
+ // Read persisted config — these override env vars
98
+ let fileHubAddress;
99
+ let fileName;
100
+ let fileSecret;
101
+ if (fs.existsSync(configFullPath)) {
102
+ try {
103
+ const raw = JSON.parse(fs.readFileSync(configFullPath, 'utf-8'));
104
+ fileHubAddress = typeof raw.hubAddress === 'string' ? raw.hubAddress : undefined;
105
+ fileName = typeof raw.name === 'string' ? raw.name : undefined;
106
+ fileSecret = typeof raw.secret === 'string' ? raw.secret : undefined;
107
+ }
108
+ catch {
109
+ /* corrupt config */
110
+ }
111
+ }
112
+ // Config file wins over env for all user-editable fields
113
+ const effectiveName = fileName ?? envName;
114
+ const effectiveHub = fileHubAddress ?? envHubAddress;
115
+ const effectiveSecret = fileSecret ?? envSecret;
116
+ return {
117
+ nodeId,
118
+ name: effectiveName,
119
+ hubAddress: effectiveHub,
120
+ dataDir: resolvedDataDir,
121
+ addonsDir: path.resolve(resolvedDataDir, 'addons'),
122
+ logLevel: process.env.CAMSTACK_LOG_LEVEL ?? 'info',
123
+ secret: effectiveSecret,
124
+ configPath: configFullPath,
125
+ statusPort: Number(process.env.CAMSTACK_STATUS_PORT) || 4444,
126
+ };
127
+ }
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.applyDeployedBundle = applyDeployedBundle;
37
+ const fs = __importStar(require("node:fs"));
38
+ const path = __importStar(require("node:path"));
39
+ const node_crypto_1 = require("node:crypto");
40
+ /** Recursively remove a path, ignoring a missing target. */
41
+ function rmrf(target) {
42
+ fs.rmSync(target, { recursive: true, force: true });
43
+ }
44
+ /**
45
+ * Move `from` → `to` atomically when on the same filesystem; fall back to a
46
+ * recursive copy + remove on a cross-device boundary (`EXDEV`). The agent's
47
+ * `/data` addonsDir is frequently a different filesystem from the OS temp dir,
48
+ * so a bare `renameSync` would throw `EXDEV` — the same fallback
49
+ * `AddonInstaller.applyUpdateFromStaged` uses.
50
+ */
51
+ function moveDir(from, to) {
52
+ try {
53
+ fs.renameSync(from, to);
54
+ }
55
+ catch (err) {
56
+ const code = err.code;
57
+ if (code === 'EXDEV') {
58
+ fs.cpSync(from, to, { recursive: true });
59
+ rmrf(from);
60
+ }
61
+ else {
62
+ throw err;
63
+ }
64
+ }
65
+ }
66
+ /**
67
+ * Atomically install a deployed addon bundle into `<addonsDir>/<addonId>`.
68
+ *
69
+ * Unlike the old `$agent.deploy` body (`rm` the live dir, THEN untar into it),
70
+ * this never leaves the live dir missing: extraction happens in a sibling temp
71
+ * dir first, the live dir is swapped in by rename, and any failure restores the
72
+ * previous copy. A killed/timed-out extract (the non-atomic-deploy hazard that
73
+ * a contaminated propagation test hit) leaves the old version fully intact.
74
+ *
75
+ * No `AddonInstaller` manifest dependency — works for any deployed addon,
76
+ * including ones never tracked in the agent's install manifest.
77
+ *
78
+ * @returns the live addon directory path (`<addonsDir>/<addonId>`).
79
+ */
80
+ async function applyDeployedBundle(input) {
81
+ const { addonsDir, addonId, bundle, extract, logger } = input;
82
+ fs.mkdirSync(addonsDir, { recursive: true });
83
+ const liveDir = path.join(addonsDir, addonId);
84
+ // Place the temp + backup dirs as SIBLINGS of the live dir (same parent →
85
+ // same filesystem → atomic rename). Deriving them from the live dir's parent
86
+ // and basename keeps a scoped `addonId` like "@camstack/addon-pipeline" — whose
87
+ // slash would otherwise turn `.<addonId>.next` into a nested path — correct.
88
+ const liveParent = path.dirname(liveDir);
89
+ const liveBase = path.basename(liveDir);
90
+ fs.mkdirSync(liveParent, { recursive: true });
91
+ const token = (0, node_crypto_1.randomBytes)(6).toString('hex');
92
+ const nextDir = path.join(liveParent, `.${liveBase}.next.${token}`);
93
+ const backupDir = path.join(liveParent, `.${liveBase}.backup.${token}`);
94
+ // 1. Extract into a temp dir. If extraction throws (e.g. a killed untar),
95
+ // drop the temp dir and rethrow — the live dir is never touched.
96
+ fs.mkdirSync(nextDir, { recursive: true });
97
+ try {
98
+ await extract(bundle, nextDir);
99
+ }
100
+ catch (err) {
101
+ rmrf(nextDir);
102
+ throw err;
103
+ }
104
+ // 2. Swap. Back up the live dir (if present), then move next → live. On any
105
+ // failure, restore the backup so the addon stays installed at the old
106
+ // version.
107
+ const hadLive = fs.existsSync(liveDir);
108
+ if (hadLive) {
109
+ fs.renameSync(liveDir, backupDir);
110
+ }
111
+ try {
112
+ moveDir(nextDir, liveDir);
113
+ }
114
+ catch (swapErr) {
115
+ if (hadLive) {
116
+ try {
117
+ if (fs.existsSync(liveDir))
118
+ rmrf(liveDir);
119
+ fs.renameSync(backupDir, liveDir);
120
+ }
121
+ catch (restoreErr) {
122
+ logger.error(`agent deploy swap: restore of "${addonId}" failed after a failed swap — manual recovery may be needed`, {
123
+ meta: {
124
+ backupDir,
125
+ error: restoreErr instanceof Error ? restoreErr.message : String(restoreErr),
126
+ },
127
+ });
128
+ }
129
+ }
130
+ rmrf(nextDir);
131
+ throw swapErr;
132
+ }
133
+ // 3. Success — drop the backup.
134
+ if (hadLive)
135
+ rmrf(backupDir);
136
+ return { addonDir: liveDir };
137
+ }
@@ -0,0 +1,175 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.readPackageManifestAddons = readPackageManifestAddons;
37
+ exports.isGroupRunnerAddon = isGroupRunnerAddon;
38
+ exports.registerGroupRunnerProviders = registerGroupRunnerProviders;
39
+ exports.ensureGroupRunner = ensureGroupRunner;
40
+ const fs = __importStar(require("node:fs"));
41
+ const path = __importStar(require("node:path"));
42
+ const types_1 = require("@camstack/types");
43
+ /**
44
+ * Read a package's `camstack.addons[]` declarations directly from its
45
+ * package.json — WITHOUT importing the addon entry module.
46
+ *
47
+ * The reload path must NOT `import()` heavy native addon entries (node-av, the
48
+ * onnxruntime bridge, …) on the agent's MAIN thread: doing so blocks the event
49
+ * loop long enough that `$agent.status` stops answering and the hub drops the
50
+ * node from topology — and a hung import wedges `$agent.reload` outright (the
51
+ * reload-wedge). A group-runner addon only needs its declaration (id,
52
+ * capabilities, execution) to be (re)dispatched to a runner SUBPROCESS, which
53
+ * loads the real module itself. Returns null on a missing/corrupt manifest.
54
+ */
55
+ function readPackageManifestAddons(dir) {
56
+ try {
57
+ const pkgPath = path.join(dir, 'package.json');
58
+ if (!fs.existsSync(pkgPath))
59
+ return null;
60
+ // Documented JSON boundary: the package.json shape is validated field-by-field below.
61
+ const raw = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
62
+ const packageName = typeof raw.name === 'string' ? raw.name : '';
63
+ const packageVersion = typeof raw.version === 'string' ? raw.version : '0.0.0';
64
+ const entries = Array.isArray(raw.camstack?.addons) ? raw.camstack.addons : [];
65
+ const declarations = [];
66
+ for (const entry of entries) {
67
+ if (entry !== null &&
68
+ typeof entry === 'object' &&
69
+ typeof entry.id === 'string') {
70
+ // Boundary cast: validated to have a string `id`; the runtime helpers
71
+ // (isDeployableToAgent / resolveRunnerId) read only `execution` / `id`.
72
+ declarations.push(entry);
73
+ }
74
+ }
75
+ if (!packageName || declarations.length === 0)
76
+ return null;
77
+ return { packageName, packageVersion, declarations };
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ /**
84
+ * Whether a deployed addon must run in a forked group runner (one addon = one
85
+ * process) rather than in-process. True when the addon declares an `execution`
86
+ * block with a placement reachable on an agent (`any-node`/`agent-only`).
87
+ *
88
+ * The default placement is `hub-only`, so a declaration without `execution`
89
+ * is hub-only and not a group-runner addon — and, being hub-only, is never
90
+ * deployable to an agent in the first place.
91
+ */
92
+ function isGroupRunnerAddon(decl) {
93
+ return decl.execution !== undefined && (0, types_1.resolveAddonPlacement)(decl) !== 'hub-only';
94
+ }
95
+ /**
96
+ * Register the broker-call cap proxies + `loadedAddons` bookkeeping for a group
97
+ * of addons whose runner is (now) live. Extracted so the boot path
98
+ * (`loadClusterCapableAddons`) and the reload path (`ensureGroupRunner`) share
99
+ * ONE implementation — preventing the two from drifting (the original cause of
100
+ * the in-process-reload wedge).
101
+ */
102
+ function registerGroupRunnerProviders(deps, addons) {
103
+ for (const a of addons) {
104
+ for (const cap of a.capabilities) {
105
+ const capName = typeof cap === 'string' ? cap : cap.name;
106
+ const proxy = new Proxy({}, {
107
+ get(_target, prop) {
108
+ if (prop === 'then' || typeof prop === 'symbol')
109
+ return undefined;
110
+ return (params) => deps.broker.call(`${a.addonId}.${capName}.${prop}`, params);
111
+ },
112
+ });
113
+ // Idempotent (re)registration. The reload path (`ensureGroupRunner`)
114
+ // re-runs this after a `$process.restart` of the SUBPROCESS, but the
115
+ // agent's central registry still holds the proxy from the previous
116
+ // registration — the subprocess restart never touched it. Since
117
+ // `registerProvider` throws on a duplicate `(cap, addonId)` pair (a
118
+ // deliberate guard against a double `registerProvider` in one init
119
+ // path), drop the stale proxy first so the fresh one installs cleanly.
120
+ // On the boot path nothing is registered yet, so this is a no-op.
121
+ // Without this, an in-place addon update/redeploy leaves the whole group
122
+ // stuck in `error` until a full agent (main-process) restart clears the
123
+ // registry.
124
+ if (deps.capabilityRegistry.hasProvider(capName, a.addonId)) {
125
+ deps.capabilityRegistry.unregisterProvider(capName, a.addonId);
126
+ }
127
+ deps.capabilityRegistry.registerProvider(capName, a.addonId, proxy);
128
+ }
129
+ deps.loadedAddons.set(a.addonId, {
130
+ id: a.addonId,
131
+ status: 'running',
132
+ version: a.version,
133
+ packageName: a.packageName,
134
+ packageVersion: a.packageVersion,
135
+ });
136
+ }
137
+ }
138
+ /**
139
+ * Ensure a group runner is live with the CURRENT on-disk addon code, then wire
140
+ * its providers.
141
+ *
142
+ * A redeploy's runner is already running (the deploy step swapped its on-disk
143
+ * code but never killed the process), and `$process.spawnRunner` throws
144
+ * "already running" for a live runnerId. So restart first — `$process.restart`
145
+ * stops the old child, evicts it from the Moleculer registry, and re-spawns it
146
+ * from the same dir (now holding the new code) in a subprocess, off the agent's
147
+ * main event loop. Only when no runner exists yet (first deploy →
148
+ * `{success:false, reason:'not found'}`) do we `$process.spawnRunner`.
149
+ *
150
+ * On a hard failure of both paths the addons are marked `error` (no providers
151
+ * registered) so `$agent.status` reports the degraded state.
152
+ */
153
+ async function ensureGroupRunner(deps, groupId, addons) {
154
+ try {
155
+ const restart = await deps.broker.call('$process.restart', { name: groupId });
156
+ if (!restart.success) {
157
+ if (restart.reason !== undefined && restart.reason !== 'not found') {
158
+ deps.logger.warn(`group "${groupId}" restart returned "${restart.reason}" — spawning a fresh runner`);
159
+ }
160
+ await deps.broker.call('$process.spawnRunner', {
161
+ runnerId: groupId,
162
+ addons: addons.map((a) => ({ addonId: a.addonId, addonDir: a.addonDir })),
163
+ });
164
+ }
165
+ registerGroupRunnerProviders(deps, addons);
166
+ deps.logger.info(`group "${groupId}" live with ${addons.length} addon(s): ${addons.map((a) => a.addonId).join(', ')}`);
167
+ }
168
+ catch (err) {
169
+ const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
170
+ deps.logger.error(`failed to ensure group "${groupId}": ${msg}`);
171
+ for (const a of addons) {
172
+ deps.loadedAddons.set(a.addonId, { id: a.addonId, status: 'error' });
173
+ }
174
+ }
175
+ }
@@ -0,0 +1,76 @@
1
+ "use strict";
2
+ /**
3
+ * Agent HTTP auth — pure request-authorization helpers for the agent's
4
+ * port-4444 API (mirrors the hub's /health hardening).
5
+ *
6
+ * The surface was fully unauthenticated on 0.0.0.0 — including
7
+ * `POST /api/agent/config`, which can rewrite `hubAddress` and the
8
+ * cluster secret itself. Model:
9
+ * - loopback callers (the Electron renderer, in-container probes) are
10
+ * always allowed — the desktop app keeps working with zero changes,
11
+ * - remote callers must present `Authorization: Bearer <cluster secret>`
12
+ * — the secret is the agent↔hub trust anchor and the only credential
13
+ * an agent can verify without a user database,
14
+ * - an UNPAIRED agent (no secret configured yet) can be set up remotely
15
+ * through the pairing surface only; everything else stays denied.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.isLoopbackAddress = isLoopbackAddress;
19
+ exports.bearerMatchesSecret = bearerMatchesSecret;
20
+ exports.isAuthorizedAgentRequest = isAuthorizedAgentRequest;
21
+ exports.isPairingRequest = isPairingRequest;
22
+ const node_crypto_1 = require("node:crypto");
23
+ /** IPv4/IPv6 loopback (incl. the IPv4-mapped IPv6 form Node reports). */
24
+ function isLoopbackAddress(addr) {
25
+ if (!addr)
26
+ return false;
27
+ if (addr === '::1')
28
+ return true;
29
+ const v4 = addr.startsWith('::ffff:') ? addr.slice('::ffff:'.length) : addr;
30
+ // 127.0.0.0/8 — every octet must be numeric so '127.evil.host' fails.
31
+ const parts = v4.split('.');
32
+ if (parts.length !== 4 || parts[0] !== '127')
33
+ return false;
34
+ return parts.every((p) => /^\d{1,3}$/.test(p));
35
+ }
36
+ /** Timing-safe `Authorization: Bearer <secret>` compare. Empty secrets never match. */
37
+ function bearerMatchesSecret(authorization, secret) {
38
+ if (secret.length === 0)
39
+ return false;
40
+ if (!authorization?.startsWith('Bearer '))
41
+ return false;
42
+ const token = Buffer.from(authorization.slice('Bearer '.length));
43
+ const expected = Buffer.from(secret);
44
+ return token.length === expected.length && (0, node_crypto_1.timingSafeEqual)(token, expected);
45
+ }
46
+ /**
47
+ * True when the request may access the protected agent API. `clusterSecret`
48
+ * is the CURRENTLY configured secret (`null`/empty = unpaired agent — no
49
+ * remote credential can be verified, so remote access is denied except for
50
+ * the pairing surface, which the route layer allows via {@link isPairingRequest}).
51
+ */
52
+ function isAuthorizedAgentRequest(input, clusterSecret) {
53
+ if (isLoopbackAddress(input.remoteAddress))
54
+ return true;
55
+ if (clusterSecret === null || clusterSecret.length === 0)
56
+ return false;
57
+ return bearerMatchesSecret(input.authorization, clusterSecret);
58
+ }
59
+ /**
60
+ * The first-boot pairing surface: what a remote browser needs to configure
61
+ * a factory-fresh agent (read status/config, write hub address + secret,
62
+ * trigger the reconnect). Process control and health details are NEVER
63
+ * part of it. Only consulted while the agent has no secret configured.
64
+ */
65
+ function isPairingRequest(method, urlPath) {
66
+ const pathOnly = urlPath.split('?')[0] ?? urlPath;
67
+ if (method === 'GET') {
68
+ return (pathOnly === '/api/agent/status' ||
69
+ pathOnly === '/api/agent/config' ||
70
+ pathOnly === '/api/agent/discovered-nodes');
71
+ }
72
+ if (method === 'POST') {
73
+ return pathOnly === '/api/agent/config' || pathOnly === '/api/agent/restart';
74
+ }
75
+ return false;
76
+ }