@mmmbuto/nexuscrew 0.8.51 → 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/README.md +1 -0
- package/lib/fleet/managed.js +11 -2
- package/lib/mcp/tools.js +174 -0
- package/lib/proxy/federation.js +15 -0
- 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/README.md
CHANGED
|
@@ -137,6 +137,7 @@ product verifies a configured pool before it can rotate within it. See
|
|
|
137
137
|
| [Notifications](docs/NOTIFICATIONS.md) | Toasts, Web Push and optional spoken alerts |
|
|
138
138
|
| [Audio Share and native TTS](docs/AUDIO_SHARE.md) | Node-native TTS, consent, groups and MCP controls |
|
|
139
139
|
| [MCP bridge](docs/MCP.md) | Operator tools, cell delivery and client setup |
|
|
140
|
+
| [VL micro-device nodes](docs/VL_MICRO_NODES.md) | Outbound pairing and bounded management for constrained native VL nodes |
|
|
140
141
|
| [Configuration](docs/CONFIGURATION.md) | Files, environment overrides and local settings |
|
|
141
142
|
| [Operations](docs/OPERATIONS.md) | CLI, boot, backup, updates and diagnostics |
|
|
142
143
|
| [Security](docs/SECURITY.md) | Trust boundaries, tokens and credential handling |
|
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,6 +105,20 @@ 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'
|
|
@@ -141,6 +155,7 @@ function allowedResource(resource, method = 'GET') {
|
|
|
141
155
|
if (resource === '/files/upload') return method === 'POST';
|
|
142
156
|
if (resource === '/cells') return method === 'GET';
|
|
143
157
|
if (resource === '/cells/send') return method === 'POST';
|
|
158
|
+
// `/vl-nodes/*` is deliberately absent — see the NOTE in `knownResource`.
|
|
144
159
|
if (resource === '/decks') return method === 'GET' || method === 'POST';
|
|
145
160
|
if (/^\/decks\/[a-z0-9-]{1,32}$/.test(resource)) {
|
|
146
161
|
return method === 'PUT' || method === 'PATCH' || method === 'DELETE';
|
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
|
+
};
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const { bearerFrom } = require('../auth/middleware.js');
|
|
5
|
+
const store = require('./store.js');
|
|
6
|
+
const { PROTOCOL, MAX_WAIT_MS } = require('./broker.js');
|
|
7
|
+
|
|
8
|
+
const OWNER_ID_RE = /^[a-f0-9]{16,64}$/;
|
|
9
|
+
const SIMPLE_COMMANDS = new Set([
|
|
10
|
+
'status', 'health', 'start', 'stop', 'restart', 'version', 'capabilities', 'unpair',
|
|
11
|
+
]);
|
|
12
|
+
const MAX_CANDIDATE_BYTES = 2_621_440;
|
|
13
|
+
|
|
14
|
+
function commandOf(body) {
|
|
15
|
+
if (!body || typeof body.kind !== 'string') return null;
|
|
16
|
+
if (SIMPLE_COMMANDS.has(body.kind)) {
|
|
17
|
+
if (body.args !== undefined && (typeof body.args !== 'object' || body.args === null
|
|
18
|
+
|| Array.isArray(body.args) || Object.keys(body.args).length > 0)) return null;
|
|
19
|
+
return { kind: body.kind, args: {} };
|
|
20
|
+
}
|
|
21
|
+
if (body.kind === 'logs') {
|
|
22
|
+
const limit = body.args && body.args.limit;
|
|
23
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) return null;
|
|
24
|
+
return { kind: 'logs', args: { limit } };
|
|
25
|
+
}
|
|
26
|
+
if (body.kind === 'update_candidate') {
|
|
27
|
+
const args = body.args;
|
|
28
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) return null;
|
|
29
|
+
const keys = Object.keys(args).sort();
|
|
30
|
+
if (keys.join(',') !== 'sha256,size,url,version') return null;
|
|
31
|
+
if (typeof args.url !== 'string' || args.url.length > 2048) return null;
|
|
32
|
+
let parsed;
|
|
33
|
+
try { parsed = new URL(args.url); } catch (_) { return null; }
|
|
34
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) return null;
|
|
35
|
+
if (!/^[a-f0-9]{64}$/.test(String(args.sha256 || ''))) return null;
|
|
36
|
+
if (!Number.isSafeInteger(args.size) || args.size < 1 || args.size > MAX_CANDIDATE_BYTES) return null;
|
|
37
|
+
if (typeof args.version !== 'string' || !/^[A-Za-z0-9._+-]{1,64}$/.test(args.version)) return null;
|
|
38
|
+
return {
|
|
39
|
+
kind: body.kind,
|
|
40
|
+
args: { url: parsed.toString(), sha256: args.sha256, size: args.size, version: args.version },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function nodeToken(req) {
|
|
47
|
+
const token = bearerFrom(req);
|
|
48
|
+
return store.TOKEN_RE.test(String(token || '')) ? token : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function jsonBodyError(error, _req, res, _next) {
|
|
52
|
+
if (error && (error.type === 'entity.too.large' || error.status === 413)) {
|
|
53
|
+
return res.status(413).json({ error: 'body too large' });
|
|
54
|
+
}
|
|
55
|
+
if (error instanceof SyntaxError) return res.status(400).json({ error: 'invalid JSON' });
|
|
56
|
+
return res.status(400).json({ error: String(error?.message || error) });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function publicRoutes({ storePath, broker, ownerId }) {
|
|
60
|
+
const r = express.Router();
|
|
61
|
+
const body = express.json({ limit: '20kb' });
|
|
62
|
+
|
|
63
|
+
r.post('/pair', body, (req, res) => {
|
|
64
|
+
const localOwner = ownerId();
|
|
65
|
+
if (!OWNER_ID_RE.test(String(localOwner || ''))) return res.status(503).json({ error: 'owner identity unavailable' });
|
|
66
|
+
if (!req.body || req.body.protocol !== PROTOCOL) return res.status(400).json({ error: 'unsupported protocol' });
|
|
67
|
+
const invite = req.get('x-vl-invite');
|
|
68
|
+
const paired = store.pairNode(storePath, {
|
|
69
|
+
invite,
|
|
70
|
+
nodeId: req.body.nodeId,
|
|
71
|
+
label: req.body.label,
|
|
72
|
+
});
|
|
73
|
+
if (!paired.ok) {
|
|
74
|
+
const status = paired.code === 'node-exists' ? 409
|
|
75
|
+
: paired.code === 'invite-expired-or-used' ? 410 : 400;
|
|
76
|
+
return res.status(status).json({ error: paired.code });
|
|
77
|
+
}
|
|
78
|
+
return res.status(201).json({
|
|
79
|
+
protocol: PROTOCOL,
|
|
80
|
+
ownerId: localOwner,
|
|
81
|
+
nodeId: paired.node.nodeId,
|
|
82
|
+
label: paired.node.label,
|
|
83
|
+
token: paired.token,
|
|
84
|
+
pollMaxMs: MAX_WAIT_MS,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
r.post('/poll', body, async (req, res) => {
|
|
89
|
+
const token = nodeToken(req);
|
|
90
|
+
const node = token && store.authenticate(storePath, token);
|
|
91
|
+
if (!node || req.body?.nodeId !== node.nodeId) return res.status(401).json({ error: 'unauthorized-node' });
|
|
92
|
+
const abort = new AbortController();
|
|
93
|
+
req.once('aborted', () => abort.abort());
|
|
94
|
+
const result = await broker.poll(node, req.body, {
|
|
95
|
+
waitMs: Number(req.get('x-vl-wait-ms')) || MAX_WAIT_MS,
|
|
96
|
+
signal: abort.signal,
|
|
97
|
+
});
|
|
98
|
+
if (res.headersSent || res.destroyed) return undefined;
|
|
99
|
+
if (result.type === 'command') return res.json({ protocol: PROTOCOL, command: result.command });
|
|
100
|
+
if (result.type === 'idle' || result.type === 'aborted' || result.type === 'acknowledged') {
|
|
101
|
+
return res.status(204).end();
|
|
102
|
+
}
|
|
103
|
+
if (result.type === 'superseded') return res.status(409).json({ error: 'stale-session', generation: result.generation });
|
|
104
|
+
if (result.type === 'revoked') return res.status(401).json({ error: 'unpaired' });
|
|
105
|
+
return res.status(400).json({ error: result.code || 'invalid-heartbeat' });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
r.post('/unpair', body, (req, res) => {
|
|
109
|
+
const token = nodeToken(req);
|
|
110
|
+
const node = token && store.authenticate(storePath, token);
|
|
111
|
+
if (!node || req.body?.nodeId !== node.nodeId || req.body?.protocol !== PROTOCOL) {
|
|
112
|
+
return res.status(401).json({ error: 'unauthorized-node' });
|
|
113
|
+
}
|
|
114
|
+
store.removeNode(storePath, node.nodeId);
|
|
115
|
+
broker.forget(node.nodeId);
|
|
116
|
+
return res.status(204).end();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
r.use(jsonBodyError);
|
|
120
|
+
|
|
121
|
+
return r;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function apiRoutes({ storePath, broker, ownerId, readonly = () => false }) {
|
|
125
|
+
const r = express.Router();
|
|
126
|
+
|
|
127
|
+
r.get('/', (_req, res) => {
|
|
128
|
+
const localOwner = ownerId();
|
|
129
|
+
if (!OWNER_ID_RE.test(String(localOwner || ''))) return res.status(503).json({ error: 'owner identity unavailable' });
|
|
130
|
+
const nodes = broker.list(store.listNodes(storePath)).map((node) => ({
|
|
131
|
+
...node,
|
|
132
|
+
id: `${localOwner}:VL-${node.nodeId}`,
|
|
133
|
+
instanceId: localOwner,
|
|
134
|
+
cell: `VL-${node.nodeId.slice(0, 8)}`,
|
|
135
|
+
canReceive: false,
|
|
136
|
+
canManage: node.online === true,
|
|
137
|
+
}));
|
|
138
|
+
return res.json({ instanceId: localOwner, protocol: PROTOCOL, nodes });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
r.post('/invite', express.json({ limit: '4kb' }), (req, res) => {
|
|
142
|
+
if (readonly()) return res.status(403).json({ error: 'READONLY: invite blocked' });
|
|
143
|
+
const localOwner = ownerId();
|
|
144
|
+
if (!OWNER_ID_RE.test(String(localOwner || ''))) return res.status(503).json({ error: 'owner identity unavailable' });
|
|
145
|
+
const ttlSeconds = req.body?.ttlSeconds === undefined ? undefined : Number(req.body.ttlSeconds);
|
|
146
|
+
if (ttlSeconds !== undefined && (!Number.isSafeInteger(ttlSeconds) || ttlSeconds < 30 || ttlSeconds > 3600)) {
|
|
147
|
+
return res.status(400).json({ error: 'ttlSeconds must be 30..3600' });
|
|
148
|
+
}
|
|
149
|
+
const invite = store.createInvite(storePath, {
|
|
150
|
+
label: req.body?.label,
|
|
151
|
+
...(ttlSeconds === undefined ? {} : { ttlMs: ttlSeconds * 1000 }),
|
|
152
|
+
});
|
|
153
|
+
return res.status(201).json({ protocol: PROTOCOL, ownerId: localOwner, ...invite });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
r.post('/:nodeId/commands', express.json({ limit: '8kb' }), (req, res) => {
|
|
157
|
+
if (readonly()) return res.status(403).json({ error: 'READONLY: command blocked' });
|
|
158
|
+
if (!store.NODE_ID_RE.test(String(req.params.nodeId || ''))
|
|
159
|
+
|| !store.listNodes(storePath).some((node) => node.nodeId === req.params.nodeId)) {
|
|
160
|
+
return res.status(404).json({ error: 'node not paired' });
|
|
161
|
+
}
|
|
162
|
+
const command = commandOf(req.body);
|
|
163
|
+
if (!command) return res.status(400).json({ error: 'invalid bounded command' });
|
|
164
|
+
const result = broker.dispatch(req.params.nodeId, command);
|
|
165
|
+
if (!result.ok) {
|
|
166
|
+
const status = result.code === 'node-offline' || result.code === 'command-in-flight' ? 409 : 400;
|
|
167
|
+
return res.status(status).json({ error: result.code });
|
|
168
|
+
}
|
|
169
|
+
return res.status(202).json({
|
|
170
|
+
id: result.id,
|
|
171
|
+
status: result.status,
|
|
172
|
+
note: 'submitted confirms delivery to a live poll only; completion requires the node ack',
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
r.delete('/:nodeId', (req, res) => {
|
|
177
|
+
if (readonly()) return res.status(403).json({ error: 'READONLY: revoke blocked' });
|
|
178
|
+
if (!store.removeNode(storePath, req.params.nodeId)) return res.status(404).json({ error: 'node not paired' });
|
|
179
|
+
broker.forget(req.params.nodeId);
|
|
180
|
+
return res.status(204).end();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
r.use(jsonBodyError);
|
|
184
|
+
|
|
185
|
+
return r;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = {
|
|
189
|
+
OWNER_ID_RE,
|
|
190
|
+
SIMPLE_COMMANDS,
|
|
191
|
+
MAX_CANDIDATE_BYTES,
|
|
192
|
+
commandOf,
|
|
193
|
+
publicRoutes,
|
|
194
|
+
apiRoutes,
|
|
195
|
+
};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const NODE_ID_RE = /^[a-f0-9]{32}$/;
|
|
8
|
+
const TOKEN_RE = /^[a-f0-9]{64}$/;
|
|
9
|
+
const STORE_VERSION = 1;
|
|
10
|
+
const DEFAULT_INVITE_TTL_MS = 10 * 60 * 1000;
|
|
11
|
+
const MAX_INVITE_TTL_MS = 60 * 60 * 1000;
|
|
12
|
+
const MAX_STORE_BYTES = 256 * 1024;
|
|
13
|
+
|
|
14
|
+
function emptyStore() {
|
|
15
|
+
return { version: STORE_VERSION, invites: [], nodes: [] };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function defaultPath(home) {
|
|
19
|
+
return path.join(home, '.nexuscrew', 'vl-nodes.json');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function cleanLabel(value, fallback = 'VL') {
|
|
23
|
+
if (typeof value !== 'string') return fallback;
|
|
24
|
+
const clean = value.normalize('NFC').replace(/[\p{Cc}\p{Cf}]/gu, '').trim();
|
|
25
|
+
return clean ? clean.slice(0, 48) : fallback;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function validRecord(record) {
|
|
29
|
+
return record && NODE_ID_RE.test(String(record.nodeId || ''))
|
|
30
|
+
&& TOKEN_RE.test(String(record.tokenHash || ''))
|
|
31
|
+
&& Number.isSafeInteger(record.pairedAt) && record.pairedAt > 0
|
|
32
|
+
&& typeof record.label === 'string' && record.label.length > 0 && record.label.length <= 48;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeStore(raw, now = Date.now()) {
|
|
36
|
+
if (!raw || raw.version !== STORE_VERSION) return emptyStore();
|
|
37
|
+
const invites = Array.isArray(raw.invites) ? raw.invites.filter((invite) => (
|
|
38
|
+
invite && TOKEN_RE.test(String(invite.tokenHash || ''))
|
|
39
|
+
&& Number.isSafeInteger(invite.expiresAt) && invite.expiresAt > now
|
|
40
|
+
&& typeof invite.label === 'string' && invite.label.length > 0 && invite.label.length <= 48
|
|
41
|
+
)).slice(-64) : [];
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
const nodes = [];
|
|
44
|
+
for (const record of Array.isArray(raw.nodes) ? raw.nodes : []) {
|
|
45
|
+
if (!validRecord(record) || seen.has(record.nodeId)) continue;
|
|
46
|
+
seen.add(record.nodeId);
|
|
47
|
+
nodes.push({
|
|
48
|
+
nodeId: record.nodeId,
|
|
49
|
+
label: cleanLabel(record.label),
|
|
50
|
+
tokenHash: record.tokenHash,
|
|
51
|
+
pairedAt: record.pairedAt,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return { version: STORE_VERSION, invites, nodes };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function ownedByCurrentUser(stat) {
|
|
58
|
+
return typeof process.getuid !== 'function' || stat.uid === process.getuid();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function assertSafeFile(stat) {
|
|
62
|
+
if (stat.isSymbolicLink() || !stat.isFile() || !ownedByCurrentUser(stat)
|
|
63
|
+
|| (stat.mode & 0o077) !== 0 || stat.size > MAX_STORE_BYTES) {
|
|
64
|
+
throw new Error('unsafe VL node store');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function loadStore(file, now = Date.now()) {
|
|
69
|
+
let fd;
|
|
70
|
+
try {
|
|
71
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
72
|
+
assertSafeFile(fs.fstatSync(fd));
|
|
73
|
+
return normalizeStore(JSON.parse(fs.readFileSync(fd, 'utf8')), now);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error && error.code === 'ENOENT') return emptyStore();
|
|
76
|
+
throw error;
|
|
77
|
+
} finally {
|
|
78
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function atomicWriteStore(file, store, now = Date.now()) {
|
|
83
|
+
const dir = path.dirname(file);
|
|
84
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
85
|
+
const dirStat = fs.lstatSync(dir);
|
|
86
|
+
if (dirStat.isSymbolicLink() || !dirStat.isDirectory() || !ownedByCurrentUser(dirStat)) {
|
|
87
|
+
throw new Error('unsafe VL node store directory');
|
|
88
|
+
}
|
|
89
|
+
fs.chmodSync(dir, 0o700);
|
|
90
|
+
try {
|
|
91
|
+
assertSafeFile(fs.lstatSync(file));
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (!error || error.code !== 'ENOENT') throw error;
|
|
94
|
+
}
|
|
95
|
+
const tmp = `${file}.tmp.${process.pid}.${crypto.randomBytes(8).toString('hex')}`;
|
|
96
|
+
const fd = fs.openSync(tmp, 'wx', 0o600);
|
|
97
|
+
try {
|
|
98
|
+
const payload = `${JSON.stringify(normalizeStore(store, now), null, 2)}\n`;
|
|
99
|
+
fs.writeFileSync(fd, payload, { encoding: 'utf8' });
|
|
100
|
+
fs.fsyncSync(fd);
|
|
101
|
+
} finally {
|
|
102
|
+
fs.closeSync(fd);
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
fs.renameSync(tmp, file);
|
|
106
|
+
fs.chmodSync(file, 0o600);
|
|
107
|
+
try {
|
|
108
|
+
const dirFd = fs.openSync(dir, 'r');
|
|
109
|
+
try { fs.fsyncSync(dirFd); } finally { fs.closeSync(dirFd); }
|
|
110
|
+
} catch (_) {}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function digest(token) {
|
|
118
|
+
return crypto.createHash('sha256').update(String(token), 'utf8').digest('hex');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function timingSafeHex(left, right) {
|
|
122
|
+
if (!TOKEN_RE.test(String(left || '')) || !TOKEN_RE.test(String(right || ''))) return false;
|
|
123
|
+
return crypto.timingSafeEqual(Buffer.from(left, 'hex'), Buffer.from(right, 'hex'));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function randomToken(randomBytes = crypto.randomBytes) {
|
|
127
|
+
return randomBytes(32).toString('hex');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function createInvite(file, { label = 'VL', ttlMs = DEFAULT_INVITE_TTL_MS } = {}, seams = {}) {
|
|
131
|
+
const now = typeof seams.now === 'function' ? seams.now() : Date.now();
|
|
132
|
+
const boundedTtl = Math.max(30_000, Math.min(MAX_INVITE_TTL_MS, Number(ttlMs) || DEFAULT_INVITE_TTL_MS));
|
|
133
|
+
const token = randomToken(seams.randomBytes);
|
|
134
|
+
const store = loadStore(file, now);
|
|
135
|
+
store.invites.push({ tokenHash: digest(token), expiresAt: now + boundedTtl, label: cleanLabel(label) });
|
|
136
|
+
store.invites = store.invites.filter((invite) => invite.expiresAt > now).slice(-64);
|
|
137
|
+
atomicWriteStore(file, store, now);
|
|
138
|
+
return { invite: token, expiresAt: now + boundedTtl, label: cleanLabel(label) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function pairNode(file, { invite, nodeId, label }, seams = {}) {
|
|
142
|
+
if (!TOKEN_RE.test(String(invite || ''))) return { ok: false, code: 'invalid-invite' };
|
|
143
|
+
if (!NODE_ID_RE.test(String(nodeId || ''))) return { ok: false, code: 'invalid-node-id' };
|
|
144
|
+
const now = typeof seams.now === 'function' ? seams.now() : Date.now();
|
|
145
|
+
const store = loadStore(file, now);
|
|
146
|
+
if (store.nodes.some((node) => node.nodeId === nodeId)) return { ok: false, code: 'node-exists' };
|
|
147
|
+
const inviteHash = digest(invite);
|
|
148
|
+
const index = store.invites.findIndex((row) => timingSafeHex(row.tokenHash, inviteHash));
|
|
149
|
+
if (index < 0) return { ok: false, code: 'invite-expired-or-used' };
|
|
150
|
+
const [accepted] = store.invites.splice(index, 1);
|
|
151
|
+
const token = randomToken(seams.randomBytes);
|
|
152
|
+
const record = {
|
|
153
|
+
nodeId,
|
|
154
|
+
label: cleanLabel(label, accepted.label),
|
|
155
|
+
tokenHash: digest(token),
|
|
156
|
+
pairedAt: now,
|
|
157
|
+
};
|
|
158
|
+
store.nodes.push(record);
|
|
159
|
+
atomicWriteStore(file, store, now);
|
|
160
|
+
return { ok: true, token, node: { nodeId, label: record.label, pairedAt: now } };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function authenticate(file, token, now = Date.now()) {
|
|
164
|
+
if (!TOKEN_RE.test(String(token || ''))) return null;
|
|
165
|
+
const tokenHash = digest(token);
|
|
166
|
+
const record = loadStore(file, now).nodes.find((node) => timingSafeHex(node.tokenHash, tokenHash));
|
|
167
|
+
return record ? { nodeId: record.nodeId, label: record.label, pairedAt: record.pairedAt } : null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function removeNode(file, nodeId, now = Date.now()) {
|
|
171
|
+
if (!NODE_ID_RE.test(String(nodeId || ''))) return false;
|
|
172
|
+
const store = loadStore(file, now);
|
|
173
|
+
const kept = store.nodes.filter((node) => node.nodeId !== nodeId);
|
|
174
|
+
if (kept.length === store.nodes.length) return false;
|
|
175
|
+
store.nodes = kept;
|
|
176
|
+
atomicWriteStore(file, store, now);
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function listNodes(file, now = Date.now()) {
|
|
181
|
+
return loadStore(file, now).nodes.map(({ nodeId, label, pairedAt }) => ({ nodeId, label, pairedAt }));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = {
|
|
185
|
+
NODE_ID_RE,
|
|
186
|
+
TOKEN_RE,
|
|
187
|
+
STORE_VERSION,
|
|
188
|
+
DEFAULT_INVITE_TTL_MS,
|
|
189
|
+
MAX_INVITE_TTL_MS,
|
|
190
|
+
emptyStore,
|
|
191
|
+
defaultPath,
|
|
192
|
+
cleanLabel,
|
|
193
|
+
normalizeStore,
|
|
194
|
+
loadStore,
|
|
195
|
+
atomicWriteStore,
|
|
196
|
+
digest,
|
|
197
|
+
timingSafeHex,
|
|
198
|
+
createInvite,
|
|
199
|
+
pairNode,
|
|
200
|
+
authenticate,
|
|
201
|
+
removeNode,
|
|
202
|
+
listNodes,
|
|
203
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: nexuscrew-agent
|
|
3
|
-
description: Use when an AI agent connected to NexusCrew must notify or ask the human, inspect runtime or deck context, list authorized Fleet cells, message an exact active cell, read its inbox, deliver a file, recover from local tmux messages that remain unsubmitted or garbled, or when a drag in the web terminal does not scroll a full-screen TUI. Prefer nc_notify, nc_ask, nc_status, nc_deck, nc_cells, nc_send_cell, nc_inbox, and nc_send_file; use bundled tmux/file helpers only as a declared compatibility fallback.
|
|
3
|
+
description: Use when an AI agent connected to NexusCrew must notify or ask the human, inspect runtime or deck context, manage exact authorized VL micro-device nodes, list authorized Fleet cells, message an exact active cell, read its inbox, deliver a file, recover from local tmux messages that remain unsubmitted or garbled, or when a drag in the web terminal does not scroll a full-screen TUI. Prefer nc_notify, nc_ask, nc_status, nc_deck, nc_cells, nc_vl_nodes, nc_vl_command, nc_send_cell, nc_inbox, and nc_send_file; use bundled tmux/file helpers only as a declared compatibility fallback.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# NexusCrew Agent I/O
|
|
@@ -19,6 +19,10 @@ When the client exposes the NexusCrew MCP server, use these tools directly:
|
|
|
19
19
|
| Read the caller's deck name(s) and member cells/tmux sessions | `nc_deck` |
|
|
20
20
|
| List every authorized owner-qualified Fleet cell | `nc_cells` |
|
|
21
21
|
| Submit a bounded message to an exact active Fleet cell | `nc_send_cell` |
|
|
22
|
+
| List authorized VL micro-device owners/nodes | `nc_vl_nodes` |
|
|
23
|
+
| Create a one-time VL pairing invite | `nc_vl_invite` |
|
|
24
|
+
| Deliver a bounded VL command | `nc_vl_command` |
|
|
25
|
+
| Revoke an exact VL pairing | `nc_vl_revoke` |
|
|
22
26
|
| List files received for the current session | `nc_inbox` |
|
|
23
27
|
| Deliver an absolute file path under the user's home | `nc_send_file` |
|
|
24
28
|
|
|
@@ -30,6 +34,7 @@ Apply these rules:
|
|
|
30
34
|
- Use `nc_status` instead of scraping NexusCrew state files. Use `nc_deck` instead of reading `decks.json`: it returns every local or authorized shared-owner deck containing the caller, preserves visual member order, identifies each deck and member by stable owner ID, includes viewer-valid Hydra routes, and reports `cell: null` when no managed Fleet match is available.
|
|
31
35
|
- Use `nc_cells` immediately before cross-cell delivery. Select its exact owner-qualified `id`; require `canReceive: true`; never guess a duplicate name or stale route.
|
|
32
36
|
- Use `nc_send_cell {target, message}` for actual Fleet-cell delivery. `submitted` confirms bracketed paste plus Enter only, never task acceptance or completion. Inactive targets are not queued.
|
|
37
|
+
- Use `nc_vl_nodes` immediately before VL mutation. Mutation tools require an active local Fleet caller and the full owner-qualified VL ID. `nc_vl_command` `submitted` means live-poll delivery only; require the same ID in `lastAck`. No offline queue exists.
|
|
33
38
|
- Use `nc_inbox` instead of guessing an inbox path when the tool is available.
|
|
34
39
|
- Pass `nc_send_file` an existing absolute regular-file path below the user's home. Let NexusCrew choose and sanitize the outbox name.
|
|
35
40
|
- Do not treat an MCP notification as a substitute for the final response required by the active client.
|
|
@@ -128,6 +133,7 @@ work. The setting is also applied to windows created later in that session.
|
|
|
128
133
|
| Discover this session's deck neighbours | `nc_deck` |
|
|
129
134
|
| Discover authorized cells across nodes | `nc_cells` |
|
|
130
135
|
| Submit to an exact active Fleet cell | `nc_send_cell` |
|
|
136
|
+
| Discover/manage a paired VL micro-device | `nc_vl_nodes`, then `nc_vl_command` |
|
|
131
137
|
| Give the human a file | `nc_send_file` or fallback `nc-deliver <file>...` |
|
|
132
138
|
| Read a file the human sent | `nc_inbox` or fallback to the path in the prompt |
|
|
133
139
|
| Send a prompt/command to a session | `nc-send <session> "text"` |
|