@mmmbuto/nexuscrew 0.7.7 → 0.8.2
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 +153 -25
- package/bin/nexuscrew.js +10 -4
- package/frontend/dist/assets/index-COsoJxXQ.css +32 -0
- package/frontend/dist/assets/index-Dey9rdY-.js +90 -0
- package/frontend/dist/assets/qr-scanner-worker.min-D85Z9gVD.js +98 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +29 -0
- package/frontend/dist/version.json +1 -0
- package/lib/auth/middleware.js +7 -1
- package/lib/auth/token.js +31 -1
- package/lib/cli/commands.js +502 -43
- package/lib/cli/doctor.js +160 -0
- package/lib/cli/fleet-service.js +16 -3
- package/lib/cli/init.js +69 -11
- package/lib/cli/path.js +24 -0
- package/lib/cli/service.js +14 -8
- package/lib/cli/url.js +63 -0
- package/lib/config.js +5 -0
- package/lib/decks/routes.js +81 -0
- package/lib/decks/store.js +123 -0
- package/lib/files/routes.js +57 -1
- package/lib/files/store.js +16 -1
- package/lib/fleet/builtin.js +151 -24
- package/lib/fleet/definitions.js +26 -0
- package/lib/fleet/managed.js +381 -0
- package/lib/fleet/routes.js +5 -4
- package/lib/mcp/server.js +381 -0
- package/lib/nodes/commands.js +411 -0
- package/lib/nodes/peering.js +116 -0
- package/lib/nodes/store.js +425 -0
- package/lib/nodes/tunnel-supervisor.js +102 -0
- package/lib/nodes/tunnel.js +315 -0
- package/lib/notify/asks.js +150 -0
- package/lib/notify/events.js +59 -0
- package/lib/notify/notifier.js +37 -0
- package/lib/notify/persist.js +73 -0
- package/lib/notify/push.js +289 -0
- package/lib/notify/routes.js +260 -0
- package/lib/proxy/federation.js +217 -0
- package/lib/proxy/node-proxy.js +305 -0
- package/lib/pty/attach.js +34 -9
- package/lib/pty/provider.js +21 -6
- package/lib/server.js +291 -14
- package/lib/settings/routes.js +685 -0
- package/lib/ws/bridge.js +10 -1
- package/package.json +8 -2
- package/skills/nexuscrew-agent/SKILL.md +83 -0
- package/skills/nexuscrew-agent/bin/nc-deliver +23 -0
- package/skills/nexuscrew-agent/bin/nc-send +48 -0
- package/frontend/dist/assets/index-DUbtTZMj.css +0 -32
- package/frontend/dist/assets/index-EVa9bnAC.js +0 -81
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
|
|
7
|
+
const SCHEMA_VERSION = 1;
|
|
8
|
+
const MAX_DECKS = 24;
|
|
9
|
+
const MAX_TILES = 9;
|
|
10
|
+
const NAME_RE = /^[a-z0-9-]{1,32}$/;
|
|
11
|
+
const NODE_RE = /^[a-z0-9-]{1,32}(?:\/[a-z0-9-]{1,32}){0,3}$/;
|
|
12
|
+
|
|
13
|
+
function validNodeRoute(node) {
|
|
14
|
+
if (!NODE_RE.test(node)) return false;
|
|
15
|
+
const parts = node.split('/');
|
|
16
|
+
return new Set(parts).size === parts.length;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function defaultDecksPath(home) {
|
|
20
|
+
return path.join(home || os.homedir(), '.nexuscrew', 'decks.json');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function emptyLayout() { return { columns: [] }; }
|
|
24
|
+
function emptyStore() {
|
|
25
|
+
return { schemaVersion: SCHEMA_VERSION, decks: [{ name: 'main', revision: 0, layout: emptyLayout() }] };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function validText(s, max) {
|
|
29
|
+
if (typeof s !== 'string' || !s || s.length > max) return false;
|
|
30
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
31
|
+
const c = s.charCodeAt(i);
|
|
32
|
+
if (c <= 0x1f || c === 0x7f) return false;
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseLayout(raw) {
|
|
38
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !Array.isArray(raw.columns)) return null;
|
|
39
|
+
if (raw.columns.length > MAX_TILES) return null;
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
const columns = [];
|
|
42
|
+
let count = 0;
|
|
43
|
+
for (const c of raw.columns) {
|
|
44
|
+
if (!c || typeof c !== 'object' || Array.isArray(c) || !Array.isArray(c.tiles) || !c.tiles.length) return null;
|
|
45
|
+
const width = Number(c.width);
|
|
46
|
+
if (!Number.isFinite(width) || width < 0.2 || width > 100) return null;
|
|
47
|
+
const tiles = [];
|
|
48
|
+
for (const t of c.tiles) {
|
|
49
|
+
if (!t || typeof t !== 'object' || Array.isArray(t) || !validText(t.session, 128)) return null;
|
|
50
|
+
if (t.node !== undefined && (typeof t.node !== 'string' || !validNodeRoute(t.node))) return null;
|
|
51
|
+
const height = Number(t.height);
|
|
52
|
+
const fontSize = Number(t.fontSize);
|
|
53
|
+
if (!Number.isFinite(height) || height < 0.2 || height > 100) return null;
|
|
54
|
+
if (!Number.isFinite(fontSize) || fontSize < 9 || fontSize > 24) return null;
|
|
55
|
+
const key = t.node ? `${t.node}:${t.session}` : t.session;
|
|
56
|
+
if (seen.has(key) || ++count > MAX_TILES) return null;
|
|
57
|
+
seen.add(key);
|
|
58
|
+
const tile = { session: t.session, height, fontSize };
|
|
59
|
+
if (t.node) tile.node = t.node;
|
|
60
|
+
tiles.push(tile);
|
|
61
|
+
}
|
|
62
|
+
columns.push({ width, tiles });
|
|
63
|
+
}
|
|
64
|
+
return { columns };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseStore(raw) {
|
|
68
|
+
try {
|
|
69
|
+
const obj = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
70
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)
|
|
71
|
+
|| obj.schemaVersion !== SCHEMA_VERSION || !Array.isArray(obj.decks)
|
|
72
|
+
|| obj.decks.length < 1 || obj.decks.length > MAX_DECKS) return null;
|
|
73
|
+
const names = new Set();
|
|
74
|
+
const decks = [];
|
|
75
|
+
for (const d of obj.decks) {
|
|
76
|
+
if (!d || typeof d !== 'object' || !NAME_RE.test(d.name) || names.has(d.name)) return null;
|
|
77
|
+
if (!Number.isSafeInteger(d.revision) || d.revision < 0) return null;
|
|
78
|
+
const layout = parseLayout(d.layout);
|
|
79
|
+
if (!layout) return null;
|
|
80
|
+
names.add(d.name);
|
|
81
|
+
decks.push({ name: d.name, revision: d.revision, layout });
|
|
82
|
+
}
|
|
83
|
+
if (!names.has('main')) return null;
|
|
84
|
+
decks.sort((a, b) => (a.name === 'main' ? -1 : b.name === 'main' ? 1 : a.name.localeCompare(b.name)));
|
|
85
|
+
return { schemaVersion: SCHEMA_VERSION, decks };
|
|
86
|
+
} catch (_) { return null; }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function loadStore(p) {
|
|
90
|
+
try {
|
|
91
|
+
const st = fs.lstatSync(p);
|
|
92
|
+
if (!st.isFile() || st.isSymbolicLink()) return null;
|
|
93
|
+
return parseStore(fs.readFileSync(p, 'utf8'));
|
|
94
|
+
} catch (_) { return null; }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function atomicWrite(p, data) {
|
|
98
|
+
try {
|
|
99
|
+
if (fs.lstatSync(p).isSymbolicLink()) throw new Error('refuse symlink decks.json');
|
|
100
|
+
} catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
101
|
+
const parsed = parseStore(data);
|
|
102
|
+
if (!parsed) throw new Error('decks.json non valido');
|
|
103
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
104
|
+
const tmp = path.join(path.dirname(p), `.${path.basename(p)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
105
|
+
try {
|
|
106
|
+
fs.writeFileSync(tmp, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
|
|
107
|
+
fs.chmodSync(tmp, 0o600);
|
|
108
|
+
fs.renameSync(tmp, p);
|
|
109
|
+
} catch (e) { try { fs.unlinkSync(tmp); } catch (_) {} throw e; }
|
|
110
|
+
return parsed;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function loadOrCreate(p) {
|
|
114
|
+
const found = loadStore(p);
|
|
115
|
+
if (found) return found;
|
|
116
|
+
try { if (fs.existsSync(p)) throw new Error('decks.json presente ma invalido'); } catch (e) { throw e; }
|
|
117
|
+
return atomicWrite(p, emptyStore());
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = {
|
|
121
|
+
SCHEMA_VERSION, MAX_DECKS, MAX_TILES, NAME_RE,
|
|
122
|
+
defaultDecksPath, emptyStore, parseLayout, parseStore, loadStore, loadOrCreate, atomicWrite,
|
|
123
|
+
};
|
package/lib/files/routes.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const express = require('express');
|
|
2
6
|
const { Router } = require('express');
|
|
3
7
|
const multer = require('multer');
|
|
4
8
|
const store = require('./store.js');
|
|
5
9
|
|
|
6
10
|
// Router file-exchange. Nessuno stato: tutto deriva da cfg + filesystem.
|
|
7
|
-
|
|
11
|
+
// notifier (opzionale, MCP bridge): emette la notify di consegna file outbox.
|
|
12
|
+
function filesRoutes({ cfg, sessionExists, paste, notifier, readonly = () => false }) {
|
|
8
13
|
const router = Router();
|
|
9
14
|
const upload = multer({
|
|
10
15
|
storage: multer.memoryStorage(),
|
|
@@ -12,6 +17,7 @@ function filesRoutes({ cfg, sessionExists, paste }) {
|
|
|
12
17
|
});
|
|
13
18
|
|
|
14
19
|
router.post('/upload', (req, res) => {
|
|
20
|
+
if (readonly()) return res.status(403).json({ error: 'READONLY: upload bloccato' });
|
|
15
21
|
upload.single('file')(req, res, async (err) => {
|
|
16
22
|
if (err) {
|
|
17
23
|
const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
|
|
@@ -31,6 +37,55 @@ function filesRoutes({ cfg, sessionExists, paste }) {
|
|
|
31
37
|
});
|
|
32
38
|
});
|
|
33
39
|
|
|
40
|
+
// Consegna deliverable dal MCP bridge (nc_send_file): copia un file locale
|
|
41
|
+
// nell'outbox della sessione mittente. Path sorgente fail-closed: stringa
|
|
42
|
+
// assoluta, realpath ESISTENTE sotto HOME (i symlink si risolvono PRIMA del
|
|
43
|
+
// check: un link che esce da HOME viene rifiutato), file regolare.
|
|
44
|
+
// F3 (audit): gated READONLY — la copia e' una scrittura su disco; il gate
|
|
45
|
+
// sta PRIMA di ogni altro check (nessun probe di sessione/path in READONLY).
|
|
46
|
+
const bridgeReadonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
|
|
47
|
+
router.post('/outbox', express.json({ limit: '8kb' }), (req, res) => {
|
|
48
|
+
try {
|
|
49
|
+
if (bridgeReadonly()) {
|
|
50
|
+
return res.status(403).json({ error: 'READONLY: consegna file bloccata' });
|
|
51
|
+
}
|
|
52
|
+
const b = req.body || {};
|
|
53
|
+
const session = String(b.session || '');
|
|
54
|
+
if (!store.isValidSession(session) || !sessionExists(session)) {
|
|
55
|
+
return res.status(404).json({ error: 'sessione tmux inesistente' });
|
|
56
|
+
}
|
|
57
|
+
if (typeof b.path !== 'string' || !path.isAbsolute(b.path)) {
|
|
58
|
+
return res.status(400).json({ error: 'path deve essere assoluto' });
|
|
59
|
+
}
|
|
60
|
+
if (b.caption !== undefined && (typeof b.caption !== 'string' || b.caption.length > 500)) {
|
|
61
|
+
return res.status(400).json({ error: 'caption deve essere una stringa (max 500)' });
|
|
62
|
+
}
|
|
63
|
+
const home = cfg.home || os.homedir();
|
|
64
|
+
let src;
|
|
65
|
+
try { src = fs.realpathSync(b.path); } catch (_) {
|
|
66
|
+
return res.status(404).json({ error: 'file sorgente inesistente' });
|
|
67
|
+
}
|
|
68
|
+
const homeReal = fs.realpathSync(home);
|
|
69
|
+
if (src !== homeReal && !src.startsWith(homeReal + path.sep)) {
|
|
70
|
+
return res.status(400).json({ error: 'path fuori da HOME' });
|
|
71
|
+
}
|
|
72
|
+
if (!fs.statSync(src).isFile()) {
|
|
73
|
+
return res.status(400).json({ error: 'path non e\' un file regolare' });
|
|
74
|
+
}
|
|
75
|
+
const saved = store.saveDeliverable(cfg.filesRoot, session, src);
|
|
76
|
+
if (!saved) return res.status(400).json({ error: 'sessione invalida' });
|
|
77
|
+
// Notify best-effort (il badge outbox lo genera comunque il watcher).
|
|
78
|
+
if (notifier) {
|
|
79
|
+
notifier.emit({
|
|
80
|
+
title: `file da ${session}`,
|
|
81
|
+
body: b.caption ? `${saved.name} — ${b.caption}` : saved.name,
|
|
82
|
+
session,
|
|
83
|
+
}).catch(() => {});
|
|
84
|
+
}
|
|
85
|
+
res.json({ name: saved.name, box: 'outbox', size: saved.size });
|
|
86
|
+
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
87
|
+
});
|
|
88
|
+
|
|
34
89
|
router.get('/', (req, res) => {
|
|
35
90
|
const session = String(req.query.session || '');
|
|
36
91
|
if (!store.isValidSession(session)) return res.status(400).json({ error: 'sessione invalida' });
|
|
@@ -50,6 +105,7 @@ function filesRoutes({ cfg, sessionExists, paste }) {
|
|
|
50
105
|
});
|
|
51
106
|
|
|
52
107
|
router.delete('/', (req, res) => {
|
|
108
|
+
if (readonly()) return res.status(403).json({ error: 'READONLY: eliminazione file bloccata' });
|
|
53
109
|
const ok = store.removeFile(
|
|
54
110
|
cfg.filesRoot, String(req.query.session || ''), String(req.query.box || ''), String(req.query.name || ''),
|
|
55
111
|
);
|
package/lib/files/store.js
CHANGED
|
@@ -77,6 +77,21 @@ function saveUpload(root, session, buffer, origName, now = new Date()) {
|
|
|
77
77
|
return { name, path: full, size: buffer.length };
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// Consegna MCP bridge: copia un file gia' su disco nell'outbox della sessione
|
|
81
|
+
// (badge/watcher scattano da soli). Naming identico a saveUpload (stamp +
|
|
82
|
+
// sanitize + dedup) e COPYFILE_EXCL: mai overwrite. La validazione del path
|
|
83
|
+
// sorgente (assoluto, sotto HOME, realpath) sta nella route, non qui.
|
|
84
|
+
function saveDeliverable(root, session, srcPath, now = new Date()) {
|
|
85
|
+
const dir = ensureBox(root, session, 'outbox');
|
|
86
|
+
if (!dir) return null;
|
|
87
|
+
const base = `${stamp(now)}_${sanitizeName(path.basename(srcPath))}`;
|
|
88
|
+
let name = base;
|
|
89
|
+
for (let i = 1; fs.existsSync(path.join(dir, name)); i += 1) name = `${i}-${base}`;
|
|
90
|
+
const full = path.join(dir, name);
|
|
91
|
+
fs.copyFileSync(srcPath, full, fs.constants.COPYFILE_EXCL);
|
|
92
|
+
return { name, path: full, size: fs.statSync(full).size };
|
|
93
|
+
}
|
|
94
|
+
|
|
80
95
|
function listBox(root, session, box) {
|
|
81
96
|
const dir = boxDir(root, session, box);
|
|
82
97
|
if (!dir) return null;
|
|
@@ -123,5 +138,5 @@ function removeFile(root, session, box, name) {
|
|
|
123
138
|
|
|
124
139
|
module.exports = {
|
|
125
140
|
isValidSession, sanitizeName, stamp, boxDir, ensureBox,
|
|
126
|
-
saveUpload, listBox, resolveExisting, removeFile, BOXES,
|
|
141
|
+
saveUpload, saveDeliverable, listBox, resolveExisting, removeFile, BOXES,
|
|
127
142
|
};
|
package/lib/fleet/builtin.js
CHANGED
|
@@ -28,6 +28,9 @@ const {
|
|
|
28
28
|
parseDefinitions, validateCommandTrust, resolveCwd,
|
|
29
29
|
loadDefinitions, atomicWrite, CAPS,
|
|
30
30
|
} = require('./definitions.js');
|
|
31
|
+
const {
|
|
32
|
+
publicCatalog, describeManaged, resolveManagedEngine, discoverOllamaModels, discoverPiModels,
|
|
33
|
+
} = require('./managed.js');
|
|
31
34
|
|
|
32
35
|
const STATUS_TTL_MS = 2000;
|
|
33
36
|
|
|
@@ -183,11 +186,34 @@ async function injectPrompt(tmuxBin, session, prompt, { env, readyMs = 400, targ
|
|
|
183
186
|
function draftFrom(defs) {
|
|
184
187
|
return {
|
|
185
188
|
schemaVersion: defs.schemaVersion,
|
|
186
|
-
engines: defs.engines.map((e) => ({
|
|
187
|
-
|
|
189
|
+
engines: defs.engines.map((e) => ({
|
|
190
|
+
...e,
|
|
191
|
+
...(e.managed ? { managed: { ...e.managed } } : {}),
|
|
192
|
+
...(e.args ? { args: [...e.args] } : {}),
|
|
193
|
+
...(e.env ? { env: { ...e.env } } : {}),
|
|
194
|
+
...(e.model ? { model: { ...e.model } } : {}),
|
|
195
|
+
})),
|
|
196
|
+
cells: defs.cells.map((c) => ({ ...c, ...(c.models ? { models: { ...c.models } } : {}) })),
|
|
188
197
|
};
|
|
189
198
|
}
|
|
190
199
|
|
|
200
|
+
// Applica engine + modello come un'unica transizione. Ogni engine ricorda il
|
|
201
|
+
// proprio ultimo modello; passando a un altro engine il modello vecchio non
|
|
202
|
+
// attraversa mai il confine.
|
|
203
|
+
function applyCellEngineModel(target, engineId, model, hasModel) {
|
|
204
|
+
const remembered = { ...(target.models || {}) };
|
|
205
|
+
target.engine = engineId;
|
|
206
|
+
if (hasModel) {
|
|
207
|
+
if (typeof model === 'string' && model) remembered[engineId] = model;
|
|
208
|
+
else delete remembered[engineId];
|
|
209
|
+
}
|
|
210
|
+
const selected = hasModel
|
|
211
|
+
? (typeof model === 'string' ? model : '')
|
|
212
|
+
: (remembered[engineId] || '');
|
|
213
|
+
if (Object.keys(remembered).length) target.models = remembered; else delete target.models;
|
|
214
|
+
if (selected) target.model = selected; else delete target.model;
|
|
215
|
+
}
|
|
216
|
+
|
|
191
217
|
// ---------------------------------------------------------------------------
|
|
192
218
|
// createBuiltinFleet(cfg) — async per parita' di contratto con createFleet.
|
|
193
219
|
// cfg: { fleetDefsPath?, tmuxBin?, home?, builtinEnabled?, readonlyDefault?,
|
|
@@ -240,11 +266,28 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
240
266
|
const alive = sessions.has(c.tmuxSession);
|
|
241
267
|
return {
|
|
242
268
|
cell: c.id, tmuxSession: c.tmuxSession, engine: c.engine,
|
|
269
|
+
model: c.model || '', models: { ...(c.models || {}) },
|
|
243
270
|
active: alive, boot: c.boot, tmux: alive,
|
|
244
271
|
rc: '', key: '', degraded: false, // builtin: cella "up" <=> sessione tmux viva
|
|
245
272
|
};
|
|
246
273
|
});
|
|
247
|
-
const
|
|
274
|
+
const needsOllama = cache.defs.engines.some((e) => e.managed?.provider === 'ollama-cloud');
|
|
275
|
+
const ollamaModels = needsOllama ? await discoverOllamaModels({ ...cfg, home }) : [];
|
|
276
|
+
const needsPi = cache.defs.engines.some((e) => e.managed?.client === 'pi');
|
|
277
|
+
const piModels = needsPi ? await discoverPiModels({ ...cfg, home }) : {};
|
|
278
|
+
const engines = cache.defs.engines.map((e) => {
|
|
279
|
+
const managed = e.managed ? describeManaged(e.managed, { ...cfg, home }) : null;
|
|
280
|
+
return {
|
|
281
|
+
id: e.id, label: e.label, rc: !!e.rc,
|
|
282
|
+
...(managed ? {
|
|
283
|
+
kind: 'managed', client: managed.client, provider: managed.provider,
|
|
284
|
+
model: e.managed.model || managed.defaultModel || '',
|
|
285
|
+
models: managed.provider === 'ollama-cloud' ? ollamaModels
|
|
286
|
+
: (managed.client === 'pi' ? (e.managed.provider === 'custom' ? [e.managed.model] : (piModels[e.managed.provider] || [])) : managed.models),
|
|
287
|
+
configured: managed.configured, reason: managed.reason,
|
|
288
|
+
} : { kind: 'custom', configured: true, model: e.model?.value || '', models: [] }),
|
|
289
|
+
};
|
|
290
|
+
});
|
|
248
291
|
return {
|
|
249
292
|
available: true,
|
|
250
293
|
provider: 'builtin',
|
|
@@ -269,10 +312,16 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
269
312
|
if (!cell) throw httpError(400, `cella sconosciuta: ${cellId}`);
|
|
270
313
|
const engine = findEngine(defs, cell.engine);
|
|
271
314
|
if (!engine) throw httpError(400, `engine dangling per cella ${cellId}: ${cell.engine}`);
|
|
315
|
+
let launchEngine = engine;
|
|
316
|
+
if (engine.managed) {
|
|
317
|
+
const resolved = resolveManagedEngine(engine, cell, { ...cfg, home });
|
|
318
|
+
if (!resolved.ok) throw httpError(400, `engine managed non configurato (${engine.id}): ${resolved.reason}`);
|
|
319
|
+
launchEngine = resolved.engine;
|
|
320
|
+
}
|
|
272
321
|
|
|
273
322
|
// (2) trust del command PRIMA di lanciare
|
|
274
|
-
const trust = validateCommandTrust(
|
|
275
|
-
if (!trust.ok) throw httpError(400, `command non trusted (${
|
|
323
|
+
const trust = validateCommandTrust(launchEngine.command);
|
|
324
|
+
if (!trust.ok) throw httpError(400, `command non trusted (${launchEngine.command}): ${trust.reason}`);
|
|
276
325
|
// (3) cwd reale sotto la home
|
|
277
326
|
const realCwd = resolveCwd(cell.cwd, home);
|
|
278
327
|
if (!realCwd) throw httpError(400, `cwd non valida (deve esistere sotto la home): ${cell.cwd}`);
|
|
@@ -281,28 +330,28 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
281
330
|
// '-P -F #{pane_id}': tmux stampa il pane id della sessione appena creata,
|
|
282
331
|
// cosi' l'iniezione del prompt bersaglia ESATTAMENTE quel pane (audit impl
|
|
283
332
|
// #5: elimina la race di riuso del nome sessione tra waitAlive e paste).
|
|
284
|
-
const argv = composeLaunchArgv({ tmuxSession: cell.tmuxSession, realCwd, engine, cell });
|
|
333
|
+
const argv = composeLaunchArgv({ tmuxSession: cell.tmuxSession, realCwd, engine: launchEngine, cell });
|
|
285
334
|
argv.splice(2, 0, '-P', '-F', '#{pane_id}');
|
|
286
335
|
const launch = await tmuxExec(tmuxBin, argv, { env: minimalEnv() });
|
|
287
336
|
if (launch.err) {
|
|
288
337
|
// Redazione (§9h): lo stderr di tmux puo' ecoare argv/env del comando lanciato.
|
|
289
338
|
const why = /duplicate session/i.test(launch.stderr)
|
|
290
339
|
? 'sessione già in esecuzione'
|
|
291
|
-
: `tmux new-session failed: ${redactSecrets(launch.stderr.trim() || launch.err.message,
|
|
340
|
+
: `tmux new-session failed: ${redactSecrets(launch.stderr.trim() || launch.err.message, launchEngine, cell)}`;
|
|
292
341
|
throw httpError(/duplicate/i.test(launch.stderr) ? 409 : 500, why);
|
|
293
342
|
}
|
|
294
343
|
|
|
295
344
|
// (6) prompt send-keys: bracketed-paste best-effort, target = pane id esatto
|
|
296
345
|
// (fallback al nome sessione con match esatto '=' se tmux non ha stampato l'id).
|
|
297
346
|
let prompt = null;
|
|
298
|
-
if (
|
|
347
|
+
if (launchEngine.promptMode === 'send-keys' && cell.prompt) {
|
|
299
348
|
const paneId = launch.stdout.trim().split('\n')[0] || '';
|
|
300
349
|
const target = paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`;
|
|
301
350
|
prompt = await injectPrompt(tmuxBin, cell.tmuxSession, cell.prompt, {
|
|
302
351
|
env: minimalEnv(),
|
|
303
352
|
readyMs: cfg.sendKeysReadyMs != null ? cfg.sendKeysReadyMs : 400,
|
|
304
353
|
target,
|
|
305
|
-
engine, cell, // per la redazione del reason se paste-buffer fallisce (§9h)
|
|
354
|
+
engine: launchEngine, cell, // per la redazione del reason se paste-buffer fallisce (§9h)
|
|
306
355
|
});
|
|
307
356
|
}
|
|
308
357
|
cache = { ...cache, at: 0 }; // invalida: prossimo status rilegge tmux
|
|
@@ -332,13 +381,14 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
332
381
|
return up(cellId);
|
|
333
382
|
}
|
|
334
383
|
|
|
335
|
-
async function setEngine(cellId, engId) {
|
|
384
|
+
async function setEngine(cellId, engId, opts = {}) {
|
|
336
385
|
if (readonly()) throw httpError(403, 'READONLY: engine bloccato');
|
|
337
386
|
const defs = reloadDefs();
|
|
338
387
|
if (!findCell(defs, cellId)) throw httpError(400, `cella sconosciuta: ${cellId}`);
|
|
339
388
|
if (!findEngine(defs, engId)) throw httpError(400, `engine non valido: ${engId}`);
|
|
340
|
-
await mutate(defs, (d) =>
|
|
341
|
-
|
|
389
|
+
await mutate(defs, (d) => applyCellEngineModel(findCell(d, cellId), engId, opts.model, typeof opts.model === 'string' && opts.model !== ''));
|
|
390
|
+
const saved = reloadDefs(); const target = findCell(saved, cellId);
|
|
391
|
+
return { ok: true, engine: engId, model: target.model || '' };
|
|
342
392
|
}
|
|
343
393
|
|
|
344
394
|
async function setBoot(cellId, enabled) {
|
|
@@ -359,13 +409,31 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
359
409
|
await mutate(reloadDefs(), (d) => { d.engines.push(def); });
|
|
360
410
|
return { ok: true, id: def.id };
|
|
361
411
|
}
|
|
362
|
-
async function editEngine(id, patch) {
|
|
412
|
+
async function editEngine(id, patch, envChanges) {
|
|
363
413
|
if (readonly()) throw httpError(403, 'READONLY: edit-engine bloccato');
|
|
364
414
|
if (!id) throw httpError(400, 'id engine mancante');
|
|
365
415
|
const defs = reloadDefs();
|
|
366
416
|
if (!findEngine(defs, id)) throw httpError(400, `engine inesistente: ${id}`);
|
|
367
|
-
|
|
368
|
-
|
|
417
|
+
if (patch && (Object.prototype.hasOwnProperty.call(patch, 'id') || Object.prototype.hasOwnProperty.call(patch, 'env'))) {
|
|
418
|
+
throw httpError(400, 'id ed env non sono modificabili tramite patch generica');
|
|
419
|
+
}
|
|
420
|
+
await mutate(defs, (d) => {
|
|
421
|
+
const target = findEngine(d, id);
|
|
422
|
+
for (const [key, value] of Object.entries(patch || {})) {
|
|
423
|
+
if (value === null) delete target[key]; else target[key] = value;
|
|
424
|
+
}
|
|
425
|
+
if (envChanges !== undefined) {
|
|
426
|
+
if (!envChanges || typeof envChanges !== 'object' || Array.isArray(envChanges)) throw httpError(400, 'envChanges non valido');
|
|
427
|
+
const next = { ...(target.env || {}) };
|
|
428
|
+
for (const key of Array.isArray(envChanges.remove) ? envChanges.remove : []) delete next[key];
|
|
429
|
+
if (envChanges.set && typeof envChanges.set === 'object' && !Array.isArray(envChanges.set)) {
|
|
430
|
+
for (const [key, value] of Object.entries(envChanges.set)) next[key] = value;
|
|
431
|
+
}
|
|
432
|
+
target.env = next;
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
const sessions = await refreshSessions();
|
|
436
|
+
return { ok: true, activeCells: defs.cells.filter((c) => c.engine === id && sessions.has(c.tmuxSession)).map((c) => c.id) };
|
|
369
437
|
}
|
|
370
438
|
async function removeEngine(id) {
|
|
371
439
|
if (readonly()) throw httpError(403, 'READONLY: remove-engine bloccato');
|
|
@@ -373,7 +441,14 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
373
441
|
if (!findEngine(defs, id)) throw httpError(400, `engine inesistente: ${id}`);
|
|
374
442
|
const used = defs.cells.filter((c) => c.engine === id);
|
|
375
443
|
if (used.length) throw httpError(400, `engine in uso da ${used.length} cella/e: rimuovi prima la cella`);
|
|
376
|
-
await mutate(defs, (d) => {
|
|
444
|
+
await mutate(defs, (d) => {
|
|
445
|
+
d.engines = d.engines.filter((e) => e.id !== id);
|
|
446
|
+
for (const cell of d.cells) {
|
|
447
|
+
if (!cell.models || !Object.prototype.hasOwnProperty.call(cell.models, id)) continue;
|
|
448
|
+
delete cell.models[id];
|
|
449
|
+
if (!Object.keys(cell.models).length) delete cell.models;
|
|
450
|
+
}
|
|
451
|
+
});
|
|
377
452
|
return { ok: true };
|
|
378
453
|
}
|
|
379
454
|
|
|
@@ -389,17 +464,54 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
389
464
|
if (!id) throw httpError(400, 'id cell mancante');
|
|
390
465
|
const defs = reloadDefs();
|
|
391
466
|
if (!findCell(defs, id)) throw httpError(400, `cell inesistente: ${id}`);
|
|
392
|
-
|
|
393
|
-
|
|
467
|
+
if (patch && (Object.prototype.hasOwnProperty.call(patch, 'id') || Object.prototype.hasOwnProperty.call(patch, 'tmuxSession'))) {
|
|
468
|
+
throw httpError(400, 'id e tmuxSession sono immutabili');
|
|
469
|
+
}
|
|
470
|
+
await mutate(defs, (d) => {
|
|
471
|
+
const target = findCell(d, id);
|
|
472
|
+
const hasEngine = typeof patch?.engine === 'string';
|
|
473
|
+
const hasModel = Object.prototype.hasOwnProperty.call(patch || {}, 'model');
|
|
474
|
+
for (const [key, value] of Object.entries(patch || {})) {
|
|
475
|
+
if (key === 'engine' || key === 'model') continue;
|
|
476
|
+
if (value === null) delete target[key]; else target[key] = value;
|
|
477
|
+
}
|
|
478
|
+
if (hasEngine || hasModel) applyCellEngineModel(target, hasEngine ? patch.engine : target.engine, patch?.model, hasModel);
|
|
479
|
+
});
|
|
480
|
+
const sessions = await refreshSessions();
|
|
481
|
+
return { ok: true, active: sessions.has(findCell(defs, id).tmuxSession) };
|
|
394
482
|
}
|
|
395
|
-
async function removeCell(id) {
|
|
483
|
+
async function removeCell(id, opts = {}) {
|
|
396
484
|
if (readonly()) throw httpError(403, 'READONLY: remove-cell bloccato');
|
|
397
|
-
|
|
398
|
-
|
|
485
|
+
let defs = reloadDefs();
|
|
486
|
+
const cell = findCell(defs, id);
|
|
487
|
+
if (!cell) throw httpError(400, `cell inesistente: ${id}`);
|
|
488
|
+
const sessions = await refreshSessions();
|
|
489
|
+
if (sessions.has(cell.tmuxSession)) {
|
|
490
|
+
if (opts.stop !== true) throw httpError(409, 'cell attiva: conferma stop e rimozione');
|
|
491
|
+
await down(id);
|
|
492
|
+
defs = reloadDefs();
|
|
493
|
+
}
|
|
399
494
|
await mutate(defs, (d) => { d.cells = d.cells.filter((c) => c.id !== id); });
|
|
400
495
|
return { ok: true };
|
|
401
496
|
}
|
|
402
497
|
|
|
498
|
+
// Vista editabile ma secret-safe: gli env values restano write-only.
|
|
499
|
+
function definitions() {
|
|
500
|
+
const defs = reloadDefs();
|
|
501
|
+
return {
|
|
502
|
+
schemaVersion: defs.schemaVersion,
|
|
503
|
+
engines: defs.engines.map((e) => {
|
|
504
|
+
const out = { ...e, envKeys: Object.keys(e.env || {}).sort() };
|
|
505
|
+
delete out.env;
|
|
506
|
+
if (e.managed) out.managedInfo = describeManaged(e.managed, { ...cfg, home });
|
|
507
|
+
return out;
|
|
508
|
+
}),
|
|
509
|
+
cells: defs.cells.map((c) => ({ ...c, ...(c.models ? { models: { ...c.models } } : {}) })),
|
|
510
|
+
managedCatalog: publicCatalog(),
|
|
511
|
+
managedConfig: { providerSecretsPath: cfg.providerSecretsPath || path.join(home, '.nexuscrew', 'providers.env') },
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
403
515
|
// Scrive il draft mutato; atomicWrite valida PRIMA (fail-closed). Su input
|
|
404
516
|
// invalido: backup predecessore + throw -> httpError(400) (mai garbage).
|
|
405
517
|
async function mutate(defs, mutator) {
|
|
@@ -421,10 +533,24 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
421
533
|
schemaVersion: 1,
|
|
422
534
|
caps: CAPS,
|
|
423
535
|
engine: {
|
|
536
|
+
kind: { type: 'enum', values: ['managed', 'custom'], default: 'managed' },
|
|
424
537
|
id: { type: 'string', required: true, pattern: '^[a-z0-9._-]{1,32}$', max: 32 },
|
|
425
538
|
label: { type: 'string', required: false, max: CAPS.MAX_LABEL_LEN, default: '<id>' },
|
|
426
539
|
rc: { type: 'boolean', required: false, default: false },
|
|
427
|
-
|
|
540
|
+
managed: {
|
|
541
|
+
type: 'object', requiredFor: 'managed',
|
|
542
|
+
client: { type: 'enum', values: ['claude', 'codex', 'codex-vl', 'pi'] },
|
|
543
|
+
provider: { type: 'catalog', source: 'managedCatalog' },
|
|
544
|
+
credentialProfile: { type: 'string', required: false, max: 32 },
|
|
545
|
+
model: { type: 'string', required: false, max: CAPS.MAX_MODEL_VAL_LEN },
|
|
546
|
+
permissionPolicy: { type: 'enum', values: ['standard', 'unsafe'], default: 'standard' },
|
|
547
|
+
displayName: { type: 'string', requiredFor: 'provider=custom', max: CAPS.MAX_LABEL_LEN },
|
|
548
|
+
protocol: { type: 'catalog', source: 'managedCatalog.protocol' },
|
|
549
|
+
baseUrl: { type: 'string', requiredFor: 'provider=custom', max: CAPS.MAX_COMMAND_LEN },
|
|
550
|
+
envKey: { type: 'string', requiredFor: 'provider=custom', pattern: '^[A-Za-z_][A-Za-z0-9_]{0,63}$' },
|
|
551
|
+
providerId: { type: 'string', requiredFor: 'provider=custom', pattern: '^[a-z][a-z0-9_-]{0,31}$' },
|
|
552
|
+
},
|
|
553
|
+
command: { type: 'string', requiredFor: 'custom', max: CAPS.MAX_COMMAND_LEN, absolute: true },
|
|
428
554
|
args: { type: 'array', of: 'string', required: false, max: CAPS.MAX_ARGS, itemMax: CAPS.MAX_ARG_LEN },
|
|
429
555
|
env: {
|
|
430
556
|
type: 'object', required: false, maxKeys: CAPS.MAX_ENV_KEYS,
|
|
@@ -448,13 +574,14 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
448
574
|
engine: { type: 'string', required: true, ref: 'engine.id' },
|
|
449
575
|
boot: { type: 'boolean', required: false, default: false },
|
|
450
576
|
model: { type: 'string', required: false, max: CAPS.MAX_MODEL_VAL_LEN },
|
|
577
|
+
models: { type: 'object', required: false, keyRef: 'engine.id', valueMax: CAPS.MAX_MODEL_VAL_LEN },
|
|
451
578
|
prompt: { type: 'string', required: false, max: CAPS.MAX_PROMPT_LEN },
|
|
452
579
|
},
|
|
453
580
|
};
|
|
454
581
|
}
|
|
455
582
|
|
|
456
583
|
function capabilities() {
|
|
457
|
-
return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'schema'];
|
|
584
|
+
return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'schema', 'definitions'];
|
|
458
585
|
}
|
|
459
586
|
|
|
460
587
|
return {
|
|
@@ -463,7 +590,7 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
463
590
|
status, up, down, restart, engine: setEngine, boot: setBoot, isCellSession,
|
|
464
591
|
defineEngine, editEngine, removeEngine,
|
|
465
592
|
defineCell, editCell, removeCell,
|
|
466
|
-
schema, capabilities,
|
|
593
|
+
schema, definitions, capabilities,
|
|
467
594
|
};
|
|
468
595
|
}
|
|
469
596
|
|
package/lib/fleet/definitions.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
const fs = require('node:fs');
|
|
12
12
|
const path = require('node:path');
|
|
13
13
|
const crypto = require('node:crypto');
|
|
14
|
+
const { normalizeManagedSpec } = require('./managed.js');
|
|
14
15
|
|
|
15
16
|
// --- Cap + identita' (dichiarati; ragionevoli per un file di flotta locale) ---
|
|
16
17
|
const SCHEMA_VERSION = 1;
|
|
@@ -145,6 +146,17 @@ function parseEngine(e) {
|
|
|
145
146
|
rc = e.rc;
|
|
146
147
|
}
|
|
147
148
|
|
|
149
|
+
// Managed: NexusCrew conosce client/provider e compone internamente il
|
|
150
|
+
// processo. Nessun command/env/argv o segreto vive nella definizione.
|
|
151
|
+
if (e.managed !== undefined) {
|
|
152
|
+
const managed = normalizeManagedSpec(e.managed);
|
|
153
|
+
if (!managed) return null;
|
|
154
|
+
for (const key of ['command', 'args', 'env', 'promptMode', 'promptFlag', 'model']) {
|
|
155
|
+
if (e[key] !== undefined) return null;
|
|
156
|
+
}
|
|
157
|
+
return { id: e.id, label, rc, managed };
|
|
158
|
+
}
|
|
159
|
+
|
|
148
160
|
// command (obbligatorio, stringa non vuota; il trust si verifica a parte)
|
|
149
161
|
if (typeof e.command !== 'string' || !e.command || e.command.length > MAX_COMMAND_LEN) return null;
|
|
150
162
|
|
|
@@ -229,6 +241,19 @@ function parseCell(c, engineIds) {
|
|
|
229
241
|
model = c.model;
|
|
230
242
|
}
|
|
231
243
|
|
|
244
|
+
// Ultimo modello per engine, persistito per cella. Consente di tornare a un
|
|
245
|
+
// provider e ritrovare la scelta precedente senza trascinarla su altri engine.
|
|
246
|
+
let models = {};
|
|
247
|
+
if (c.models !== undefined) {
|
|
248
|
+
if (!c.models || typeof c.models !== 'object' || Array.isArray(c.models)) return null;
|
|
249
|
+
const entries = Object.entries(c.models);
|
|
250
|
+
if (entries.length > MAX_ENGINES) return null;
|
|
251
|
+
for (const [engineId, value] of entries) {
|
|
252
|
+
if (!engineIds.has(engineId) || typeof value !== 'string' || !value || value.length > MAX_MODEL_VAL_LEN) return null;
|
|
253
|
+
models[engineId] = value;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
232
257
|
// prompt (opzionale, cap)
|
|
233
258
|
let prompt;
|
|
234
259
|
if (c.prompt !== undefined) {
|
|
@@ -254,6 +279,7 @@ function parseCell(c, engineIds) {
|
|
|
254
279
|
|
|
255
280
|
const out = { id: c.id, cwd: c.cwd, engine: c.engine, boot, tmuxSession };
|
|
256
281
|
if (model !== undefined) out.model = model;
|
|
282
|
+
if (Object.keys(models).length) out.models = models;
|
|
257
283
|
if (prompt !== undefined) out.prompt = prompt;
|
|
258
284
|
return out;
|
|
259
285
|
}
|