@mmmbuto/nexuscrew 0.8.44 → 0.8.46
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 +51 -0
- package/README.md +7 -1
- package/frontend/dist/assets/{index-CNI9gSN5.js → index-DMG-rioF.js} +3 -3
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/cells/routes.js +3 -2
- package/lib/cli/commands.js +5 -2
- package/lib/cli/pidfile.js +45 -2
- package/lib/fleet/builtin.js +21 -4
- package/lib/fleet/managed.js +89 -23
- package/lib/fleet/runtime.js +24 -11
- package/lib/mcp/cells.js +27 -4
- package/lib/mcp/server.js +16 -3
- package/lib/nodes/commands.js +17 -0
- package/lib/nodes/health.js +23 -2
- package/lib/nodes/reverse-pool.js +221 -0
- package/lib/nodes/reverse-rotation.js +78 -0
- package/lib/nodes/reverse-slot-listeners.js +80 -0
- package/lib/nodes/reverse-slot-proof.js +108 -0
- package/lib/nodes/store.js +169 -11
- package/lib/nodes/tunnel-supervisor.js +8 -1
- package/lib/nodes/tunnel.js +96 -11
- package/lib/proxy/federation.js +337 -9
- package/lib/server.js +247 -1
- package/lib/settings/pairing-coordinator.js +18 -0
- package/lib/settings/public-peering-routes.js +58 -4
- package/lib/settings/routes.js +31 -4
- package/package.json +1 -1
package/frontend/dist/index.html
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
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-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-DMG-rioF.js"></script>
|
|
15
15
|
<link rel="stylesheet" crossorigin href="/assets/index-BAq6N1Md.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.8.
|
|
1
|
+
{"version":"0.8.46"}
|
package/lib/cells/routes.js
CHANGED
|
@@ -56,10 +56,11 @@ function cellsRoutes({ fleetP, instanceId, submit, readonly = () => false, now =
|
|
|
56
56
|
|
|
57
57
|
async function status() {
|
|
58
58
|
const fleet = await fleetP;
|
|
59
|
-
|
|
59
|
+
const statusFn = fleet && (typeof fleet.cellStatus === 'function' ? fleet.cellStatus : fleet.status);
|
|
60
|
+
if (!fleet || fleet.available !== true || typeof statusFn !== 'function') {
|
|
60
61
|
return { available: false, cells: [] };
|
|
61
62
|
}
|
|
62
|
-
return
|
|
63
|
+
return statusFn.call(fleet);
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
r.get('/', async (_req, res) => {
|
package/lib/cli/commands.js
CHANGED
|
@@ -264,7 +264,10 @@ function status(opts = {}) {
|
|
|
264
264
|
remotePort: red.remotePort, localPort: red.localPort,
|
|
265
265
|
direction: red.direction, shared: red.shared, hasToken: red.hasToken,
|
|
266
266
|
tunnel: n.direction === 'inbound'
|
|
267
|
-
? {
|
|
267
|
+
? {
|
|
268
|
+
status: n.shared === true ? 'shared-peer' : 'private-peer', managed: false,
|
|
269
|
+
share: n.shared === true ? 'enabled' : 'disabled',
|
|
270
|
+
}
|
|
268
271
|
: nodesTunnel.readTunnelState(home, n.name),
|
|
269
272
|
};
|
|
270
273
|
});
|
|
@@ -281,7 +284,7 @@ function status(opts = {}) {
|
|
|
281
284
|
log(`port: ${out.port}`);
|
|
282
285
|
log(`url: ${out.url}`);
|
|
283
286
|
log(`roles: client=${out.roles.client} node=${out.roles.node}`);
|
|
284
|
-
log(`nodes: ${out.nodes.length === 0 ? '(nessuno)' : out.nodes.map((n) => `${n.name}[${n.tunnel.status}]`).join(', ')}`);
|
|
287
|
+
log(`nodes: ${out.nodes.length === 0 ? '(nessuno)' : out.nodes.map((n) => `${n.name}[${n.tunnel.status}${n.tunnel.share ? `, Share ${n.tunnel.share}` : ''}]`).join(', ')}`);
|
|
285
288
|
if (platform === 'termux') log(`boot: ${out.bootScriptInstalled ? 'boot-script installed' : 'no boot-script'}`);
|
|
286
289
|
}
|
|
287
290
|
return out;
|
package/lib/cli/pidfile.js
CHANGED
|
@@ -18,11 +18,40 @@ function readPidfile(p) {
|
|
|
18
18
|
} catch (_) { return null; }
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
function currentUid() {
|
|
22
|
+
try { return typeof process.getuid === 'function' ? process.getuid() : null; }
|
|
23
|
+
catch (_) { return null; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// `/proc/<pid>/stat` field 22 is the kernel start tick. Unlike a PID or an
|
|
27
|
+
// argv it cannot be recreated by a later process. macOS has no /proc, so a
|
|
28
|
+
// conservative `ps lstart` fallback still combines with UID, argv and runId.
|
|
29
|
+
function readProcessStart(pid) {
|
|
30
|
+
try {
|
|
31
|
+
const raw = fs.readFileSync(`/proc/${pid}/stat`, 'utf8').trim();
|
|
32
|
+
const match = raw.match(/^\d+\s+\([^)]*\)\s+(.+)$/);
|
|
33
|
+
const fields = match && match[1].trim().split(/\s+/);
|
|
34
|
+
const ticks = fields && fields[19]; // field 22, after state=field 3
|
|
35
|
+
if (/^\d+$/.test(String(ticks || ''))) return `linux:${ticks}`;
|
|
36
|
+
} catch (_) {}
|
|
37
|
+
try {
|
|
38
|
+
const text = execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8' }).trim();
|
|
39
|
+
return text ? `ps:${text}` : null;
|
|
40
|
+
} catch (_) { return null; }
|
|
41
|
+
}
|
|
42
|
+
|
|
21
43
|
// Exclusive create (wx): fallisce se il pidfile esiste già (no overwrite silenzioso).
|
|
22
44
|
function writePidfile(p, pid, cmd, extra = {}) {
|
|
23
45
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
24
46
|
const safeExtra = extra && typeof extra === 'object' && !Array.isArray(extra) ? extra : {};
|
|
25
|
-
const
|
|
47
|
+
const processStart = readProcessStart(pid);
|
|
48
|
+
const uid = currentUid();
|
|
49
|
+
const meta = JSON.stringify({
|
|
50
|
+
pid, cmd: cmd || '', startTs: Date.now(),
|
|
51
|
+
...(uid === null ? {} : { uid }),
|
|
52
|
+
...(processStart ? { processStart } : {}),
|
|
53
|
+
...safeExtra,
|
|
54
|
+
});
|
|
26
55
|
fs.writeFileSync(p, meta + '\n', { flag: 'wx', mode: 0o600 });
|
|
27
56
|
}
|
|
28
57
|
|
|
@@ -78,6 +107,19 @@ function isAlive(meta, impl = {}) {
|
|
|
78
107
|
return true;
|
|
79
108
|
}
|
|
80
109
|
|
|
110
|
+
// Strong ownership used by per-slot reverse supervisors. Older generic
|
|
111
|
+
// pidfiles remain readable for lifecycle compatibility, but a rotatable slot
|
|
112
|
+
// is never stopped or adopted unless all four local facts are present.
|
|
113
|
+
function isAttributable(meta, impl = {}) {
|
|
114
|
+
if (!meta || !Number.isFinite(meta.pid) || !Number.isInteger(meta.uid)
|
|
115
|
+
|| typeof meta.processStart !== 'string' || !meta.processStart) return false;
|
|
116
|
+
const uid = impl.currentUidImpl ? impl.currentUidImpl() : currentUid();
|
|
117
|
+
if (uid === null || uid !== meta.uid) return false;
|
|
118
|
+
if (!isAlive(meta, impl)) return false;
|
|
119
|
+
const liveStart = (impl.readProcessStartImpl || readProcessStart)(meta.pid);
|
|
120
|
+
return typeof liveStart === 'string' && liveStart === meta.processStart;
|
|
121
|
+
}
|
|
122
|
+
|
|
81
123
|
// Rimuove pidfile stale (pid morto o non verificabile). Ritorna true se rimosso.
|
|
82
124
|
function cleanStale(p, impl = {}) {
|
|
83
125
|
const meta = readPidfile(p);
|
|
@@ -122,5 +164,6 @@ function killPidfile(p, signal = 'SIGTERM', impl = {}) {
|
|
|
122
164
|
|
|
123
165
|
module.exports = {
|
|
124
166
|
defaultPidfilePath, readPidfile, writePidfile, removePidfile,
|
|
125
|
-
|
|
167
|
+
currentUid, readProcessStart, pidOwnership, pidExists, readCmdline,
|
|
168
|
+
isAlive, isAttributable, cleanStale, killPidfile,
|
|
126
169
|
};
|
package/lib/fleet/builtin.js
CHANGED
|
@@ -34,7 +34,7 @@ const {
|
|
|
34
34
|
resolveCwd, normalizeCwdRel, deriveCwdRel,
|
|
35
35
|
} = require('./definitions.js');
|
|
36
36
|
const {
|
|
37
|
-
publicCatalog, describeManaged, describeCatalogCredential, defaultShellEngine, defaultAgyEngine,
|
|
37
|
+
publicCatalog, describeManaged, describeCatalogCredential, defaultShellEngine, defaultAgyEngine, defaultKimiEngine,
|
|
38
38
|
} = require('./managed.js');
|
|
39
39
|
const { validEnvKey } = require('./env-key.js');
|
|
40
40
|
const { setCredential, removeCredential } = require('./credentials.js');
|
|
@@ -107,6 +107,21 @@ function backfillAgyEngine(defsPath, defs, cfg = {}) {
|
|
|
107
107
|
try { return atomicWrite(defsPath, draft); } catch (_) { return defs; }
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
// Backfill dell'engine Kimi Code CLI nativo: installazioni esistenti ricevono
|
|
111
|
+
// kimi.native in modo idempotente e non distruttivo. Nessun platform gate: il
|
|
112
|
+
// CLI gira ovunque giri Node e su Termux il resolver applica gia' il workaround
|
|
113
|
+
// shebang. Idempotente (gia' presente -> skip), NON sovrascrive un id
|
|
114
|
+
// 'kimi.native' gia' scelto dall'utente per altro (collisione -> skip, store
|
|
115
|
+
// invariato), rispetta il cap MAX_ENGINES. Non tocca CELLE.
|
|
116
|
+
function backfillKimiEngine(defsPath, defs) {
|
|
117
|
+
if (!defs || defs.engines.some((engine) => engine.managed?.client === 'kimi')) return defs;
|
|
118
|
+
if (defs.engines.some((engine) => engine.id === 'kimi.native')) return defs;
|
|
119
|
+
if (defs.engines.length >= CAPS.MAX_ENGINES) return defs;
|
|
120
|
+
const draft = draftFrom(defs);
|
|
121
|
+
draft.engines.push(defaultKimiEngine());
|
|
122
|
+
try { return atomicWrite(defsPath, draft); } catch (_) { return defs; }
|
|
123
|
+
}
|
|
124
|
+
|
|
110
125
|
// Applica engine + modello + policy come un'unica transizione. Ogni engine ricorda
|
|
111
126
|
// il proprio ultimo modello E l'ultima policy; passando a un altro engine né
|
|
112
127
|
// l'uno né l'altra attraversano il confine. La policy e' PER-CELL PER-ENGINE:
|
|
@@ -275,6 +290,7 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
275
290
|
}
|
|
276
291
|
boot = backfillShellEngine(defsPath, boot);
|
|
277
292
|
boot = backfillAgyEngine(defsPath, boot, cfg);
|
|
293
|
+
boot = backfillKimiEngine(defsPath, boot);
|
|
278
294
|
}
|
|
279
295
|
|
|
280
296
|
// Adopt or create the shared server before exposing a mutable Fleet. Reapply
|
|
@@ -296,7 +312,7 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
296
312
|
cfg, home, defsPath, tmuxBin, readonly, launchBroker, boot, ensureProtection,
|
|
297
313
|
});
|
|
298
314
|
const {
|
|
299
|
-
status, up, down, restart, isCellSession,
|
|
315
|
+
status, cellStatus, up, down, restart, isCellSession,
|
|
300
316
|
reloadDefs, findCell, findEngine, refreshSessions, commitDefs,
|
|
301
317
|
} = rt;
|
|
302
318
|
|
|
@@ -808,7 +824,7 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
808
824
|
rc: { type: 'boolean', required: false, default: false },
|
|
809
825
|
managed: {
|
|
810
826
|
type: 'object', requiredFor: 'managed',
|
|
811
|
-
client: { type: 'enum', values: ['claude', 'codex', 'codex-vl', 'pi', 'agy', 'shell'] },
|
|
827
|
+
client: { type: 'enum', values: ['claude', 'codex', 'codex-vl', 'pi', 'agy', 'kimi', 'shell'] },
|
|
812
828
|
provider: { type: 'catalog', source: 'managedCatalog' },
|
|
813
829
|
credentialProfile: { type: 'string', required: false, max: 32 },
|
|
814
830
|
model: { type: 'string', required: false, max: CAPS.MAX_MODEL_VAL_LEN },
|
|
@@ -861,7 +877,7 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
861
877
|
return {
|
|
862
878
|
available: true,
|
|
863
879
|
provider: 'builtin',
|
|
864
|
-
status, up, down, restart, engine: setEngine, boot: setBoot, isCellSession,
|
|
880
|
+
status, cellStatus, up, down, restart, engine: setEngine, boot: setBoot, isCellSession,
|
|
865
881
|
defineEngine, editEngine, removeEngine,
|
|
866
882
|
defineCell, editCell, removeCell, importCell, restoreCells, restoreEngines,
|
|
867
883
|
schema, definitions, capabilities,
|
|
@@ -873,6 +889,7 @@ module.exports = {
|
|
|
873
889
|
createBuiltinFleet,
|
|
874
890
|
backfillShellEngine,
|
|
875
891
|
backfillAgyEngine,
|
|
892
|
+
backfillKimiEngine,
|
|
876
893
|
resolveCellCwd,
|
|
877
894
|
composeLaunchArgv,
|
|
878
895
|
composeClientInvocation,
|
package/lib/fleet/managed.js
CHANGED
|
@@ -65,7 +65,7 @@ const MANAGED_KEYS = new Set(['client', 'provider', 'credentialProfile', 'model'
|
|
|
65
65
|
// resolution order (runtime -> store -> shell -> key files -> legacy) so a
|
|
66
66
|
// pre-WP1 fleet.json migrates no-op: no existing cell changes resolution.
|
|
67
67
|
const CREDENTIAL_SOURCES = Object.freeze(['environment', 'nexuscrew-store', 'auto']);
|
|
68
|
-
const CLIENT_LABELS = Object.freeze({ claude: 'Claude Code', codex: 'Codex', 'codex-vl': 'Codex-VL', pi: 'Pi', agy: 'Agy', shell: 'Shell' });
|
|
68
|
+
const CLIENT_LABELS = Object.freeze({ claude: 'Claude Code', codex: 'Codex', 'codex-vl': 'Codex-VL', pi: 'Pi', agy: 'Agy', kimi: 'Kimi Code CLI', shell: 'Shell' });
|
|
69
69
|
const PROVIDER_ID_RE = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
70
70
|
|
|
71
71
|
function validBaseUrl(value) {
|
|
@@ -142,6 +142,16 @@ const CATALOG = Object.freeze([
|
|
|
142
142
|
// (core) per la UI; describeManaged lo dichiara non configurato altrove.
|
|
143
143
|
// Termux/Windows restano fuori dal primary: l'utente usa agy via shell.local.
|
|
144
144
|
{ id: 'agy.native', client: 'agy', provider: 'native', label: 'Agy', auth: 'login', protocol: 'agy_native', core: true },
|
|
145
|
+
|
|
146
|
+
// Kimi Code CLI nativo (@moonshot-ai/kimi-code): client gestito con auth
|
|
147
|
+
// delegata al login del CLI (device-code flow, provider in config.toml).
|
|
148
|
+
// NexusCrew non legge ne' copia credenziali: nessun env provider, nessun
|
|
149
|
+
// token su argv. Distinto dal provider claude.kimi-code (adattatore Claude
|
|
150
|
+
// Code sull'endpoint Kimi), che resta il percorso K3 gestito via ANTHROPIC_*.
|
|
151
|
+
// Non e' un default seed: backfill idempotente in builtin.js, come Agy ma
|
|
152
|
+
// senza platform gate (il CLI gira ovunque giri Node; su Termux il resolver
|
|
153
|
+
// applica gia' il workaround shebang needsExplicitNode).
|
|
154
|
+
{ id: 'kimi.native', client: 'kimi', provider: 'native', label: 'Kimi account (CLI login)', auth: 'login', protocol: 'kimi_native', core: true, notice: 'kimi-native' },
|
|
145
155
|
]);
|
|
146
156
|
|
|
147
157
|
function profileFor(client, provider, credentialProfile) {
|
|
@@ -234,6 +244,18 @@ function defaultAgyEngine() {
|
|
|
234
244
|
};
|
|
235
245
|
}
|
|
236
246
|
|
|
247
|
+
// Engine Kimi Code CLI per il backfill (builtin.js): standard di default, auth
|
|
248
|
+
// delegata al login nativo del CLI, niente remote-control. Non e' un default seed.
|
|
249
|
+
function defaultKimiEngine() {
|
|
250
|
+
const profile = CATALOG.find((entry) => entry.id === 'kimi.native');
|
|
251
|
+
return {
|
|
252
|
+
id: profile.id,
|
|
253
|
+
label: CLIENT_LABELS.kimi,
|
|
254
|
+
rc: false,
|
|
255
|
+
managed: { client: 'kimi', provider: 'native', model: '', permissionPolicy: 'standard' },
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
237
259
|
function parseAssignments(raw) {
|
|
238
260
|
const out = {};
|
|
239
261
|
for (const line of raw.split(/\r?\n/)) {
|
|
@@ -503,12 +525,24 @@ async function discoverOllamaModels(opts = {}) {
|
|
|
503
525
|
}
|
|
504
526
|
}
|
|
505
527
|
|
|
528
|
+
// Una discovery esterna non deve mai consumare l'intero budget del bridge MCP
|
|
529
|
+
// (10 s): il caller ha ancora margine per serializzare la directory e fallire
|
|
530
|
+
// in modo diagnostico. Ogni futura discovery tramite binario deve usare lo
|
|
531
|
+
// stesso contratto bounded + negative-cache, non una retry ad ogni richiesta.
|
|
532
|
+
const EXTERNAL_DISCOVERY_TIMEOUT_MS = 5000;
|
|
506
533
|
let piCache = { at: 0, providers: {} };
|
|
507
534
|
let piInFlight = null;
|
|
535
|
+
function copyPiProviders(providers) {
|
|
536
|
+
return Object.fromEntries(Object.entries(providers).map(([key, models]) => [key, [...models]]));
|
|
537
|
+
}
|
|
538
|
+
|
|
508
539
|
async function discoverPiModels(opts = {}) {
|
|
509
540
|
const now = Date.now(); const ttl = opts.ttlMs === undefined ? 300000 : opts.ttlMs;
|
|
510
|
-
|
|
511
|
-
|
|
541
|
+
// `at`, non il contenuto, rende valida anche una failure cacheata: una lista
|
|
542
|
+
// vuota e' un risultato operativo, non il segnale di rilanciare un binario
|
|
543
|
+
// eventualmente bloccato ad ogni richiesta.
|
|
544
|
+
if (!opts.noCache && piCache.at > 0 && now - piCache.at < ttl) {
|
|
545
|
+
return copyPiProviders(piCache.providers);
|
|
512
546
|
}
|
|
513
547
|
const home = opts.home || require('node:os').homedir();
|
|
514
548
|
const binary = opts.binary || findBinary('pi', home);
|
|
@@ -516,24 +550,38 @@ async function discoverPiModels(opts = {}) {
|
|
|
516
550
|
if (!opts.noCache && piInFlight) return piInFlight;
|
|
517
551
|
const execFileImpl = opts.execFileImpl || execFile;
|
|
518
552
|
const load = async () => {
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
553
|
+
try {
|
|
554
|
+
const stdout = await new Promise((resolve, reject) => {
|
|
555
|
+
execFileImpl(binary, ['--list-models'], {
|
|
556
|
+
encoding: 'utf8', timeout: opts.timeoutMs === undefined ? EXTERNAL_DISCOVERY_TIMEOUT_MS : opts.timeoutMs,
|
|
557
|
+
maxBuffer: 1024 * 1024,
|
|
558
|
+
}, (err, out) => {
|
|
559
|
+
if (err) reject(err); else resolve(String(out || ''));
|
|
560
|
+
});
|
|
522
561
|
});
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
562
|
+
const providers = {};
|
|
563
|
+
for (const line of stdout.split(/\r?\n/).slice(1)) {
|
|
564
|
+
const [provider, model] = line.trim().split(/\s+/);
|
|
565
|
+
if (!PROVIDER_ID_RE.test(provider || '') || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(model || '')) continue;
|
|
566
|
+
(providers[provider] ||= []).push(model);
|
|
567
|
+
}
|
|
568
|
+
for (const key of Object.keys(providers)) providers[key] = [...new Set(providers[key])];
|
|
569
|
+
piCache = { at: now, providers };
|
|
570
|
+
return copyPiProviders(providers);
|
|
571
|
+
} catch (_) {
|
|
572
|
+
// Cache negativa: una failure (timeout compreso) vale per il TTL intero.
|
|
573
|
+
// Questo mantiene le route Fleet disponibili anche quando un binario di
|
|
574
|
+
// discovery e' installato ma non risponde.
|
|
575
|
+
// `noCache` e' un refresh diagnostico richiesto dall'operatore: se
|
|
576
|
+
// fallisce non deve avvelenare una cache condivisa ancora valida.
|
|
577
|
+
if (!opts.noCache) piCache = { at: now, providers: {} };
|
|
578
|
+
return {};
|
|
529
579
|
}
|
|
530
|
-
for (const key of Object.keys(providers)) providers[key] = [...new Set(providers[key])];
|
|
531
|
-
piCache = { at: now, providers };
|
|
532
|
-
return Object.fromEntries(Object.entries(providers).map(([k, v]) => [k, [...v]]));
|
|
533
580
|
};
|
|
534
|
-
if (opts.noCache)
|
|
535
|
-
|
|
536
|
-
|
|
581
|
+
if (opts.noCache) return load();
|
|
582
|
+
// load() assorbe gia' gli errori operativi. Il catch e' una cintura per una
|
|
583
|
+
// futura regressione: chi aspetta il single-flight non deve mai ricevere un
|
|
584
|
+
// rejection che renda la directory Fleet indisponibile.
|
|
537
585
|
piInFlight = load().catch(() => ({})).finally(() => { piInFlight = null; });
|
|
538
586
|
return piInFlight;
|
|
539
587
|
}
|
|
@@ -727,6 +775,11 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
727
775
|
if (effectivePolicy === 'unsafe') {
|
|
728
776
|
if (spec.client === 'claude' || spec.client === 'agy') args.push('--dangerously-skip-permissions');
|
|
729
777
|
if (spec.client === 'codex' || spec.client === 'codex-vl') args.push('--dangerously-bypass-approvals-and-sandbox');
|
|
778
|
+
// Kimi Code CLI: unsafe mappa su --yolo (auto-approva le chiamate tool
|
|
779
|
+
// ordinarie ma l'agente puo' ancora fare domande). --auto (fully
|
|
780
|
+
// autonomous, nessuna domanda) NON e' mappato: il contratto NexusCrew
|
|
781
|
+
// distingue solo standard/unsafe e il default resta interattivo.
|
|
782
|
+
if (spec.client === 'kimi') args.push('--yolo');
|
|
730
783
|
}
|
|
731
784
|
let shellOneShot = false;
|
|
732
785
|
if (spec.client === 'shell') {
|
|
@@ -858,8 +911,17 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
858
911
|
// dal push generico qui sotto). Senza prompt parte il TUI interattivo `agy`.
|
|
859
912
|
if (model) args.push('--model', model);
|
|
860
913
|
if (cell?.prompt) args.push('--prompt-interactive');
|
|
914
|
+
} else if (spec.client === 'kimi') {
|
|
915
|
+
// Kimi Code CLI nativo: auth e provider gestiti dal CLI (login device-code,
|
|
916
|
+
// config.toml): niente env provider, niente credenziali su argv. Il CLI non
|
|
917
|
+
// documenta un flag prompt interattivo (`kimi -p` e' non-interattivo, senza
|
|
918
|
+
// TUI): il prompt della cella NON va su argv ma viene iniettato via
|
|
919
|
+
// bracketed paste dopo la readiness (promptMode 'send-keys' qui sotto,
|
|
920
|
+
// reiniettato anche ai restart supervisionati). Senza argomenti parte il
|
|
921
|
+
// TUI interattivo nella cwd della cella.
|
|
922
|
+
if (model) args.push('--model', model);
|
|
861
923
|
}
|
|
862
|
-
if (spec.client !== 'shell' && cell?.prompt) args.push(cell.prompt);
|
|
924
|
+
if (spec.client !== 'shell' && spec.client !== 'kimi' && cell?.prompt) args.push(cell.prompt);
|
|
863
925
|
// nexuscrew-store source: neutralize the profile's env set in the composed
|
|
864
926
|
// child env (unset, never empty), so the runtime cannot leak credentials that
|
|
865
927
|
// the local store is meant to own.
|
|
@@ -870,7 +932,8 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
870
932
|
args.unshift(info.binary);
|
|
871
933
|
}
|
|
872
934
|
return { ok: true, info, engine: {
|
|
873
|
-
...engine, command, args, env,
|
|
935
|
+
...engine, command, args, env,
|
|
936
|
+
promptMode: spec.client === 'kimi' ? 'send-keys' : 'managed-argv', clientBinary: info.binary,
|
|
874
937
|
...(spec.client === 'shell' ? { shellOneShot } : {}),
|
|
875
938
|
} };
|
|
876
939
|
}
|
|
@@ -883,7 +946,10 @@ function publicCatalog() {
|
|
|
883
946
|
protocols: [...(p.protocols || [p.protocol])], supportsUnsafe: !['pi', 'shell'].includes(p.client), requiresModel: !!p.requiresModel || !!p.custom,
|
|
884
947
|
permissionPolicyDefault: p.client === 'claude' ? 'unsafe' : 'standard',
|
|
885
948
|
rc: !!p.rc, custom: !!p.custom, default: !!p.default, notice: p.notice || '',
|
|
886
|
-
|
|
949
|
+
// 'login'/'none' non sono variabili d'ambiente: nessuna KEY section per gli
|
|
950
|
+
// engine che delegano l'auth al login del CLI (rappresentazione onesta).
|
|
951
|
+
credentialEnv: p.auth === 'dynamic' ? !!p.credentialEnv
|
|
952
|
+
: (p.auth !== 'login' && p.auth !== 'none' && ENV_KEY_RE.test(p.auth || '') ? p.auth : false),
|
|
887
953
|
defaultEnvKey: p.defaultEnvKey || '',
|
|
888
954
|
}));
|
|
889
955
|
}
|
|
@@ -892,8 +958,8 @@ module.exports = {
|
|
|
892
958
|
CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, ALIBABA_TOKEN_PLAN_MODELS,
|
|
893
959
|
ALIBABA_CODEX_MODELS, ALIBABA_TOKEN_PLAN_CONTEXT, ALIBABA_PI_MODELS,
|
|
894
960
|
CLIENT_LABELS, normalizeManagedSpec, profileFor,
|
|
895
|
-
defaultDefinitions, defaultShellEngine, defaultAgyEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
|
|
896
|
-
discoverPiModels, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension,
|
|
961
|
+
defaultDefinitions, defaultShellEngine, defaultAgyEngine, defaultKimiEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
|
|
962
|
+
discoverPiModels, EXTERNAL_DISCOVERY_TIMEOUT_MS, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension,
|
|
897
963
|
providerKeyPaths, parseProviderKeyFiles, credentialSources, credential,
|
|
898
964
|
credentialEnvNeutralizeSet, applyStoreNeutralization,
|
|
899
965
|
ensureKimiClaudeConfig, ensureAlibabaClaudeConfig, resolveInteractiveShell,
|
package/lib/fleet/runtime.js
CHANGED
|
@@ -69,7 +69,10 @@ function createBuiltinRuntime(ctx) {
|
|
|
69
69
|
return set;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
// La directory cella e il trasporto MCP dipendono soltanto da definizioni e
|
|
73
|
+
// tmux. Tenerla separata dai cataloghi modello evita che un binario esterno
|
|
74
|
+
// lento trasformi `/api/cells` in un falso guasto della flotta.
|
|
75
|
+
async function cellStatus() {
|
|
73
76
|
if (Date.now() - cache.at > STATUS_TTL_MS) {
|
|
74
77
|
reloadDefs(); // pick-up di edit esterne/file
|
|
75
78
|
const sessions = await refreshSessions();
|
|
@@ -98,10 +101,26 @@ function createBuiltinRuntime(ctx) {
|
|
|
98
101
|
rc: '', key: '', degraded: false, // supervisor vivo <=> sessione tmux viva
|
|
99
102
|
};
|
|
100
103
|
});
|
|
104
|
+
return {
|
|
105
|
+
available: true,
|
|
106
|
+
provider: 'builtin',
|
|
107
|
+
bootOwner: 'builtin',
|
|
108
|
+
reason: cfg.fleetProviderReason || 'fleet.json definitions',
|
|
109
|
+
cells,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function status() {
|
|
114
|
+
const base = await cellStatus();
|
|
101
115
|
const needsOllama = cache.defs.engines.some((e) => e.managed?.provider === 'ollama-cloud');
|
|
102
|
-
const ollamaModels = needsOllama ? await discoverOllamaModels({ ...cfg, home }) : [];
|
|
103
116
|
const needsPi = cache.defs.engines.some((e) => e.managed?.client === 'pi');
|
|
104
|
-
|
|
117
|
+
// Le discovery esterne hanno budget propri. Avviarle in parallelo mantiene
|
|
118
|
+
// il budget dello status sotto quello del bridge invece di sommare i timeout
|
|
119
|
+
// di Ollama e Pi in sequenza.
|
|
120
|
+
const [ollamaModels, piModels] = await Promise.all([
|
|
121
|
+
needsOllama ? discoverOllamaModels({ ...cfg, home }) : [],
|
|
122
|
+
needsPi ? discoverPiModels({ ...cfg, home }) : {},
|
|
123
|
+
]);
|
|
105
124
|
const engines = cache.defs.engines.map((e) => {
|
|
106
125
|
const managed = e.managed ? describeManaged(e.managed, { ...cfg, home }) : null;
|
|
107
126
|
return {
|
|
@@ -121,13 +140,7 @@ function createBuiltinRuntime(ctx) {
|
|
|
121
140
|
} : { kind: 'custom', configured: true, model: e.model?.value || '', models: [] }),
|
|
122
141
|
};
|
|
123
142
|
});
|
|
124
|
-
return {
|
|
125
|
-
available: true,
|
|
126
|
-
provider: 'builtin',
|
|
127
|
-
bootOwner: 'builtin', // §9b: la UI non puo' mentire su chi possiede il boot
|
|
128
|
-
reason: cfg.fleetProviderReason || 'fleet.json definitions',
|
|
129
|
-
cells, engines,
|
|
130
|
-
};
|
|
143
|
+
return { ...base, engines };
|
|
131
144
|
}
|
|
132
145
|
|
|
133
146
|
function isCellSession(name) {
|
|
@@ -424,7 +437,7 @@ function createBuiltinRuntime(ctx) {
|
|
|
424
437
|
}
|
|
425
438
|
|
|
426
439
|
return {
|
|
427
|
-
status, up, down, restart, isCellSession,
|
|
440
|
+
status, cellStatus, up, down, restart, isCellSession,
|
|
428
441
|
reloadDefs, findCell, findEngine, refreshSessions, commitDefs,
|
|
429
442
|
};
|
|
430
443
|
}
|
package/lib/mcp/cells.js
CHANGED
|
@@ -121,6 +121,30 @@ function normalizeCellPayload(payload, owner, callerSession = null) {
|
|
|
121
121
|
return out;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
function unavailableOwner(owner, error) {
|
|
125
|
+
let current = error;
|
|
126
|
+
for (let depth = 0; current && depth < 4; depth += 1, current = current.cause) {
|
|
127
|
+
if (current.code === 'NEXUSCREW_HTTP_TIMEOUT') {
|
|
128
|
+
return {
|
|
129
|
+
instanceId: owner.instanceId,
|
|
130
|
+
owner: owner.label,
|
|
131
|
+
route: owner.route.length ? owner.route.join('/') : 'local',
|
|
132
|
+
...(owner.route.length === 0 ? { local: true } : {}),
|
|
133
|
+
failure: 'timeout',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const message = String(error && error.message || error || '');
|
|
138
|
+
const name = String(error && error.name || '');
|
|
139
|
+
return {
|
|
140
|
+
instanceId: owner.instanceId,
|
|
141
|
+
owner: owner.label,
|
|
142
|
+
route: owner.route.length ? owner.route.join('/') : 'local',
|
|
143
|
+
...(owner.route.length === 0 ? { local: true } : {}),
|
|
144
|
+
failure: /timeout/i.test(name) || /\btimeout\b/i.test(message) ? 'timeout' : 'unreachable',
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
124
148
|
async function readCellDirectory(ctx, callerSession = null) {
|
|
125
149
|
const [config, topology] = await Promise.all([
|
|
126
150
|
ctx.api('GET', '/api/config'), ctx.api('GET', '/api/topology'),
|
|
@@ -136,9 +160,8 @@ async function readCellDirectory(ctx, callerSession = null) {
|
|
|
136
160
|
if (!apiPath) return;
|
|
137
161
|
try {
|
|
138
162
|
cells.push(...normalizeCellPayload(await ctx.api('GET', apiPath), owner, callerSession));
|
|
139
|
-
} catch (
|
|
140
|
-
unavailable.push(
|
|
141
|
-
route: owner.route.length ? owner.route.join('/') : 'local' });
|
|
163
|
+
} catch (error) {
|
|
164
|
+
unavailable.push(unavailableOwner(owner, error));
|
|
142
165
|
}
|
|
143
166
|
}));
|
|
144
167
|
cells.sort((a, b) => (a.route === 'local' ? -1 : b.route === 'local' ? 1
|
|
@@ -150,5 +173,5 @@ async function readCellDirectory(ctx, callerSession = null) {
|
|
|
150
173
|
module.exports = {
|
|
151
174
|
NODE_PART_RE, NODE_ID_RE, CELL_ID_RE,
|
|
152
175
|
orderedDeckMembers, fleetStatusPath, fleetCellsBySession, routePath,
|
|
153
|
-
topologyOwners, memberOwnerId, parseCellTarget, normalizeCellPayload, readCellDirectory,
|
|
176
|
+
topologyOwners, memberOwnerId, parseCellTarget, normalizeCellPayload, unavailableOwner, readCellDirectory,
|
|
154
177
|
};
|
package/lib/mcp/server.js
CHANGED
|
@@ -34,6 +34,20 @@ const cells = require('./cells.js');
|
|
|
34
34
|
// Versione protocollo di fallback se il client non ne dichiara una valida.
|
|
35
35
|
const PROTOCOL_FALLBACK = '2025-03-26';
|
|
36
36
|
const HTTP_TIMEOUT_MS = 10000;
|
|
37
|
+
const HTTP_TIMEOUT_CODE = 'NEXUSCREW_HTTP_TIMEOUT';
|
|
38
|
+
const HTTP_UNREACHABLE_CODE = 'NEXUSCREW_HTTP_UNREACHABLE';
|
|
39
|
+
|
|
40
|
+
// Trasporta la causa in forma strutturata tra bridge e directory celle. Il
|
|
41
|
+
// messaggio resta per l'operatore, ma la classificazione non dipende dalla
|
|
42
|
+
// lingua o da una regex sul testo prodotto da un altro modulo.
|
|
43
|
+
function transportError(baseUrl, cause) {
|
|
44
|
+
const timeout = !!(cause && (cause.name === 'TimeoutError' || cause.code === 'ABORT_ERR' || cause.code === 'ETIMEDOUT'));
|
|
45
|
+
const error = new Error(`NexusCrew non raggiungibile su ${baseUrl} (${timeout ? 'timeout' : 'server spento?'})`);
|
|
46
|
+
error.name = 'NexusCrewTransportError';
|
|
47
|
+
error.code = timeout ? HTTP_TIMEOUT_CODE : HTTP_UNREACHABLE_CODE;
|
|
48
|
+
error.cause = cause;
|
|
49
|
+
return error;
|
|
50
|
+
}
|
|
37
51
|
|
|
38
52
|
// JSON-RPC error codes standard.
|
|
39
53
|
const PARSE_ERROR = -32700;
|
|
@@ -199,9 +213,7 @@ function createMcpServer(opts = {}) {
|
|
|
199
213
|
...(payload !== undefined ? { body: payload } : {}),
|
|
200
214
|
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
201
215
|
});
|
|
202
|
-
} catch (e) {
|
|
203
|
-
throw new Error(`NexusCrew non raggiungibile su ${baseUrl} (${e && e.name === 'TimeoutError' ? 'timeout' : 'server spento?'})`);
|
|
204
|
-
}
|
|
216
|
+
} catch (e) { throw transportError(baseUrl, e); }
|
|
205
217
|
const j = await r.json().catch(() => ({}));
|
|
206
218
|
if (!r.ok) throw new Error(j.error ? `API ${r.status}: ${j.error}` : `API ${r.status}`);
|
|
207
219
|
return j;
|
|
@@ -351,6 +363,7 @@ function startMcp(opts = {}) {
|
|
|
351
363
|
|
|
352
364
|
module.exports = {
|
|
353
365
|
createMcpServer, startMcp, resolveSession, resolveIdentity, TOOLS,
|
|
366
|
+
PROTOCOL_FALLBACK, HTTP_TIMEOUT_MS, HTTP_TIMEOUT_CODE, HTTP_UNREACHABLE_CODE, transportError,
|
|
354
367
|
parseCellTarget: cells.parseCellTarget,
|
|
355
368
|
normalizeCellPayload: cells.normalizeCellPayload,
|
|
356
369
|
readCellDirectory: cells.readCellDirectory,
|
package/lib/nodes/commands.js
CHANGED
|
@@ -16,6 +16,7 @@ const path = require('node:path');
|
|
|
16
16
|
const { execFileSync } = require('node:child_process');
|
|
17
17
|
const store = require('./store.js');
|
|
18
18
|
const tunnel = require('./tunnel.js');
|
|
19
|
+
const reversePool = require('./reverse-pool.js');
|
|
19
20
|
const topologyCache = require('./topology-cache.js');
|
|
20
21
|
const inventory = require('./inventory.js');
|
|
21
22
|
const federation = require('../proxy/federation.js');
|
|
@@ -364,6 +365,22 @@ function nodesRemove(opts) {
|
|
|
364
365
|
log(`nodes remove: impossibile fermare il tunnel (${e.message}); config preservata`);
|
|
365
366
|
return { code: 1, reason: 'tunnel stop failed' };
|
|
366
367
|
}
|
|
368
|
+
// Pools allocated to an inbound peer are monotonic even after removal: its
|
|
369
|
+
// old SSH key can still hold permitlisten grants, so a future peer must never
|
|
370
|
+
// inherit those ports. Ledger first, anchor second preserves the safe
|
|
371
|
+
// crash direction (an ahead ledger is reconciled by the allocator).
|
|
372
|
+
if (node.direction === 'inbound' && node.reversePool) {
|
|
373
|
+
const ledgerPath = opts.reversePoolLedgerPath || reversePool.defaultLedgerPath(home);
|
|
374
|
+
const ledger = reversePool.loadLedger(ledgerPath);
|
|
375
|
+
const checked = ledger && reversePool.validateLedgerAnchor(ledger, st.reversePoolAnchor);
|
|
376
|
+
if (!checked || !checked.ok) {
|
|
377
|
+
log('nodes remove: ledger reverse non verificabile; config preservata');
|
|
378
|
+
return { code: 1, reason: 'reverse pool ledger invalid' };
|
|
379
|
+
}
|
|
380
|
+
const retired = reversePool.appendLedger(ledger, { type: 'retired', base: node.reversePool.base });
|
|
381
|
+
reversePool.atomicWriteLedger(ledgerPath, retired);
|
|
382
|
+
next = { ...next, reversePoolAnchor: reversePool.ledgerHead(retired) };
|
|
383
|
+
}
|
|
367
384
|
store.atomicWriteStore(nodesPath, next);
|
|
368
385
|
log(`nodes remove: nodo "${name}" rimosso${stopped ? ' (tunnel attivo fermato)' : ''}`);
|
|
369
386
|
return { code: 0, name, stopped };
|
package/lib/nodes/health.js
CHANGED
|
@@ -47,7 +47,7 @@ async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = fal
|
|
|
47
47
|
|
|
48
48
|
let health;
|
|
49
49
|
if (node.direction === 'inbound') {
|
|
50
|
-
if (node.shared !== true) {
|
|
50
|
+
if (!node.token && node.shared !== true) {
|
|
51
51
|
health = {
|
|
52
52
|
transport: 'unknown', auth: 'unknown', reachability: 'unknown', status: 'passive',
|
|
53
53
|
detail: 'client privato collegato (Share disattivato)', expected: true, managed: false, at: now,
|
|
@@ -61,10 +61,31 @@ async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = fal
|
|
|
61
61
|
const probed = await probeHealth({
|
|
62
62
|
port: node.localPort, token: node.token, expectedInstanceId: node.nodeId || null, fetchImpl, now,
|
|
63
63
|
});
|
|
64
|
+
// Un client privato non dovrebbe avere alcun -R. Se la sua porta inbound
|
|
65
|
+
// risponde e la federation conferma proprio quel peer, e' un reverse
|
|
66
|
+
// residuo (per esempio un supervisor pre-upgrade): non lo pubblichiamo e
|
|
67
|
+
// non lo terminiamo, ma smettiamo di dichiararlo "passive".
|
|
68
|
+
if (node.shared !== true) {
|
|
69
|
+
if (probed.transport === 'down') {
|
|
70
|
+
health = {
|
|
71
|
+
transport: 'unknown', auth: 'unknown', reachability: 'unknown', status: 'passive',
|
|
72
|
+
detail: 'client privato offline (nessun reverse atteso)', expected: true, managed: false, at: now,
|
|
73
|
+
};
|
|
74
|
+
} else if (probed.status === 'healthy') {
|
|
75
|
+
health = {
|
|
76
|
+
...probed, status: 'degraded', code: 'private-reverse-listener', expected: false, managed: false,
|
|
77
|
+
detail: 'canale reverse attivo nonostante Share disattivato: verificare e riconnettere il peer prima di riattivare Share',
|
|
78
|
+
};
|
|
79
|
+
} else {
|
|
80
|
+
health = {
|
|
81
|
+
...probed, status: 'degraded', code: 'private-inbound-listener', expected: false, managed: false,
|
|
82
|
+
detail: `porta inbound privata in ascolto ma peer non verificato (${probed.detail || 'health non valida'})`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
64
85
|
// The receiving side does not own an inbound client's lifecycle. A
|
|
65
86
|
// client-only (or legacy unknown-role) peer being offline is expected,
|
|
66
87
|
// not a broken server. Live auth/payload failures remain real failures.
|
|
67
|
-
if (probed.transport === 'down' && (node.rolesKnown !== true || node.roles?.node !== true)) {
|
|
88
|
+
} else if (probed.transport === 'down' && (node.rolesKnown !== true || node.roles?.node !== true)) {
|
|
68
89
|
health = {
|
|
69
90
|
...probed, status: 'passive', expected: true, managed: false,
|
|
70
91
|
detail: node.rolesKnown === true ? 'client peer offline (expected)' : 'inbound peer offline',
|