@mmmbuto/nexuscrew 0.8.29 → 0.8.31
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 +74 -0
- package/README.md +45 -9
- package/frontend/dist/assets/index-CZxcRYUd.js +93 -0
- package/frontend/dist/assets/index-CrggFFKZ.css +32 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/fleet/builtin.js +65 -12
- package/lib/fleet/causes.js +1 -0
- package/lib/fleet/cell-hold.js +20 -0
- package/lib/fleet/definitions.js +113 -15
- package/lib/fleet/launch-broker.js +4 -1
- package/lib/fleet/launch.js +124 -0
- package/lib/fleet/managed.js +63 -9
- package/lib/fleet/provider.js +1 -1
- package/lib/fleet/runtime.js +115 -23
- package/lib/mcp/tools.js +143 -3
- package/lib/proxy/federation.js +63 -2
- package/lib/server.js +22 -6
- package/lib/settings/routes.js +60 -14
- package/lib/tmux/lifecycle.js +1 -0
- package/package.json +1 -1
- package/frontend/dist/assets/index-BAzlX2nP.css +0 -32
- package/frontend/dist/assets/index-BJdAdNOq.js +0 -93
package/lib/fleet/launch.js
CHANGED
|
@@ -23,6 +23,7 @@ const path = require('node:path');
|
|
|
23
23
|
const { execFile } = require('node:child_process');
|
|
24
24
|
const { minimalRuntimeEnv } = require('../runtime/env.js');
|
|
25
25
|
const { codeOf, phaseOf } = require('./causes.js');
|
|
26
|
+
const { isTmuxSafeName, tmuxSessionForCell } = require('./definitions.js');
|
|
26
27
|
|
|
27
28
|
// Env minimale controllato dal service (design §9a). Allowlist DURA: le definizioni
|
|
28
29
|
// non possono toccare PATH/loader-key (parseDefinitions le rifiuta gia' in env);
|
|
@@ -129,6 +130,128 @@ function tmuxExec(tmuxBin, args, { env, timeoutMs = 10000 } = {}) {
|
|
|
129
130
|
|
|
130
131
|
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
131
132
|
|
|
133
|
+
function tmuxMigrationError(code, message) {
|
|
134
|
+
const error = new Error(message);
|
|
135
|
+
error.code = code;
|
|
136
|
+
return error;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function boundedTmuxFailure(result) {
|
|
140
|
+
return String(result?.stderr || result?.err?.message || 'errore tmux')
|
|
141
|
+
.replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, 240) || 'errore tmux';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Migrazione identita' tmux (design §3.2). Prima costruisce e valida TUTTO il
|
|
145
|
+
// piano: nessun rename parte se una sessione legacy normalizzata e' rivendicata
|
|
146
|
+
// da piu celle (es. a.b vs a_b), se legacy+target safe coesistono o se il target
|
|
147
|
+
// non e' tmux-safe. Solo dopo il preflight rinomina via `$N`; qualunque errore e'
|
|
148
|
+
// propagato con causa bounded e impedisce la persistenza di fleet.json. I rename
|
|
149
|
+
// gia riusciti prima di un errore operativo restano comunque idempotenti al boot
|
|
150
|
+
// successivo, mentre lo store precedente rimane intatto.
|
|
151
|
+
async function migrateLegacyTmuxSessions(tmuxBin, defs, { readonly = false } = {}) {
|
|
152
|
+
const cells = defs && Array.isArray(defs.cells) ? defs.cells : [];
|
|
153
|
+
const legacyMap = defs?.legacyTmuxSessions instanceof Map ? defs.legacyTmuxSessions : new Map();
|
|
154
|
+
const candidates = [];
|
|
155
|
+
for (const cell of cells) {
|
|
156
|
+
if (!cell || typeof cell.id !== 'string') continue;
|
|
157
|
+
const legacyRaw = legacyMap.get(cell.id) || (cell.id.includes('.') ? `cloud-${cell.id}` : '');
|
|
158
|
+
if (!legacyRaw) continue;
|
|
159
|
+
const safe = cell.tmuxSession || tmuxSessionForCell(cell.id);
|
|
160
|
+
if (!isTmuxSafeName(safe)) {
|
|
161
|
+
throw tmuxMigrationError('TMUX_MIGRATION_INVALID_TARGET',
|
|
162
|
+
`migrazione tmux bloccata per ${cell.id}: target safe non valido`);
|
|
163
|
+
}
|
|
164
|
+
candidates.push({ id: cell.id, legacyRaw, legacyNorm: legacyRaw.replace(/\./g, '_'), safe });
|
|
165
|
+
}
|
|
166
|
+
if (!candidates.length || readonly) {
|
|
167
|
+
return {
|
|
168
|
+
migrated: [], reason: readonly ? 'readonly' : 'none',
|
|
169
|
+
needsPersistence: legacyMap.size > 0,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const listing = await tmuxExec(tmuxBin,
|
|
173
|
+
['list-sessions', '-F', '#{session_id}\t#{session_name}'], { env: minimalEnv() });
|
|
174
|
+
if (listing.err) {
|
|
175
|
+
const detail = boundedTmuxFailure(listing);
|
|
176
|
+
if (/no server running|failed to connect|connection refused|no such file.*tmux/i.test(detail)) {
|
|
177
|
+
return { migrated: [], reason: 'no-tmux-server', needsPersistence: legacyMap.size > 0 };
|
|
178
|
+
}
|
|
179
|
+
throw tmuxMigrationError('TMUX_MIGRATION_LIST_FAILED',
|
|
180
|
+
`migrazione tmux: elenco sessioni non disponibile (${detail})`);
|
|
181
|
+
}
|
|
182
|
+
const realByName = new Map(); // session_name -> session_id ($N)
|
|
183
|
+
for (const line of listing.stdout.split('\n')) {
|
|
184
|
+
const [sid, sname] = line.split('\t');
|
|
185
|
+
if (sid && sname) realByName.set(String(sname).trim(), String(sid).trim());
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const ownersBySafe = new Map(cells.map((cell) => [cell.tmuxSession, cell.id]));
|
|
189
|
+
const claimsByReal = new Map();
|
|
190
|
+
for (const candidate of candidates) {
|
|
191
|
+
for (const name of new Set([candidate.legacyRaw, candidate.legacyNorm])) {
|
|
192
|
+
if (!realByName.has(name)) continue;
|
|
193
|
+
const claims = claimsByReal.get(name) || [];
|
|
194
|
+
claims.push(candidate);
|
|
195
|
+
claimsByReal.set(name, claims);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const activeNamesByCell = new Map();
|
|
200
|
+
for (const [realName, claims] of claimsByReal) {
|
|
201
|
+
for (const claim of claims) {
|
|
202
|
+
const names = activeNamesByCell.get(claim.id) || [];
|
|
203
|
+
names.push(realName);
|
|
204
|
+
activeNamesByCell.set(claim.id, names);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
for (const [cellId, names] of activeNamesByCell) {
|
|
208
|
+
if (names.length > 1) {
|
|
209
|
+
throw tmuxMigrationError('TMUX_MIGRATION_AMBIGUOUS',
|
|
210
|
+
`migrazione tmux ambigua per ${cellId}: piu sessioni legacy (${names.join(', ')})`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const operations = [];
|
|
215
|
+
for (const [realName, claims] of claimsByReal) {
|
|
216
|
+
if (claims.length !== 1) {
|
|
217
|
+
throw tmuxMigrationError('TMUX_MIGRATION_AMBIGUOUS',
|
|
218
|
+
`migrazione tmux ambigua: ${realName} corrisponde a ${claims.map((c) => c.id).join(', ')}`);
|
|
219
|
+
}
|
|
220
|
+
const candidate = claims[0];
|
|
221
|
+
const owner = ownersBySafe.get(realName);
|
|
222
|
+
if (owner && owner !== candidate.id) {
|
|
223
|
+
throw tmuxMigrationError('TMUX_MIGRATION_AMBIGUOUS',
|
|
224
|
+
`migrazione tmux ambigua: ${realName} e gia assegnata a ${owner}`);
|
|
225
|
+
}
|
|
226
|
+
const legacyId = realByName.get(realName);
|
|
227
|
+
if (!/^\$[0-9]+$/.test(legacyId || '')) {
|
|
228
|
+
throw tmuxMigrationError('TMUX_MIGRATION_INVALID_ID',
|
|
229
|
+
`migrazione tmux bloccata per ${candidate.id}: session ID non valido`);
|
|
230
|
+
}
|
|
231
|
+
const safeId = realByName.get(candidate.safe);
|
|
232
|
+
if (safeId && safeId !== legacyId) {
|
|
233
|
+
throw tmuxMigrationError('TMUX_MIGRATION_TARGET_EXISTS',
|
|
234
|
+
`migrazione tmux bloccata per ${candidate.id}: target ${candidate.safe} gia esistente`);
|
|
235
|
+
}
|
|
236
|
+
operations.push({ ...candidate, legacyId, realName });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const migrated = [];
|
|
240
|
+
for (const operation of operations) {
|
|
241
|
+
const rn = await tmuxExec(tmuxBin,
|
|
242
|
+
['rename-session', '-t', operation.legacyId, operation.safe], { env: minimalEnv() });
|
|
243
|
+
if (rn.err) {
|
|
244
|
+
throw tmuxMigrationError('TMUX_MIGRATION_RENAME_FAILED',
|
|
245
|
+
`migrazione tmux fallita per ${operation.id} (${boundedTmuxFailure(rn)})`);
|
|
246
|
+
}
|
|
247
|
+
migrated.push({ id: operation.id, from: operation.realName, to: operation.safe });
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
migrated, reason: 'ok',
|
|
251
|
+
needsPersistence: legacyMap.size > 0 || migrated.length > 0,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
132
255
|
// Policy caratteri del prompt send-keys (§9e): ammette stampabili + \t \n \r;
|
|
133
256
|
// rifiuta ESC(0x1b) e gli altri byte di controllo (niente marker bracketed-paste
|
|
134
257
|
// iniettabili). parseDefinitions caps solo la lunghezza: questo e' defense-in-depth.
|
|
@@ -243,6 +366,7 @@ module.exports = {
|
|
|
243
366
|
promptCharsOk,
|
|
244
367
|
composeClientInvocation,
|
|
245
368
|
composeLaunchArgv,
|
|
369
|
+
migrateLegacyTmuxSessions,
|
|
246
370
|
waitAlive,
|
|
247
371
|
waitStablePane,
|
|
248
372
|
injectPrompt,
|
package/lib/fleet/managed.js
CHANGED
|
@@ -61,7 +61,7 @@ const ALIBABA_PI_MODELS = Object.freeze([
|
|
|
61
61
|
|
|
62
62
|
const CUSTOM_KEYS = ['displayName', 'protocol', 'baseUrl', 'envKey', 'providerId'];
|
|
63
63
|
const MANAGED_KEYS = new Set(['client', 'provider', 'credentialProfile', 'model', 'permissionPolicy', ...CUSTOM_KEYS]);
|
|
64
|
-
const CLIENT_LABELS = Object.freeze({ claude: 'Claude Code', codex: 'Codex', 'codex-vl': 'Codex-VL', pi: 'Pi', shell: 'Shell' });
|
|
64
|
+
const CLIENT_LABELS = Object.freeze({ claude: 'Claude Code', codex: 'Codex', 'codex-vl': 'Codex-VL', pi: 'Pi', agy: 'Agy', shell: 'Shell' });
|
|
65
65
|
const PROVIDER_ID_RE = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
66
66
|
|
|
67
67
|
function validBaseUrl(value) {
|
|
@@ -128,6 +128,16 @@ const CATALOG = Object.freeze([
|
|
|
128
128
|
{ id: 'pi.ollama', client: 'pi', provider: 'ollama', label: 'Ollama local', auth: 'none', protocol: 'openai-completions', piProvider: 'ollama', requiresModel: true, core: true, piExtension: { baseUrl: 'http://127.0.0.1:11434/v1', apiKey: 'ollama' } },
|
|
129
129
|
{ id: 'pi.zai', client: 'pi', provider: 'zai', label: 'Z.AI', auth: 'ZAI_API_KEY', protocol: 'pi_native', piProvider: 'zai', core: true },
|
|
130
130
|
{ id: 'pi.custom', client: 'pi', provider: 'custom', label: 'Custom provider', auth: 'dynamic', protocol: 'openai-responses', protocols: ['openai-responses', 'anthropic-messages', 'openai-completions', 'google-generative-ai'], custom: true, core: true },
|
|
131
|
+
|
|
132
|
+
// Agy: client gestito primario (Linux/macOS non-Termux). Auth delegata al
|
|
133
|
+
// login locale del client (nessuno store letto/copiato). Nessun remote-control
|
|
134
|
+
// NexusCrew; permission default 'standard' (unsafe aggiunge --dangerously-skip-
|
|
135
|
+
// permissions, allineato agli altri client). NON e' un default seed (non va nel
|
|
136
|
+
// fleet.json fresco su piattaforme non supportate): viene aggiunto solo da un
|
|
137
|
+
// backfill platform-aware (builtin.js) su Linux/macOS non-Termux. Qui figura
|
|
138
|
+
// (core) per la UI; describeManaged lo dichiara non configurato altrove.
|
|
139
|
+
// Termux/Windows restano fuori dal primary: l'utente usa agy via shell.local.
|
|
140
|
+
{ id: 'agy.native', client: 'agy', provider: 'native', label: 'Agy', auth: 'login', protocol: 'agy_native', core: true },
|
|
131
141
|
]);
|
|
132
142
|
|
|
133
143
|
function profileFor(client, provider, credentialProfile) {
|
|
@@ -202,6 +212,18 @@ function defaultShellEngine() {
|
|
|
202
212
|
};
|
|
203
213
|
}
|
|
204
214
|
|
|
215
|
+
// Engine Agy per il backfill platform-aware (builtin.js): standard di default,
|
|
216
|
+
// auth delegata al login locale, niente remote-control. Non e' un default seed.
|
|
217
|
+
function defaultAgyEngine() {
|
|
218
|
+
const profile = CATALOG.find((entry) => entry.id === 'agy.native');
|
|
219
|
+
return {
|
|
220
|
+
id: profile.id,
|
|
221
|
+
label: CLIENT_LABELS.agy,
|
|
222
|
+
rc: false,
|
|
223
|
+
managed: { client: 'agy', provider: 'native', model: '', permissionPolicy: 'standard' },
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
205
227
|
function parseAssignments(raw) {
|
|
206
228
|
const out = {};
|
|
207
229
|
for (const line of raw.split(/\r?\n/)) {
|
|
@@ -312,6 +334,17 @@ function shellLoginArgs(command) {
|
|
|
312
334
|
return ['bash', 'zsh', 'sh', 'dash'].includes(path.basename(String(command || ''))) ? ['-l'] : [];
|
|
313
335
|
}
|
|
314
336
|
|
|
337
|
+
// Un command configurato deve vedere lo stesso ambiente della shell interattiva
|
|
338
|
+
// che l'utente ottiene lasciando vuoto il campo. Con `-lc`, zsh non carica
|
|
339
|
+
// `.zshrc`: alias e PATH user-locali (per esempio ~/.local/bin) spariscono e il
|
|
340
|
+
// command esce 127 pur essendo disponibile nel terminale normale. I quattro
|
|
341
|
+
// shell POSIX-like supportati vengono quindi avviati login+interactive+command;
|
|
342
|
+
// shell custom conservano il contratto storico -lc.
|
|
343
|
+
function shellConfiguredCommandArgs(command, raw) {
|
|
344
|
+
return ['bash', 'zsh', 'sh', 'dash'].includes(path.basename(String(command || '')))
|
|
345
|
+
? ['-lic', raw] : ['-lc', raw];
|
|
346
|
+
}
|
|
347
|
+
|
|
315
348
|
// Termux reports process.platform === 'android' and deliberately has no
|
|
316
349
|
// /usr/bin/env. npm CLI shims commonly resolve to a JavaScript file with
|
|
317
350
|
// `#!/usr/bin/env node`; direct tmux exec then fails in the kernel before the
|
|
@@ -474,17 +507,30 @@ function describeManaged(spec, cfg = {}) {
|
|
|
474
507
|
const delegatedPiAuth = profile.client === 'pi' && profile.provider !== 'custom'
|
|
475
508
|
&& profile.delegatePiAuth !== false;
|
|
476
509
|
const authConfigured = delegatedPiAuth || profile.auth === 'login' || profile.auth === 'none' || !!cred.value;
|
|
510
|
+
let configured = !!binary && authConfigured;
|
|
511
|
+
let reason = !binary ? `client ${profile.client} not found` : (!authConfigured
|
|
512
|
+
? `credential ${cred.envKey} missing — set it on this device`
|
|
513
|
+
: 'ready');
|
|
514
|
+
// Agy e' un client primario supportato solo su Linux/macOS non-Termux.
|
|
515
|
+
// Rilevazione Termux via termuxRuntimePaths (non solo process.platform): un
|
|
516
|
+
// Node che riporta 'linux' sotto proot/Termux viene comunque intercettato.
|
|
517
|
+
if (normalized.client === 'agy') {
|
|
518
|
+
const platform = cfg.platform || process.platform;
|
|
519
|
+
const termux = platform === 'android' || termuxRuntimePaths(cfg.env || process.env, { platform, home }) !== null;
|
|
520
|
+
if (termux || (platform !== 'linux' && platform !== 'darwin')) {
|
|
521
|
+
configured = false;
|
|
522
|
+
reason = 'agy non supportato su questa piattaforma (usa shell.local con command agy)';
|
|
523
|
+
}
|
|
524
|
+
}
|
|
477
525
|
return {
|
|
478
526
|
client: profile.client, clientLabel: CLIENT_LABELS[profile.client], provider: profile.provider,
|
|
479
527
|
credentialProfile: normalized.credentialProfile || '', model: normalized.model,
|
|
480
528
|
permissionPolicy: normalized.permissionPolicy, protocol: normalized.protocol || profile.protocol,
|
|
481
529
|
endpoint: normalized.baseUrl || profile.endpoint || '', auth: cred.envKey, authConfigured,
|
|
482
530
|
credentialSource: authConfigured ? cred.source : 'missing',
|
|
483
|
-
configured
|
|
531
|
+
configured, models: [...(profile.models || [])], defaultModel: profile.model || '',
|
|
484
532
|
binary: binary || '', displayName: normalized.displayName || profile.label,
|
|
485
|
-
reason
|
|
486
|
-
? `credential ${cred.envKey} missing — set it on this device`
|
|
487
|
-
: 'ready'),
|
|
533
|
+
reason,
|
|
488
534
|
};
|
|
489
535
|
}
|
|
490
536
|
|
|
@@ -633,7 +679,7 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
633
679
|
if (spec.client === 'pi' || spec.client === 'shell') effectivePolicy = 'standard';
|
|
634
680
|
info.permissionPolicy = effectivePolicy;
|
|
635
681
|
if (effectivePolicy === 'unsafe') {
|
|
636
|
-
if (spec.client === 'claude') args.push('--dangerously-skip-permissions');
|
|
682
|
+
if (spec.client === 'claude' || spec.client === 'agy') args.push('--dangerously-skip-permissions');
|
|
637
683
|
if (spec.client === 'codex' || spec.client === 'codex-vl') args.push('--dangerously-bypass-approvals-and-sandbox');
|
|
638
684
|
}
|
|
639
685
|
let shellOneShot = false;
|
|
@@ -641,7 +687,7 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
641
687
|
const raw = cell?.commands && typeof cell.commands[engineId] === 'string'
|
|
642
688
|
? cell.commands[engineId] : '';
|
|
643
689
|
shellOneShot = raw.trim().length > 0;
|
|
644
|
-
if (shellOneShot) args.push(
|
|
690
|
+
if (shellOneShot) args.push(...shellConfiguredCommandArgs(info.binary, raw));
|
|
645
691
|
else args.push(...shellLoginArgs(info.binary));
|
|
646
692
|
} else if (spec.client === 'claude') {
|
|
647
693
|
if (spec.provider === 'native') {
|
|
@@ -759,6 +805,13 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
759
805
|
if (spec.provider !== 'native') args.push('--provider', spec.provider === 'custom' ? spec.providerId : profile.piProvider);
|
|
760
806
|
if (model) args.push('--model', model);
|
|
761
807
|
if (spec.provider === 'alibaba-token-plan' && model === 'qwen3.8-max-preview') args.push('--thinking', 'xhigh');
|
|
808
|
+
} else if (spec.client === 'agy') {
|
|
809
|
+
// Agy delega l'autenticazione al proprio login locale (auth: 'login'): niente
|
|
810
|
+
// env provider, niente credenziali copiate. Argv diretto: --model prima del
|
|
811
|
+
// prompt; --prompt-interactive e' l'ultima coppia (il prompt value e' accodato
|
|
812
|
+
// dal push generico qui sotto). Senza prompt parte il TUI interattivo `agy`.
|
|
813
|
+
if (model) args.push('--model', model);
|
|
814
|
+
if (cell?.prompt) args.push('--prompt-interactive');
|
|
762
815
|
}
|
|
763
816
|
if (spec.client !== 'shell' && cell?.prompt) args.push(cell.prompt);
|
|
764
817
|
let command = info.binary;
|
|
@@ -789,8 +842,9 @@ module.exports = {
|
|
|
789
842
|
CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, ALIBABA_TOKEN_PLAN_MODELS,
|
|
790
843
|
ALIBABA_CODEX_MODELS, ALIBABA_TOKEN_PLAN_CONTEXT, ALIBABA_PI_MODELS,
|
|
791
844
|
CLIENT_LABELS, normalizeManagedSpec,
|
|
792
|
-
defaultDefinitions, defaultShellEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
|
|
845
|
+
defaultDefinitions, defaultShellEngine, defaultAgyEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
|
|
793
846
|
discoverPiModels, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension,
|
|
794
847
|
providerKeyPaths, parseProviderKeyFiles, credentialSources, credential,
|
|
795
|
-
ensureKimiClaudeConfig, ensureAlibabaClaudeConfig, resolveInteractiveShell,
|
|
848
|
+
ensureKimiClaudeConfig, ensureAlibabaClaudeConfig, resolveInteractiveShell,
|
|
849
|
+
shellLoginArgs, shellConfiguredCommandArgs, ENV_KEY_RE,
|
|
796
850
|
};
|
package/lib/fleet/provider.js
CHANGED
|
@@ -16,7 +16,7 @@ async function selectProvider(cfg = {}) {
|
|
|
16
16
|
if (cfg.fleetEnabled === false) return disabled('fleet disabilitata (fleetEnabled=false)');
|
|
17
17
|
if (cfg.builtinEnabled === false) return disabled('fleet builtin disabilitata (builtinEnabled=false)');
|
|
18
18
|
const fleet = await createBuiltinFleet({ ...cfg, fleetProviderReason: 'NexusCrew builtin fleet' });
|
|
19
|
-
if (!fleet.available) return disabled('fleet.json mancante o invalido (fail-closed)');
|
|
19
|
+
if (!fleet.available) return disabled(fleet.reason || 'fleet.json mancante o invalido (fail-closed)');
|
|
20
20
|
return { mode: 'builtin', reason: 'NexusCrew builtin fleet', fleet };
|
|
21
21
|
}
|
|
22
22
|
|
package/lib/fleet/runtime.js
CHANGED
|
@@ -21,7 +21,7 @@ const {
|
|
|
21
21
|
} = require('./managed.js');
|
|
22
22
|
const {
|
|
23
23
|
httpError, minimalEnv, tmuxExec,
|
|
24
|
-
composeClientInvocation,
|
|
24
|
+
composeClientInvocation,
|
|
25
25
|
waitAlive, waitStablePane, injectPrompt,
|
|
26
26
|
redactSecrets, sanitizeEarlyDiagnostic,
|
|
27
27
|
} = require('./launch.js');
|
|
@@ -216,39 +216,105 @@ function createBuiltinRuntime(ctx) {
|
|
|
216
216
|
args: [path.join(__dirname, 'cell-exec.js'), '--socket', ticket.socketPath, '--nonce', ticket.nonce],
|
|
217
217
|
env: {}, promptMode: 'managed-argv',
|
|
218
218
|
};
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
219
|
+
const tmuxChild = composeClientInvocation(tmuxLaunchEngine, cell);
|
|
220
|
+
|
|
221
|
+
// Avvio staged (design §3.3): crea il pane con un placeholder inerte trusted
|
|
222
|
+
// (cell-hold.js), arma remain-on-exit window-local sul @N, poi respawn-pane -k
|
|
223
|
+
// verso cell-exec sul %N esatto. Cosi' remain-on-exit e' gia' ON quando il vero
|
|
224
|
+
// child puo' terminare: nessuna finestra scomparsa, nessun NEW_SESSION_FAILED
|
|
225
|
+
// che maschera l'exit reale. Il nome sessione e' gia' validato tmux-safe (v2
|
|
226
|
+
// per id puntati, definitions.js); gli step critici usano gli ID restituiti
|
|
227
|
+
// da tmux ($N/@N/%N). respawn-pane -k preserva il pane ID: il %N catturato
|
|
228
|
+
// qui resta valido per readiness e prompt. Nessuna shell string, nessun
|
|
229
|
+
// command/env/prompt del child nell'argv tmux (solo cell-hold, poi cell-exec
|
|
230
|
+
// via broker). Nessun sleep come sincronizzazione.
|
|
231
|
+
const CELL_HOLD = path.join(__dirname, 'cell-hold.js');
|
|
232
|
+
const create = await tmuxExec(tmuxBin,
|
|
233
|
+
['new-session', '-d', '-s', cell.tmuxSession, '-c', realCwd,
|
|
234
|
+
'-P', '-F', '#{session_id}\t#{window_id}\t#{pane_id}',
|
|
235
|
+
process.execPath, CELL_HOLD],
|
|
236
|
+
{ env: minimalEnv() });
|
|
237
|
+
if (create.err) {
|
|
238
|
+
// new-session fallita: nessuna sessione creata. Revoca il ticket (il child
|
|
239
|
+
// non partira' mai) prima di propagare l'errore.
|
|
240
|
+
try { await launchBroker.revoke?.(ticket.nonce); } catch (_) { /* best-effort */ }
|
|
229
241
|
// Redazione (§9h): lo stderr di tmux puo' ecoare argv/env del comando lanciato.
|
|
230
|
-
const dup = /duplicate session/i.test(
|
|
242
|
+
const dup = /duplicate session/i.test(create.stderr);
|
|
231
243
|
const why = dup
|
|
232
244
|
? 'sessione già in esecuzione'
|
|
233
|
-
: `tmux new-session failed: ${redactSecrets(
|
|
245
|
+
: `tmux new-session failed: ${redactSecrets(create.stderr.trim() || create.err.message, launchEngine, cell)}`;
|
|
234
246
|
throw httpError(dup ? 409 : 500, why, null,
|
|
235
247
|
{ phase: 'new-session', code: dup ? 'SESSION_DUPLICATE' : 'NEW_SESSION_FAILED' });
|
|
236
248
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
249
|
+
const createdIds = create.stdout.trim().split('\n')[0].split('\t');
|
|
250
|
+
const sessionId = /^\$[0-9]+$/.test(createdIds[0] || '') ? createdIds[0] : '';
|
|
251
|
+
const windowId = /^@[0-9]+$/.test(createdIds[1] || '') ? createdIds[1] : '';
|
|
252
|
+
const paneId = /^%[0-9]+$/.test(createdIds[2] || '') ? createdIds[2] : '';
|
|
253
|
+
const resolveSessionIdForCleanup = async () => {
|
|
254
|
+
if (sessionId) return sessionId;
|
|
255
|
+
// Output parziale anomalo: risali al $N da un @N/%N gia restituito. Come
|
|
256
|
+
// ultima recovery enumera id+nomi e usa il nome safe solo per SELEZIONARE
|
|
257
|
+
// l'id; kill-session non torna mai al target nominale richiesto.
|
|
258
|
+
for (const target of [paneId, windowId]) {
|
|
259
|
+
if (!target) continue;
|
|
260
|
+
const shown = await tmuxExec(tmuxBin,
|
|
261
|
+
['display-message', '-p', '-t', target, '#{session_id}'],
|
|
262
|
+
{ env: minimalEnv(), timeoutMs: 2000 });
|
|
263
|
+
const resolved = shown.stdout.trim();
|
|
264
|
+
if (!shown.err && /^\$[0-9]+$/.test(resolved)) return resolved;
|
|
265
|
+
}
|
|
266
|
+
const listed = await tmuxExec(tmuxBin,
|
|
267
|
+
['list-sessions', '-F', '#{session_id}\t#{session_name}'],
|
|
268
|
+
{ env: minimalEnv(), timeoutMs: 2000 });
|
|
269
|
+
if (listed.err) return '';
|
|
270
|
+
for (const line of listed.stdout.split('\n')) {
|
|
271
|
+
const [sid, name] = line.split('\t');
|
|
272
|
+
if (name === cell.tmuxSession && /^\$[0-9]+$/.test(sid || '')) return sid;
|
|
273
|
+
}
|
|
274
|
+
return '';
|
|
275
|
+
};
|
|
276
|
+
const cleanupLaunch = async () => {
|
|
277
|
+
const stableSessionId = await resolveSessionIdForCleanup();
|
|
278
|
+
if (stableSessionId) {
|
|
279
|
+
await tmuxExec(tmuxBin, ['kill-session', '-t', stableSessionId],
|
|
280
|
+
{ env: minimalEnv(), timeoutMs: 2000 });
|
|
281
|
+
}
|
|
282
|
+
try { await launchBroker.revoke?.(ticket.nonce); } catch (_) { /* best-effort */ }
|
|
283
|
+
};
|
|
284
|
+
if (!sessionId || !windowId || !paneId) {
|
|
285
|
+
await cleanupLaunch();
|
|
286
|
+
throw httpError(500, 'tmux new-session: ID sessione/finestra/pane non restituito', null,
|
|
287
|
+
{ phase: 'new-session', code: 'NEW_SESSION_FAILED' });
|
|
288
|
+
}
|
|
289
|
+
// Arma remain-on-exit window-local sul @N: la finestra esiste (il placeholder
|
|
290
|
+
// la tiene viva), quindi niente race con un child che termini nel frattempo.
|
|
291
|
+
const arm = await tmuxExec(tmuxBin,
|
|
292
|
+
['set-option', '-w', '-t', windowId, 'remain-on-exit', 'on'],
|
|
293
|
+
{ env: minimalEnv() });
|
|
294
|
+
if (arm.err) {
|
|
295
|
+
await cleanupLaunch();
|
|
296
|
+
throw httpError(500,
|
|
297
|
+
`tmux set-option remain-on-exit failed: ${redactSecrets(arm.stderr.trim() || arm.err.message, launchEngine, cell)}`,
|
|
298
|
+
null, { phase: 'new-session', code: 'NEW_SESSION_FAILED' });
|
|
299
|
+
}
|
|
300
|
+
// Sostituisci il placeholder con il vero child (cell-exec via broker) sul %N.
|
|
301
|
+
const respawn = await tmuxExec(tmuxBin,
|
|
302
|
+
['respawn-pane', '-k', '-c', realCwd, '-t', paneId, tmuxChild.command, ...tmuxChild.args],
|
|
303
|
+
{ env: minimalEnv() });
|
|
304
|
+
if (respawn.err) {
|
|
305
|
+
// respawn fallito dopo issue(): il nonce va revocato/consumato prima
|
|
306
|
+
// dell'errore (design §3.3), poi cleanup della sessione-pannello.
|
|
307
|
+
await cleanupLaunch();
|
|
308
|
+
throw httpError(500,
|
|
309
|
+
`tmux respawn-pane failed: ${redactSecrets(respawn.stderr.trim() || respawn.err.message, launchEngine, cell)}`,
|
|
310
|
+
null, { phase: 'new-session', code: 'NEW_SESSION_FAILED' });
|
|
245
311
|
}
|
|
246
312
|
|
|
247
313
|
// `tmux new-session -d` can return 0 even when the launched CLI exits a
|
|
248
314
|
// moment later (missing login, bad model, incompatible provider). Without
|
|
249
315
|
// this readiness gate the PWA reported success and then showed nothing.
|
|
250
316
|
// Always verify liveness, including cells without a system prompt.
|
|
251
|
-
|
|
317
|
+
|
|
252
318
|
const readiness = paneId.startsWith('%')
|
|
253
319
|
? await waitStablePane(tmuxBin, paneId, { env: minimalEnv(), readyMs })
|
|
254
320
|
: { alive: await waitAlive(tmuxBin, cell.tmuxSession, { env: minimalEnv(), readyMs }), status: null, target: null };
|
|
@@ -261,10 +327,25 @@ function createBuiltinRuntime(ctx) {
|
|
|
261
327
|
}
|
|
262
328
|
// remain-on-exit era soltanto diagnostico: nessun pane morto deve restare
|
|
263
329
|
// nella Fleet o nella lista tmux dopo aver raccolto l'errore.
|
|
264
|
-
await
|
|
330
|
+
await cleanupLaunch();
|
|
265
331
|
cache = { ...cache, at: 0 };
|
|
332
|
+
// Un command Shell completato rapidamente con exit 0 e' un one-shot
|
|
333
|
+
// riuscito. Qualunque altro exit immediato e' invece osservabile come
|
|
334
|
+
// errore strutturato: prima il runtime restituiva un falso successo e
|
|
335
|
+
// scartava proprio l'exit 127/diagnostica che servivano all'operatore.
|
|
336
|
+
if (launchEngine.shellOneShot && readiness.status === 0) {
|
|
337
|
+
return {
|
|
338
|
+
ok: true, cell: cellId, session: cell.tmuxSession, prompt: null,
|
|
339
|
+
oneShot: true, active: false, completed: true, exitCode: 0,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
266
342
|
const client = path.basename(launchEngine.clientBinary || launchEngine.command || 'client');
|
|
267
343
|
const status = Number.isInteger(readiness.status) ? ` (exit ${readiness.status})` : '';
|
|
344
|
+
if (launchEngine.shellOneShot) {
|
|
345
|
+
throw httpError(500,
|
|
346
|
+
`comando Shell terminato subito${status}: ${diagnostic || 'verifica command, PATH e configurazione della shell'}`,
|
|
347
|
+
null, { phase: 'readiness', code: 'SHELL_COMMAND_FAILED' });
|
|
348
|
+
}
|
|
268
349
|
// Cause-preserving (T4): distinguish a cell-client spawn failure (the
|
|
269
350
|
// captured pane carries the stable 'cell spawn failed:' marker produced by
|
|
270
351
|
// cell-exec.js) from a generic early exit. Both stay on the readiness
|
|
@@ -278,6 +359,17 @@ function createBuiltinRuntime(ctx) {
|
|
|
278
359
|
['set-option', '-w', '-t', readiness.target, 'remain-on-exit', 'off'], { env: minimalEnv(), timeoutMs: 2000 });
|
|
279
360
|
}
|
|
280
361
|
|
|
362
|
+
// Il command Shell e' partito ed e' ancora vivo dopo la finestra di
|
|
363
|
+
// readiness: la cella deve risultare attiva (per CLI interattive come agy),
|
|
364
|
+
// poi tornera' inattiva quando il processo terminera' naturalmente.
|
|
365
|
+
if (launchEngine.shellOneShot) {
|
|
366
|
+
cache = { ...cache, at: 0 };
|
|
367
|
+
return {
|
|
368
|
+
ok: true, cell: cellId, session: cell.tmuxSession, prompt: null,
|
|
369
|
+
oneShot: true, active: true, completed: false,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
281
373
|
// (6) prompt send-keys: bracketed-paste best-effort, target = pane id esatto
|
|
282
374
|
// (fallback al nome sessione con match esatto '=' se tmux non ha stampato l'id).
|
|
283
375
|
let prompt = null;
|
package/lib/mcp/tools.js
CHANGED
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
// argomenti e orchestrazione delle chiamate via ctx.api. Ordine, nomi, schemi,
|
|
10
10
|
// handler, identity gate e semantica read/write sono preservati EXACT.
|
|
11
11
|
const path = require('node:path');
|
|
12
|
+
const { codeOf, phaseOf } = require('../fleet/causes.js');
|
|
12
13
|
const {
|
|
13
14
|
NODE_ID_RE, orderedDeckMembers, fleetStatusPath, fleetCellsBySession,
|
|
14
|
-
routePath, topologyOwners, memberOwnerId, parseCellTarget,
|
|
15
|
+
routePath, topologyOwners, memberOwnerId, parseCellTarget, normalizeCellPayload,
|
|
16
|
+
readCellDirectory,
|
|
15
17
|
} = require('./cells.js');
|
|
16
18
|
|
|
17
19
|
// --- helper input tool (fail-closed) ------------------------------------------
|
|
@@ -57,6 +59,93 @@ function requireSession(session, tool, code = IDENTITY_CODE.MISSING) {
|
|
|
57
59
|
);
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
// Il command Shell e' utile per diagnosticare una cella locale, ma puo'
|
|
63
|
+
// contenere credenziali scritte per errore. Manteniamo la struttura operativa
|
|
64
|
+
// e redigiamo flag/assegnazioni credential-shaped prima che il valore lasci il
|
|
65
|
+
// bridge MCP. Nessun command entra mai nella directory federata nc_cells.
|
|
66
|
+
function commandForDiagnostics(raw) {
|
|
67
|
+
if (typeof raw !== 'string' || !raw.trim()) {
|
|
68
|
+
return { configured: false, value: null, redacted: false, truncated: false };
|
|
69
|
+
}
|
|
70
|
+
let value = raw.normalize('NFC').replace(/[\p{Cc}\p{Cf}]/gu, ' ').replace(/\s+/g, ' ').trim();
|
|
71
|
+
const before = value;
|
|
72
|
+
value = value
|
|
73
|
+
.replace(/\bBearer\s+\S+/gi, 'Bearer [redacted]')
|
|
74
|
+
.replace(/((?:--)?(?:api[-_]?key|token|secret|password|credential))(\s*(?:=|:)\s*|\s+)(?:"[^"]*"|'[^']*'|\S+)/gi,
|
|
75
|
+
'$1$2[redacted]')
|
|
76
|
+
.replace(/\b([A-Za-z][A-Za-z0-9_]*(?:_API_KEY|_KEY|_TOKEN|_SECRET|_PASSWORD|_CREDENTIAL|_AUTH)[A-Za-z0-9_]*)\s*=\s*(?:"[^"]*"|'[^']*'|\S+)/gi,
|
|
77
|
+
'$1=[redacted]')
|
|
78
|
+
// env maiuscole generiche senza separatore prima del suffisso credenziale
|
|
79
|
+
// (ZAIKEY=, PASSWD=, MYPASS=, DATABASE_URL=...): coerente con
|
|
80
|
+
// lib/diagnostics/store.js, ma consuma anche valori quotati per intero.
|
|
81
|
+
.replace(/\b([A-Z][A-Z0-9_]{2,})\s*=\s*(?:"[^"]*"|'[^']*'|\S+)/g, '$1=[redacted]')
|
|
82
|
+
.replace(/\b(?:sk|xox[baprs]|gh[pousr]|npm)[_-][A-Za-z0-9_-]{8,}\b/g, '[redacted]');
|
|
83
|
+
const truncated = value.length > 4096;
|
|
84
|
+
if (truncated) value = value.slice(0, 4096);
|
|
85
|
+
return { configured: true, value, redacted: value !== before || truncated, truncated };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Accetta soltanto i due eventi failure prodotti dal router Fleet e ricostruisce
|
|
89
|
+
// una causa chiusa. Free text e meta ignoti vengono sempre scartati, anche se
|
|
90
|
+
// arrivano da un runtime precedente o compromesso.
|
|
91
|
+
function failureForDiagnostics(record, cell) {
|
|
92
|
+
if (!record || record.component !== 'fleet' || !record.meta || record.meta.cell !== cell) return null;
|
|
93
|
+
const at = typeof record.ts === 'string' && record.ts.length <= 64 ? record.ts : null;
|
|
94
|
+
const status = Number.isSafeInteger(record.meta.status) && record.meta.status >= 100 && record.meta.status <= 599
|
|
95
|
+
? record.meta.status : null;
|
|
96
|
+
if (record.code === 'CELL_SPAWN_FAILED') {
|
|
97
|
+
return {
|
|
98
|
+
event: 'CELL_SPAWN_FAILED', at, status,
|
|
99
|
+
code: 'SPAWN_CLIENT_FAILED', phase: 'spawn-client',
|
|
100
|
+
errno: /^[A-Z][A-Z0-9_]{0,31}$/.test(String(record.meta.errno || '')) ? record.meta.errno : null,
|
|
101
|
+
client: /^[A-Za-z0-9._+-]{1,128}$/.test(String(record.meta.client || '')) ? record.meta.client : null,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (record.code !== 'FLEET_ACTION_FAILED') return null;
|
|
105
|
+
return {
|
|
106
|
+
event: 'FLEET_ACTION_FAILED', at, status,
|
|
107
|
+
code: codeOf(record.meta.code), phase: phaseOf(record.meta.phase),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function lastFailureForCell(records, cell) {
|
|
112
|
+
if (!Array.isArray(records)) return null;
|
|
113
|
+
for (let i = records.length - 1; i >= 0; i -= 1) {
|
|
114
|
+
const failure = failureForDiagnostics(records[i], cell);
|
|
115
|
+
if (failure) return failure;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function recentDiagnostics(ctx) {
|
|
121
|
+
const out = [];
|
|
122
|
+
let after = 0;
|
|
123
|
+
// Store bounded a 500 record: tre pagine da 200 coprono l'intero buffer senza
|
|
124
|
+
// una query illimitata e senza dipendere dalla modalita' verbose.
|
|
125
|
+
for (let page = 0; page < 3; page += 1) {
|
|
126
|
+
const payload = await ctx.api('GET', `/api/diagnostics/logs?after=${after}&limit=200`);
|
|
127
|
+
const records = Array.isArray(payload && payload.records) ? payload.records : [];
|
|
128
|
+
out.push(...records);
|
|
129
|
+
const cursor = Number.isSafeInteger(payload && payload.cursor) ? payload.cursor : after;
|
|
130
|
+
if (records.length < 200 || cursor <= after) break;
|
|
131
|
+
after = cursor;
|
|
132
|
+
}
|
|
133
|
+
return out.slice(-500);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// La diagnostica command/cause e' intenzionalmente locale: non deve neppure
|
|
137
|
+
// interrogare la topologia o i proxy remoti. Questo helper costruisce la stessa
|
|
138
|
+
// forma normalizzata di nc_cells usando soltanto config + /api/cells locali.
|
|
139
|
+
async function readLocalCellDirectory(ctx, callerSession) {
|
|
140
|
+
const [config, payload] = await Promise.all([
|
|
141
|
+
ctx.api('GET', '/api/config'), ctx.api('GET', '/api/cells'),
|
|
142
|
+
]);
|
|
143
|
+
const nodeId = String(config && config.instanceId || '');
|
|
144
|
+
if (!NODE_ID_RE.test(nodeId)) throw new Error('instanceId locale non disponibile');
|
|
145
|
+
const owner = { instanceId: nodeId, route: [], label: 'Local', stale: false };
|
|
146
|
+
return { nodeId, cells: normalizeCellPayload(payload, owner, callerSession) };
|
|
147
|
+
}
|
|
148
|
+
|
|
60
149
|
// --- definizione tool (prefisso nc_ anti-collisione) ---------------------------
|
|
61
150
|
// annotations.readOnlyHint:true sui tool che non mutano nulla (§1).
|
|
62
151
|
const TOOLS = [
|
|
@@ -206,7 +295,8 @@ const TOOLS = [
|
|
|
206
295
|
const members = orderedDeckMembers(deck).map((member) => ({
|
|
207
296
|
...member,
|
|
208
297
|
ownerId: memberOwnerId(member, source.owner, source.ownerTopology),
|
|
209
|
-
}))
|
|
298
|
+
})).filter((member) => member.ownerId === localNodeId
|
|
299
|
+
|| (member.ownerId && viewerById.has(member.ownerId)));
|
|
210
300
|
if (!members.some((member) => member.ownerId === localNodeId && member.tmuxSession === tmuxSession)) continue;
|
|
211
301
|
decks.push({
|
|
212
302
|
id: `${source.owner.instanceId}:${deck.name}`,
|
|
@@ -263,6 +353,53 @@ const TOOLS = [
|
|
|
263
353
|
return readCellDirectory(ctx, callerSession);
|
|
264
354
|
},
|
|
265
355
|
},
|
|
356
|
+
{
|
|
357
|
+
name: 'nc_cell_diagnostics',
|
|
358
|
+
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.',
|
|
359
|
+
inputSchema: {
|
|
360
|
+
type: 'object',
|
|
361
|
+
properties: {
|
|
362
|
+
target: { type: 'string', description: 'id owner-qualified locale restituito da nc_cells: <instanceId>:<cell>' },
|
|
363
|
+
},
|
|
364
|
+
required: ['target'],
|
|
365
|
+
},
|
|
366
|
+
annotations: { readOnlyHint: true },
|
|
367
|
+
async handler(args, ctx) {
|
|
368
|
+
const targetRef = parseCellTarget(argString(args, 'target', { required: true, max: 128 }));
|
|
369
|
+
if (!targetRef) throw new Error('target non valido: usa l\'id esatto restituito da nc_cells');
|
|
370
|
+
const identity = await ctx.identity();
|
|
371
|
+
const callerSession = requireSession(identity.session, 'nc_cell_diagnostics', identity.code);
|
|
372
|
+
const directory = await readLocalCellDirectory(ctx, callerSession);
|
|
373
|
+
const sender = directory.cells.find((cell) => cell.self && cell.active);
|
|
374
|
+
if (!sender) throw new Error('nc_cell_diagnostics: la sessione chiamante non e\' una cella Fleet attiva locale');
|
|
375
|
+
if (targetRef.instanceId !== directory.nodeId) {
|
|
376
|
+
throw new Error('nc_cell_diagnostics: target remoto rifiutato; i command non attraversano la federazione');
|
|
377
|
+
}
|
|
378
|
+
const target = directory.cells.find((cell) => cell.instanceId === targetRef.instanceId
|
|
379
|
+
&& cell.cell === targetRef.cell);
|
|
380
|
+
if (!target) throw new Error('nc_cell_diagnostics: cella locale non trovata');
|
|
381
|
+
|
|
382
|
+
const [definitions, diagnostics] = await Promise.all([
|
|
383
|
+
ctx.api('GET', '/api/fleet/definitions'), recentDiagnostics(ctx),
|
|
384
|
+
]);
|
|
385
|
+
const definition = Array.isArray(definitions && definitions.cells)
|
|
386
|
+
? definitions.cells.find((cell) => cell && cell.id === target.cell) : null;
|
|
387
|
+
if (!definition || definition.tmuxSession !== target.tmuxSession || definition.engine !== target.engine) {
|
|
388
|
+
throw new Error('nc_cell_diagnostics: definizione locale incoerente o non disponibile');
|
|
389
|
+
}
|
|
390
|
+
const rawCommand = definition.commands && typeof definition.commands === 'object'
|
|
391
|
+
? definition.commands[definition.engine] : '';
|
|
392
|
+
return {
|
|
393
|
+
target: target.id,
|
|
394
|
+
cell: target.cell,
|
|
395
|
+
tmuxSession: target.tmuxSession,
|
|
396
|
+
engine: target.engine,
|
|
397
|
+
active: target.active,
|
|
398
|
+
command: commandForDiagnostics(rawCommand),
|
|
399
|
+
lastFailure: lastFailureForCell(diagnostics, target.cell),
|
|
400
|
+
};
|
|
401
|
+
},
|
|
402
|
+
},
|
|
266
403
|
{
|
|
267
404
|
name: 'nc_send_cell',
|
|
268
405
|
description: 'Invia e sottopone un messaggio a una cella Fleet attiva autorizzata. target deve essere l\'id esatto restituito da nc_cells; submitted non significa lavoro completato.',
|
|
@@ -346,4 +483,7 @@ const TOOLS = [
|
|
|
346
483
|
},
|
|
347
484
|
];
|
|
348
485
|
|
|
349
|
-
module.exports = {
|
|
486
|
+
module.exports = {
|
|
487
|
+
TOOLS, argString, requireSession, commandForDiagnostics, failureForDiagnostics,
|
|
488
|
+
lastFailureForCell, readLocalCellDirectory, IDENTITY_CODE, IDENTITY_REMEDIATION,
|
|
489
|
+
};
|