@mmmbuto/nexuscrew 0.8.50 → 0.8.52-rc.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/CHANGELOG.md +54 -0
- package/README.md +18 -0
- package/frontend/dist/assets/index-DNQLAGoa.css +32 -0
- package/frontend/dist/assets/index-qLF-zV3C.js +93 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/fleet/managed.js +11 -2
- package/lib/mcp/tools.js +174 -0
- package/lib/proxy/federation.js +26 -5
- package/lib/server.js +15 -0
- package/lib/vl-nodes/broker.js +228 -0
- package/lib/vl-nodes/routes.js +195 -0
- package/lib/vl-nodes/store.js +203 -0
- package/package.json +1 -1
- package/skills/nexuscrew-agent/SKILL.md +7 -1
- package/frontend/dist/assets/index-43DFO1EH.css +0 -32
- package/frontend/dist/assets/index-kj-l-RDc.js +0 -93
package/frontend/dist/index.html
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
<meta name="apple-mobile-web-app-title" content="NexusCrew" />
|
|
12
12
|
<link rel="manifest" href="/manifest.json" />
|
|
13
13
|
<title>NexusCrew</title>
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
15
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-qLF-zV3C.js"></script>
|
|
15
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DNQLAGoa.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
18
18
|
<div id="root"></div>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.8.
|
|
1
|
+
{"version":"0.8.51"}
|
package/lib/fleet/managed.js
CHANGED
|
@@ -19,6 +19,15 @@ const OLLAMA_CONTEXT = Object.freeze({
|
|
|
19
19
|
'minimax-m3': 524288, 'qwen3.5:397b': 262144, 'deepseek-v4-flash': 1048576,
|
|
20
20
|
'mistral-large-3:675b': 262144, 'gemma4:31b': 262144,
|
|
21
21
|
});
|
|
22
|
+
// Lo stesso modello si scrive con o senza tag ('deepseek-v4-flash' e
|
|
23
|
+
// 'deepseek-v4-flash:0731'). Si prova prima la chiave esatta, perche' nella
|
|
24
|
+
// mappa ci sono nomi in cui il tag fa parte dell'identita' del modello
|
|
25
|
+
// ('qwen3.5:397b'), e solo dopo si ricade sul nome base. Troncare sempre al
|
|
26
|
+
// primo ':' romperebbe proprio quelle voci.
|
|
27
|
+
function ollamaContextFor(model) {
|
|
28
|
+
const key = String(model || '');
|
|
29
|
+
return OLLAMA_CONTEXT[key] ?? OLLAMA_CONTEXT[key.split(':')[0]];
|
|
30
|
+
}
|
|
22
31
|
const ALIBABA_TOKEN_PLAN_MODELS = Object.freeze([
|
|
23
32
|
'qwen3.8-max-preview', 'qwen3.7-plus', 'qwen3.7-max',
|
|
24
33
|
'qwen3.6-flash', 'glm-5.2', 'deepseek-v4-pro',
|
|
@@ -849,7 +858,7 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
849
858
|
} else {
|
|
850
859
|
const endpoint = spec.baseUrl || profile.endpoint;
|
|
851
860
|
const token = profile.auth === 'none' ? 'ollama' : cred.value;
|
|
852
|
-
const contextWindow =
|
|
861
|
+
const contextWindow = ollamaContextFor(model) ?? (spec.provider === 'zai' ? 1000000 : 200000);
|
|
853
862
|
Object.assign(env, {
|
|
854
863
|
ANTHROPIC_BASE_URL: endpoint, ANTHROPIC_AUTH_TOKEN: token, ANTHROPIC_API_KEY: '',
|
|
855
864
|
ANTHROPIC_MODEL: model, ANTHROPIC_DEFAULT_OPUS_MODEL: model,
|
|
@@ -866,7 +875,7 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
866
875
|
else if (spec.provider === 'ollama-cloud') {
|
|
867
876
|
env.OPENAI_API_KEY = cred.value;
|
|
868
877
|
args.push(...codexProviderArgs('ollama_cloud', 'Ollama Cloud', profile.endpoint, 'OPENAI_API_KEY'));
|
|
869
|
-
args.push('-c', 'model_providers.ollama_cloud.stream_idle_timeout_ms=600000', '-c', `model_context_window=${
|
|
878
|
+
args.push('-c', 'model_providers.ollama_cloud.stream_idle_timeout_ms=600000', '-c', `model_context_window=${ollamaContextFor(model) ?? 200000}`);
|
|
870
879
|
const localCatalog = path.join(home, '.codex', 'ollama_cloud_model_catalog.json');
|
|
871
880
|
if (fs.existsSync(localCatalog)) args.push('-c', `model_catalog_json="${localCatalog}"`);
|
|
872
881
|
} else if (spec.provider === 'openrouter') {
|
package/lib/mcp/tools.js
CHANGED
|
@@ -147,6 +147,92 @@ async function readLocalCellDirectory(ctx, callerSession) {
|
|
|
147
147
|
return { nodeId, cells: normalizeCellPayload(payload, owner, callerSession) };
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
const VL_TARGET_RE = /^([a-f0-9]{16,64}):VL-([a-f0-9]{32})$/;
|
|
151
|
+
|
|
152
|
+
function parseVlTarget(value) {
|
|
153
|
+
const match = VL_TARGET_RE.exec(String(value || ''));
|
|
154
|
+
return match ? { instanceId: match[1], nodeId: match[2], id: match[0] } : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function requireActiveLocalSender(ctx, tool) {
|
|
158
|
+
const identity = await ctx.identity();
|
|
159
|
+
const callerSession = requireSession(identity.session, tool, identity.code);
|
|
160
|
+
const directory = await readLocalCellDirectory(ctx, callerSession);
|
|
161
|
+
if (!directory.cells.some((cell) => cell.self && cell.active)) {
|
|
162
|
+
throw new Error(`${tool}: la sessione chiamante non e' una cella Fleet attiva locale`);
|
|
163
|
+
}
|
|
164
|
+
return callerSession;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function readVlDirectory(ctx) {
|
|
168
|
+
const [config, topology] = await Promise.all([
|
|
169
|
+
ctx.api('GET', '/api/config'), ctx.api('GET', '/api/topology'),
|
|
170
|
+
]);
|
|
171
|
+
const localId = String(config && config.instanceId || '');
|
|
172
|
+
if (!NODE_ID_RE.test(localId)) throw new Error('instanceId locale non disponibile');
|
|
173
|
+
const owners = [{ instanceId: localId, route: [], label: 'Local', stale: false },
|
|
174
|
+
...topologyOwners(topology).filter((owner) => !owner.stale && owner.instanceId !== localId)];
|
|
175
|
+
const nodes = [];
|
|
176
|
+
const availableOwners = [];
|
|
177
|
+
const unavailable = [];
|
|
178
|
+
await Promise.all(owners.map(async (owner) => {
|
|
179
|
+
// Le route /vl-nodes/* non sono federabili (NOTE in lib/proxy/federation.js).
|
|
180
|
+
// Un owner remoto non e' irraggiungibile: e' chiuso per decisione. Chiamarlo
|
|
181
|
+
// per raccogliere il 404 e riportarlo come 'unreachable' descriverebbe un
|
|
182
|
+
// confine sano come un guasto, e manderebbe chi diagnostica a cercare un
|
|
183
|
+
// tunnel che sta benissimo — proprio quando quel campo serve a distinguere
|
|
184
|
+
// un nodo davvero muto.
|
|
185
|
+
if (owner.route.length) {
|
|
186
|
+
unavailable.push({
|
|
187
|
+
instanceId: owner.instanceId, owner: owner.label,
|
|
188
|
+
route: owner.route.join('/'), failure: 'policy-denied',
|
|
189
|
+
});
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const apiPath = '/api/vl-nodes';
|
|
193
|
+
try {
|
|
194
|
+
const payload = await ctx.api('GET', apiPath);
|
|
195
|
+
if (String(payload && payload.instanceId || '') !== owner.instanceId
|
|
196
|
+
|| !Array.isArray(payload && payload.nodes)) throw new Error('directory VL owner mismatch');
|
|
197
|
+
const route = owner.route.length ? owner.route.join('/') : 'local';
|
|
198
|
+
availableOwners.push({ instanceId: owner.instanceId, owner: owner.label, route });
|
|
199
|
+
for (const node of payload.nodes) {
|
|
200
|
+
const expected = `${owner.instanceId}:VL-${node && node.nodeId}`;
|
|
201
|
+
if (!node || !/^[a-f0-9]{32}$/.test(String(node.nodeId || '')) || node.id !== expected) continue;
|
|
202
|
+
nodes.push({
|
|
203
|
+
id: expected, instanceId: owner.instanceId, nodeId: node.nodeId,
|
|
204
|
+
cell: typeof node.cell === 'string' ? node.cell : `VL-${node.nodeId.slice(0, 8)}`,
|
|
205
|
+
label: typeof node.label === 'string' ? node.label : 'VL', route,
|
|
206
|
+
online: node.online === true, canManage: node.canManage === true,
|
|
207
|
+
lastSeen: Number.isSafeInteger(node.lastSeen) ? node.lastSeen : null,
|
|
208
|
+
generation: Number.isSafeInteger(node.generation) ? node.generation : 0,
|
|
209
|
+
version: typeof node.version === 'string' ? node.version : '',
|
|
210
|
+
capabilities: Array.isArray(node.capabilities) ? node.capabilities : [],
|
|
211
|
+
health: node.health && typeof node.health === 'object' ? node.health : null,
|
|
212
|
+
inflight: node.inflight && typeof node.inflight === 'object' ? node.inflight : null,
|
|
213
|
+
lastAck: node.lastAck && typeof node.lastAck === 'object' ? node.lastAck : null,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
} catch (error) {
|
|
217
|
+
unavailable.push({
|
|
218
|
+
instanceId: owner.instanceId, owner: owner.label,
|
|
219
|
+
route: owner.route.length ? owner.route.join('/') : 'local',
|
|
220
|
+
failure: /timeout/i.test(String(error && error.message || error)) ? 'timeout' : 'unreachable',
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}));
|
|
224
|
+
availableOwners.sort((a, b) => (a.route === 'local' ? -1 : b.route === 'local' ? 1 : a.route.localeCompare(b.route)));
|
|
225
|
+
nodes.sort((a, b) => (a.route === 'local' ? -1 : b.route === 'local' ? 1 : a.route.localeCompare(b.route))
|
|
226
|
+
|| a.cell.localeCompare(b.cell));
|
|
227
|
+
unavailable.sort((a, b) => a.route.localeCompare(b.route));
|
|
228
|
+
return { nodeId: localId, owners: availableOwners, nodes, unavailable };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function vlOwnerPath(owner, leaf = '') {
|
|
232
|
+
const resource = leaf ? `vl-nodes/${leaf}` : 'vl-nodes';
|
|
233
|
+
return owner.route === 'local' ? `/api/${resource}` : routePath(owner.route.split('/'), resource);
|
|
234
|
+
}
|
|
235
|
+
|
|
150
236
|
// --- definizione tool (prefisso nc_ anti-collisione) ---------------------------
|
|
151
237
|
// annotations.readOnlyHint:true sui tool che non mutano nulla (§1).
|
|
152
238
|
const TOOLS = [
|
|
@@ -368,6 +454,94 @@ const TOOLS = [
|
|
|
368
454
|
return readCellDirectory(ctx, callerSession);
|
|
369
455
|
},
|
|
370
456
|
},
|
|
457
|
+
{
|
|
458
|
+
name: 'nc_vl_nodes',
|
|
459
|
+
description: 'Directory read-only dei micro-device VL pairati sui nodi NexusCrew autorizzati. online richiede un poll end-to-end recente; usa l id owner-qualified esatto per i comandi.',
|
|
460
|
+
inputSchema: { type: 'object', properties: {} },
|
|
461
|
+
annotations: { readOnlyHint: true },
|
|
462
|
+
async handler(_args, ctx) {
|
|
463
|
+
return readVlDirectory(ctx);
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
name: 'nc_vl_invite',
|
|
468
|
+
description: 'Crea un invite VL one-time bounded su un owner NexusCrew autorizzato. Richiede una cella Fleet locale attiva; il valore restituito e un segreto effimero da consegnare fuori da log e argv.',
|
|
469
|
+
inputSchema: {
|
|
470
|
+
type: 'object',
|
|
471
|
+
properties: {
|
|
472
|
+
ownerId: { type: 'string', description: 'instanceId esatto restituito da nc_vl_nodes' },
|
|
473
|
+
label: { type: 'string', description: 'label device, max 48 caratteri' },
|
|
474
|
+
ttlSeconds: { type: 'integer', minimum: 30, maximum: 3600 },
|
|
475
|
+
},
|
|
476
|
+
required: ['ownerId', 'label'],
|
|
477
|
+
},
|
|
478
|
+
async handler(args, ctx) {
|
|
479
|
+
await requireActiveLocalSender(ctx, 'nc_vl_invite');
|
|
480
|
+
const ownerId = argString(args, 'ownerId', { required: true, max: 64 });
|
|
481
|
+
const label = argString(args, 'label', { required: true, max: 48 });
|
|
482
|
+
const ttlSeconds = args.ttlSeconds === undefined ? 600 : args.ttlSeconds;
|
|
483
|
+
if (!NODE_ID_RE.test(ownerId) || !Number.isSafeInteger(ttlSeconds)
|
|
484
|
+
|| ttlSeconds < 30 || ttlSeconds > 3600) throw new Error('ownerId o ttlSeconds non valido');
|
|
485
|
+
const directory = await readVlDirectory(ctx);
|
|
486
|
+
const owner = directory.owners.find((item) => item.instanceId === ownerId);
|
|
487
|
+
if (!owner) throw new Error('owner VL non disponibile nella rete autorizzata');
|
|
488
|
+
const apiPath = vlOwnerPath(owner, 'invite');
|
|
489
|
+
if (!apiPath) throw new Error('route owner VL non valida');
|
|
490
|
+
const result = await ctx.api('POST', apiPath, { label, ttlSeconds });
|
|
491
|
+
return { ...result, note: 'invite e segreto effimero: non inserirlo in argv, log o handoff' };
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
name: 'nc_vl_command',
|
|
496
|
+
description: 'Invia un comando bounded a un micro-device VL online. submitted conferma solo consegna al poll live; il completamento richiede lastAck con lo stesso id.',
|
|
497
|
+
inputSchema: {
|
|
498
|
+
type: 'object',
|
|
499
|
+
properties: {
|
|
500
|
+
target: { type: 'string', description: 'id owner-qualified esatto restituito da nc_vl_nodes' },
|
|
501
|
+
kind: { type: 'string', enum: ['status', 'health', 'start', 'stop', 'restart', 'version', 'capabilities', 'logs', 'update_candidate', 'unpair'] },
|
|
502
|
+
args: { type: 'object', description: 'argomenti exact del comando bounded' },
|
|
503
|
+
},
|
|
504
|
+
required: ['target', 'kind'],
|
|
505
|
+
},
|
|
506
|
+
async handler(args, ctx) {
|
|
507
|
+
await requireActiveLocalSender(ctx, 'nc_vl_command');
|
|
508
|
+
const target = parseVlTarget(argString(args, 'target', { required: true, max: 128 }));
|
|
509
|
+
const kind = argString(args, 'kind', { required: true, max: 32 });
|
|
510
|
+
const commandArgs = args.args === undefined ? {} : args.args;
|
|
511
|
+
if (!target || !commandArgs || typeof commandArgs !== 'object' || Array.isArray(commandArgs)
|
|
512
|
+
|| JSON.stringify(commandArgs).length > 4096) throw new Error('target o args VL non validi');
|
|
513
|
+
const directory = await readVlDirectory(ctx);
|
|
514
|
+
const node = directory.nodes.find((item) => item.id === target.id);
|
|
515
|
+
if (!node) throw new Error('micro-device VL non trovato nella rete autorizzata');
|
|
516
|
+
if (!node.canManage || !node.online) throw new Error('micro-device VL offline; nessun comando accodato');
|
|
517
|
+
const apiPath = vlOwnerPath(node, `${target.nodeId}/commands`);
|
|
518
|
+
if (!apiPath) throw new Error('route micro-device VL non valida');
|
|
519
|
+
const receipt = await ctx.api('POST', apiPath, { kind, args: commandArgs });
|
|
520
|
+
return { ...receipt, target: target.id,
|
|
521
|
+
note: receipt.note || 'submitted conferma il trasporto live; completamento solo via lastAck' };
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
name: 'nc_vl_revoke',
|
|
526
|
+
description: 'Revoca esplicitamente un pairing VL su un owner autorizzato. Richiede una cella Fleet locale attiva e l id owner-qualified esatto.',
|
|
527
|
+
inputSchema: {
|
|
528
|
+
type: 'object', properties: {
|
|
529
|
+
target: { type: 'string', description: 'id owner-qualified esatto restituito da nc_vl_nodes' },
|
|
530
|
+
}, required: ['target'],
|
|
531
|
+
},
|
|
532
|
+
async handler(args, ctx) {
|
|
533
|
+
await requireActiveLocalSender(ctx, 'nc_vl_revoke');
|
|
534
|
+
const target = parseVlTarget(argString(args, 'target', { required: true, max: 128 }));
|
|
535
|
+
if (!target) throw new Error('target VL non valido');
|
|
536
|
+
const directory = await readVlDirectory(ctx);
|
|
537
|
+
const node = directory.nodes.find((item) => item.id === target.id);
|
|
538
|
+
if (!node) throw new Error('micro-device VL non trovato nella rete autorizzata');
|
|
539
|
+
const apiPath = vlOwnerPath(node, target.nodeId);
|
|
540
|
+
if (!apiPath) throw new Error('route micro-device VL non valida');
|
|
541
|
+
await ctx.api('DELETE', apiPath);
|
|
542
|
+
return { target: target.id, revoked: true };
|
|
543
|
+
},
|
|
544
|
+
},
|
|
371
545
|
{
|
|
372
546
|
name: 'nc_cell_diagnostics',
|
|
373
547
|
description: 'Diagnostica read-only target-specifica di una cella Fleet locale: command Shell redatto e ultima causa spawn/start bounded. Richiede un caller Fleet locale attivo; non attraversa la federazione.',
|
package/lib/proxy/federation.js
CHANGED
|
@@ -105,16 +105,37 @@ function knownResource(resource) {
|
|
|
105
105
|
|| resource === '/files/upload'
|
|
106
106
|
|| resource === '/cells'
|
|
107
107
|
|| resource === '/cells/send'
|
|
108
|
+
// NOTE: `/vl-nodes`, `/vl-nodes/invite`, `/vl-nodes/:id` and
|
|
109
|
+
// `/vl-nodes/:id/commands` were federated when the VL bridge was written.
|
|
110
|
+
// They were removed before the bridge ever shipped. `commands` accepts
|
|
111
|
+
// `update_candidate`, which names the URL the device downloads its own
|
|
112
|
+
// update from — and accepts `http:` — while the `sha256` guarantees
|
|
113
|
+
// nothing, because the sender supplies both the URL and the hash. Federated,
|
|
114
|
+
// that lets a paired peer install an arbitrary binary on someone else's
|
|
115
|
+
// device.
|
|
116
|
+
//
|
|
117
|
+
// Same class as `/settings/peering/invite` below: a capability that extends
|
|
118
|
+
// trust past the boundary the owner chose. Denied by default; it can be
|
|
119
|
+
// federated the day the update channel is bound to something the receiving
|
|
120
|
+
// owner controls, not before. Local `/vl-nodes/*` calls are unaffected —
|
|
121
|
+
// this is the federation allowlist only.
|
|
108
122
|
|| resource === '/decks'
|
|
109
123
|
|| /^\/decks\/[a-z0-9-]{1,32}$/.test(resource)
|
|
110
124
|
|| resource === '/topology'
|
|
111
125
|
|| resource === '/diagnostics/status'
|
|
112
126
|
|| resource === '/diagnostics/logs'
|
|
113
127
|
|| resource === '/diagnostics/verbose'
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
128
|
+
// NOTE: `/settings/peering/invite` used to live here, so a connected client
|
|
129
|
+
// could ask its hub to mint a hub-owned invite. It was removed on
|
|
130
|
+
// 2026-08-04. The invite is bound to the HUB's instanceId: whoever consumes
|
|
131
|
+
// it joins the hub, not the peer that asked for it. So any paired node could
|
|
132
|
+
// obtain a live invite and hand it to a third party, and the hub's operator
|
|
133
|
+
// would neither act nor know — trust became transitive without a decision.
|
|
134
|
+
//
|
|
135
|
+
// A paired node is otherwise trusted as its owner (see docs/SECURITY.md),
|
|
136
|
+
// and that is deliberate. This one is different: it is the capability that
|
|
137
|
+
// ADMITS a further node, so leaving it federated would let a peer break the
|
|
138
|
+
// very boundary the owner chose. Minting an invite is done on the hub.
|
|
118
139
|
|| resource === '/ws'
|
|
119
140
|
|| resource === '/audio/capability'
|
|
120
141
|
|| resource === '/audio/speak'
|
|
@@ -134,6 +155,7 @@ function allowedResource(resource, method = 'GET') {
|
|
|
134
155
|
if (resource === '/files/upload') return method === 'POST';
|
|
135
156
|
if (resource === '/cells') return method === 'GET';
|
|
136
157
|
if (resource === '/cells/send') return method === 'POST';
|
|
158
|
+
// `/vl-nodes/*` is deliberately absent — see the NOTE in `knownResource`.
|
|
137
159
|
if (resource === '/decks') return method === 'GET' || method === 'POST';
|
|
138
160
|
if (/^\/decks\/[a-z0-9-]{1,32}$/.test(resource)) {
|
|
139
161
|
return method === 'PUT' || method === 'PATCH' || method === 'DELETE';
|
|
@@ -142,7 +164,6 @@ function allowedResource(resource, method = 'GET') {
|
|
|
142
164
|
if (resource === '/diagnostics/status') return method === 'GET';
|
|
143
165
|
if (resource === '/diagnostics/logs') return method === 'GET' || method === 'DELETE';
|
|
144
166
|
if (resource === '/diagnostics/verbose') return method === 'PATCH';
|
|
145
|
-
if (resource === '/settings/peering/invite') return method === 'POST';
|
|
146
167
|
if (resource === '/ws') return method === 'GET';
|
|
147
168
|
if (/^\/fleet\/(status|schema|definitions|credentials\/status)$/.test(resource)) return method === 'GET';
|
|
148
169
|
if (/^\/fleet\/(credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource)) return method === 'POST';
|
package/lib/server.js
CHANGED
|
@@ -56,6 +56,9 @@ const { notifyRoutes } = require('./notify/routes.js');
|
|
|
56
56
|
const { createNpmUpdater } = require('./update/manager.js');
|
|
57
57
|
const { createDiagnostics } = require('./diagnostics/store.js');
|
|
58
58
|
const { diagnosticsRoutes } = require('./diagnostics/routes.js');
|
|
59
|
+
const vlNodeStore = require('./vl-nodes/store.js');
|
|
60
|
+
const { createBroker: createVlNodeBroker } = require('./vl-nodes/broker.js');
|
|
61
|
+
const { publicRoutes: vlNodePublicRoutes, apiRoutes: vlNodeApiRoutes } = require('./vl-nodes/routes.js');
|
|
59
62
|
|
|
60
63
|
function sessionExists(tmuxBin, name) {
|
|
61
64
|
if (typeof name !== 'string' || !/^[\w.@%:+-]{1,128}$/.test(name)) return false;
|
|
@@ -141,6 +144,9 @@ function createServer(opts = {}) {
|
|
|
141
144
|
// token / add-remove nodi visibili senza restart). token MAI redatto qui: e'
|
|
142
145
|
// il valore che il proxy inietta upstream, non esce mai verso il browser.
|
|
143
146
|
const nodesPath = cfg.nodesPath || nodesStore.defaultNodesPath(cfg.home || os.homedir());
|
|
147
|
+
const vlNodesPath = cfg.vlNodesPath || vlNodeStore.defaultPath(cfg.home || os.homedir());
|
|
148
|
+
const vlNodeBroker = opts.vlNodeBroker || createVlNodeBroker();
|
|
149
|
+
const vlOwnerId = () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null;
|
|
144
150
|
// fetch usata dai probe di salute federati (iniettabile nei test); default globale.
|
|
145
151
|
const healthFetch = cfg.fetchImpl || fetch;
|
|
146
152
|
const topologyCachePath = cfg.topologyCachePath || topologyCache.defaultPath(cfg.home || os.homedir());
|
|
@@ -518,6 +524,12 @@ function createServer(opts = {}) {
|
|
|
518
524
|
// no-store on everything (HTML+assets+API): this is a local, token-adjacent tool.
|
|
519
525
|
app.use((_req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
|
|
520
526
|
app.use('/pair', publicPeeringRoutes({ cfg, nodesPath }));
|
|
527
|
+
// VL micro-device nodes use a separate, scoped credential surface. These
|
|
528
|
+
// routes never accept the NexusCrew UI bearer and expose only pair, poll and
|
|
529
|
+
// self-unpair. Operator management remains behind /api + federation ACL.
|
|
530
|
+
app.use('/vl-node/v1', vlNodePublicRoutes({
|
|
531
|
+
storePath: vlNodesPath, broker: vlNodeBroker, ownerId: vlOwnerId,
|
|
532
|
+
}));
|
|
521
533
|
// This route is deliberately outside the bearer-authenticated federation
|
|
522
534
|
// router. It emits a MAC only on a listener owned by the expected slot and
|
|
523
535
|
// never accepts/sends a bearer to a suspicious loopback listener.
|
|
@@ -678,6 +690,9 @@ function createServer(opts = {}) {
|
|
|
678
690
|
})),
|
|
679
691
|
readonly: proxyReadonly,
|
|
680
692
|
}));
|
|
693
|
+
api.use('/vl-nodes', vlNodeApiRoutes({
|
|
694
|
+
storePath: vlNodesPath, broker: vlNodeBroker, ownerId: vlOwnerId, readonly: proxyReadonly,
|
|
695
|
+
}));
|
|
681
696
|
api.use('/decks', decksRoutes({ cfg, decksPath }));
|
|
682
697
|
api.use('/fs', fsRoutes({ home: os.homedir() })); // folder-picker del dialog new session
|
|
683
698
|
// /nodes (read-only, per la settings UI B2): stesso formato di `nodes list --json`
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const { NODE_ID_RE } = require('./store.js');
|
|
5
|
+
|
|
6
|
+
const SESSION_RE = /^[a-f0-9]{32}$/;
|
|
7
|
+
const PROTOCOL = 'vl-node/1';
|
|
8
|
+
const MAX_WAIT_MS = 30_000;
|
|
9
|
+
const ONLINE_GRACE_MS = 45_000;
|
|
10
|
+
|
|
11
|
+
function boundedString(value, max = 128) {
|
|
12
|
+
return typeof value === 'string' ? value.normalize('NFC').replace(/[\p{Cc}\p{Cf}]/gu, '').slice(0, max) : '';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function boundedInteger(value, min, max, fallback = 0) {
|
|
16
|
+
return Number.isSafeInteger(value) && value >= min && value <= max ? value : fallback;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function sanitizeAck(raw) {
|
|
20
|
+
if (!raw || !/^[a-f0-9]{32}$/.test(String(raw.id || ''))) return null;
|
|
21
|
+
if (!['ok', 'error', 'rejected'].includes(raw.status)) return null;
|
|
22
|
+
const result = raw.result && typeof raw.result === 'object' && !Array.isArray(raw.result)
|
|
23
|
+
? JSON.parse(JSON.stringify(raw.result).slice(0, 16_384)) : null;
|
|
24
|
+
return { id: raw.id, status: raw.status, ...(result ? { result } : {}) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sanitizeHealth(raw) {
|
|
28
|
+
const health = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
29
|
+
return {
|
|
30
|
+
state: ['starting', 'running', 'stopped', 'degraded', 'error'].includes(health.state)
|
|
31
|
+
? health.state : 'degraded',
|
|
32
|
+
uptimeSec: boundedInteger(health.uptimeSec, 0, Number.MAX_SAFE_INTEGER),
|
|
33
|
+
rssBytes: boundedInteger(health.rssBytes, 0, 1 << 30),
|
|
34
|
+
processCount: boundedInteger(health.processCount, 1, 16, 1),
|
|
35
|
+
brokerReachable: health.brokerReachable === true,
|
|
36
|
+
childPid: boundedInteger(health.childPid, 1, 1 << 30, 0) || null,
|
|
37
|
+
batteryPercent: boundedInteger(health.batteryPercent, 0, 100, -1),
|
|
38
|
+
detail: boundedString(health.detail, 256),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sanitizeHeartbeat(raw) {
|
|
43
|
+
if (!raw || raw.protocol !== PROTOCOL || !SESSION_RE.test(String(raw.sessionId || ''))
|
|
44
|
+
|| !Number.isSafeInteger(raw.seq) || raw.seq < 0) return null;
|
|
45
|
+
const capabilities = Array.isArray(raw.capabilities)
|
|
46
|
+
? [...new Set(raw.capabilities.filter((item) => /^[a-z][a-z0-9_]{0,31}$/.test(String(item))))].slice(0, 32)
|
|
47
|
+
: [];
|
|
48
|
+
return {
|
|
49
|
+
protocol: PROTOCOL,
|
|
50
|
+
sessionId: raw.sessionId,
|
|
51
|
+
seq: raw.seq,
|
|
52
|
+
version: boundedString(raw.version, 64),
|
|
53
|
+
capabilities,
|
|
54
|
+
health: sanitizeHealth(raw.health),
|
|
55
|
+
ack: sanitizeAck(raw.ack),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createBroker(options = {}) {
|
|
60
|
+
const now = options.now || Date.now;
|
|
61
|
+
const setTimer = options.setTimeout || setTimeout;
|
|
62
|
+
const clearTimer = options.clearTimeout || clearTimeout;
|
|
63
|
+
const randomBytes = options.randomBytes || crypto.randomBytes;
|
|
64
|
+
const states = new Map();
|
|
65
|
+
|
|
66
|
+
function ensure(node) {
|
|
67
|
+
let state = states.get(node.nodeId);
|
|
68
|
+
if (!state) {
|
|
69
|
+
state = {
|
|
70
|
+
nodeId: node.nodeId,
|
|
71
|
+
label: node.label,
|
|
72
|
+
pairedAt: node.pairedAt,
|
|
73
|
+
lastSeen: null,
|
|
74
|
+
heartbeat: null,
|
|
75
|
+
generation: 0,
|
|
76
|
+
waiter: null,
|
|
77
|
+
inflight: null,
|
|
78
|
+
lastAck: null,
|
|
79
|
+
};
|
|
80
|
+
states.set(node.nodeId, state);
|
|
81
|
+
}
|
|
82
|
+
state.label = node.label;
|
|
83
|
+
state.pairedAt = node.pairedAt;
|
|
84
|
+
return state;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function finishWaiter(state, value) {
|
|
88
|
+
const waiter = state.waiter;
|
|
89
|
+
if (!waiter) return false;
|
|
90
|
+
state.waiter = null;
|
|
91
|
+
clearTimer(waiter.timer);
|
|
92
|
+
waiter.cleanup?.();
|
|
93
|
+
waiter.resolve(value);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function observe(node, raw) {
|
|
98
|
+
const heartbeat = sanitizeHeartbeat(raw);
|
|
99
|
+
if (!heartbeat) return { ok: false, code: 'invalid-heartbeat' };
|
|
100
|
+
// Reaching this authenticated endpoint is itself the end-to-end proof.
|
|
101
|
+
// Do not preserve a stale false value from the instant before send().
|
|
102
|
+
heartbeat.health.brokerReachable = true;
|
|
103
|
+
const state = ensure(node);
|
|
104
|
+
const sameSession = Boolean(state.heartbeat
|
|
105
|
+
&& state.heartbeat.sessionId === heartbeat.sessionId);
|
|
106
|
+
if (sameSession
|
|
107
|
+
&& heartbeat.seq <= state.heartbeat.seq) return { ok: false, code: 'stale-sequence' };
|
|
108
|
+
if (!sameSession) {
|
|
109
|
+
state.generation += 1;
|
|
110
|
+
finishWaiter(state, { type: 'superseded', generation: state.generation });
|
|
111
|
+
if (state.inflight) {
|
|
112
|
+
state.lastAck = {
|
|
113
|
+
id: state.inflight.id,
|
|
114
|
+
status: 'error',
|
|
115
|
+
result: { code: 'stale-session', detail: 'device session changed before ack' },
|
|
116
|
+
at: now(),
|
|
117
|
+
};
|
|
118
|
+
state.inflight = null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
state.lastSeen = now();
|
|
122
|
+
state.heartbeat = heartbeat;
|
|
123
|
+
let acknowledged = false;
|
|
124
|
+
if (heartbeat.ack && state.inflight && heartbeat.ack.id === state.inflight.id) {
|
|
125
|
+
state.lastAck = { ...heartbeat.ack, at: state.lastSeen };
|
|
126
|
+
state.inflight = null;
|
|
127
|
+
acknowledged = true;
|
|
128
|
+
} else if (state.inflight && sameSession) {
|
|
129
|
+
state.lastAck = {
|
|
130
|
+
id: state.inflight.id,
|
|
131
|
+
status: 'error',
|
|
132
|
+
result: {
|
|
133
|
+
code: 'delivery-unknown',
|
|
134
|
+
detail: 'device repolled without acknowledging the dispatched command',
|
|
135
|
+
},
|
|
136
|
+
at: state.lastSeen,
|
|
137
|
+
};
|
|
138
|
+
state.inflight = null;
|
|
139
|
+
}
|
|
140
|
+
return { ok: true, state, acknowledged };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function poll(node, rawHeartbeat, { waitMs = MAX_WAIT_MS, signal = null } = {}) {
|
|
144
|
+
const observed = observe(node, rawHeartbeat);
|
|
145
|
+
if (!observed.ok) return Promise.resolve({ type: 'error', code: observed.code });
|
|
146
|
+
const state = observed.state;
|
|
147
|
+
finishWaiter(state, { type: 'superseded', generation: state.generation });
|
|
148
|
+
if (observed.acknowledged) {
|
|
149
|
+
return Promise.resolve({ type: 'acknowledged', generation: state.generation });
|
|
150
|
+
}
|
|
151
|
+
const boundedWait = Math.max(1, Math.min(MAX_WAIT_MS, Number(waitMs) || MAX_WAIT_MS));
|
|
152
|
+
return new Promise((resolve) => {
|
|
153
|
+
const waiter = { resolve, timer: null, cleanup: null };
|
|
154
|
+
waiter.timer = setTimer(() => {
|
|
155
|
+
if (state.waiter === waiter) state.waiter = null;
|
|
156
|
+
waiter.cleanup?.();
|
|
157
|
+
resolve({ type: 'idle', generation: state.generation });
|
|
158
|
+
}, boundedWait);
|
|
159
|
+
waiter.timer.unref?.();
|
|
160
|
+
if (signal) {
|
|
161
|
+
const onAbort = () => {
|
|
162
|
+
if (state.waiter === waiter) state.waiter = null;
|
|
163
|
+
clearTimer(waiter.timer);
|
|
164
|
+
resolve({ type: 'aborted', generation: state.generation });
|
|
165
|
+
};
|
|
166
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
167
|
+
waiter.cleanup = () => signal.removeEventListener('abort', onAbort);
|
|
168
|
+
}
|
|
169
|
+
state.waiter = waiter;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function dispatch(nodeId, command) {
|
|
174
|
+
if (!NODE_ID_RE.test(String(nodeId || ''))) return { ok: false, code: 'invalid-node-id' };
|
|
175
|
+
const state = states.get(nodeId);
|
|
176
|
+
if (!state || !state.waiter || !state.heartbeat || now() - state.lastSeen > ONLINE_GRACE_MS) {
|
|
177
|
+
return { ok: false, code: 'node-offline' };
|
|
178
|
+
}
|
|
179
|
+
if (state.inflight) return { ok: false, code: 'command-in-flight' };
|
|
180
|
+
const id = randomBytes(16).toString('hex');
|
|
181
|
+
const envelope = { id, issuedAt: now(), generation: state.generation, ...command };
|
|
182
|
+
state.inflight = { id, kind: command.kind, status: 'submitted', submittedAt: envelope.issuedAt };
|
|
183
|
+
if (!finishWaiter(state, { type: 'command', command: envelope })) {
|
|
184
|
+
state.inflight = null;
|
|
185
|
+
return { ok: false, code: 'node-offline' };
|
|
186
|
+
}
|
|
187
|
+
return { ok: true, id, status: 'submitted', generation: state.generation };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function forget(nodeId) {
|
|
191
|
+
const state = states.get(nodeId);
|
|
192
|
+
if (state) finishWaiter(state, { type: 'revoked' });
|
|
193
|
+
return states.delete(nodeId);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function list(nodes) {
|
|
197
|
+
const at = now();
|
|
198
|
+
return nodes.map((node) => {
|
|
199
|
+
const state = states.get(node.nodeId);
|
|
200
|
+
const online = Boolean(state && (state.waiter || state.inflight)
|
|
201
|
+
&& state.lastSeen !== null && at - state.lastSeen <= ONLINE_GRACE_MS);
|
|
202
|
+
return {
|
|
203
|
+
...node,
|
|
204
|
+
online,
|
|
205
|
+
lastSeen: state?.lastSeen ?? null,
|
|
206
|
+
generation: state?.generation ?? 0,
|
|
207
|
+
version: state?.heartbeat?.version || '',
|
|
208
|
+
capabilities: state?.heartbeat?.capabilities || [],
|
|
209
|
+
health: state?.heartbeat?.health || null,
|
|
210
|
+
inflight: state?.inflight || null,
|
|
211
|
+
lastAck: state?.lastAck || null,
|
|
212
|
+
};
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { poll, dispatch, forget, list, observe, _states: states };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = {
|
|
220
|
+
SESSION_RE,
|
|
221
|
+
PROTOCOL,
|
|
222
|
+
MAX_WAIT_MS,
|
|
223
|
+
ONLINE_GRACE_MS,
|
|
224
|
+
sanitizeHeartbeat,
|
|
225
|
+
sanitizeHealth,
|
|
226
|
+
sanitizeAck,
|
|
227
|
+
createBroker,
|
|
228
|
+
};
|