@mmmbuto/nexuscrew 0.8.30 → 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 +50 -0
- package/README.md +22 -7
- 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/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 +49 -7
- package/lib/fleet/provider.js +1 -1
- package/lib/fleet/runtime.js +90 -14
- package/lib/mcp/tools.js +2 -1
- 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-CJtVoawR.css +0 -32
- package/frontend/dist/assets/index-CPNWWnqN.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/)) {
|
|
@@ -485,17 +507,30 @@ function describeManaged(spec, cfg = {}) {
|
|
|
485
507
|
const delegatedPiAuth = profile.client === 'pi' && profile.provider !== 'custom'
|
|
486
508
|
&& profile.delegatePiAuth !== false;
|
|
487
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
|
+
}
|
|
488
525
|
return {
|
|
489
526
|
client: profile.client, clientLabel: CLIENT_LABELS[profile.client], provider: profile.provider,
|
|
490
527
|
credentialProfile: normalized.credentialProfile || '', model: normalized.model,
|
|
491
528
|
permissionPolicy: normalized.permissionPolicy, protocol: normalized.protocol || profile.protocol,
|
|
492
529
|
endpoint: normalized.baseUrl || profile.endpoint || '', auth: cred.envKey, authConfigured,
|
|
493
530
|
credentialSource: authConfigured ? cred.source : 'missing',
|
|
494
|
-
configured
|
|
531
|
+
configured, models: [...(profile.models || [])], defaultModel: profile.model || '',
|
|
495
532
|
binary: binary || '', displayName: normalized.displayName || profile.label,
|
|
496
|
-
reason
|
|
497
|
-
? `credential ${cred.envKey} missing — set it on this device`
|
|
498
|
-
: 'ready'),
|
|
533
|
+
reason,
|
|
499
534
|
};
|
|
500
535
|
}
|
|
501
536
|
|
|
@@ -644,7 +679,7 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
644
679
|
if (spec.client === 'pi' || spec.client === 'shell') effectivePolicy = 'standard';
|
|
645
680
|
info.permissionPolicy = effectivePolicy;
|
|
646
681
|
if (effectivePolicy === 'unsafe') {
|
|
647
|
-
if (spec.client === 'claude') args.push('--dangerously-skip-permissions');
|
|
682
|
+
if (spec.client === 'claude' || spec.client === 'agy') args.push('--dangerously-skip-permissions');
|
|
648
683
|
if (spec.client === 'codex' || spec.client === 'codex-vl') args.push('--dangerously-bypass-approvals-and-sandbox');
|
|
649
684
|
}
|
|
650
685
|
let shellOneShot = false;
|
|
@@ -770,6 +805,13 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
770
805
|
if (spec.provider !== 'native') args.push('--provider', spec.provider === 'custom' ? spec.providerId : profile.piProvider);
|
|
771
806
|
if (model) args.push('--model', model);
|
|
772
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');
|
|
773
815
|
}
|
|
774
816
|
if (spec.client !== 'shell' && cell?.prompt) args.push(cell.prompt);
|
|
775
817
|
let command = info.binary;
|
|
@@ -800,7 +842,7 @@ module.exports = {
|
|
|
800
842
|
CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, ALIBABA_TOKEN_PLAN_MODELS,
|
|
801
843
|
ALIBABA_CODEX_MODELS, ALIBABA_TOKEN_PLAN_CONTEXT, ALIBABA_PI_MODELS,
|
|
802
844
|
CLIENT_LABELS, normalizeManagedSpec,
|
|
803
|
-
defaultDefinitions, defaultShellEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
|
|
845
|
+
defaultDefinitions, defaultShellEngine, defaultAgyEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
|
|
804
846
|
discoverPiModels, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension,
|
|
805
847
|
providerKeyPaths, parseProviderKeyFiles, credentialSources, credential,
|
|
806
848
|
ensureKimiClaudeConfig, ensureAlibabaClaudeConfig, resolveInteractiveShell,
|
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,29 +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
|
-
|
|
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 */ }
|
|
228
241
|
// Redazione (§9h): lo stderr di tmux puo' ecoare argv/env del comando lanciato.
|
|
229
|
-
const dup = /duplicate session/i.test(
|
|
242
|
+
const dup = /duplicate session/i.test(create.stderr);
|
|
230
243
|
const why = dup
|
|
231
244
|
? 'sessione già in esecuzione'
|
|
232
|
-
: `tmux new-session failed: ${redactSecrets(
|
|
245
|
+
: `tmux new-session failed: ${redactSecrets(create.stderr.trim() || create.err.message, launchEngine, cell)}`;
|
|
233
246
|
throw httpError(dup ? 409 : 500, why, null,
|
|
234
247
|
{ phase: 'new-session', code: dup ? 'SESSION_DUPLICATE' : 'NEW_SESSION_FAILED' });
|
|
235
248
|
}
|
|
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' });
|
|
311
|
+
}
|
|
236
312
|
|
|
237
313
|
// `tmux new-session -d` can return 0 even when the launched CLI exits a
|
|
238
314
|
// moment later (missing login, bad model, incompatible provider). Without
|
|
239
315
|
// this readiness gate the PWA reported success and then showed nothing.
|
|
240
316
|
// Always verify liveness, including cells without a system prompt.
|
|
241
|
-
|
|
317
|
+
|
|
242
318
|
const readiness = paneId.startsWith('%')
|
|
243
319
|
? await waitStablePane(tmuxBin, paneId, { env: minimalEnv(), readyMs })
|
|
244
320
|
: { alive: await waitAlive(tmuxBin, cell.tmuxSession, { env: minimalEnv(), readyMs }), status: null, target: null };
|
|
@@ -251,7 +327,7 @@ function createBuiltinRuntime(ctx) {
|
|
|
251
327
|
}
|
|
252
328
|
// remain-on-exit era soltanto diagnostico: nessun pane morto deve restare
|
|
253
329
|
// nella Fleet o nella lista tmux dopo aver raccolto l'errore.
|
|
254
|
-
await
|
|
330
|
+
await cleanupLaunch();
|
|
255
331
|
cache = { ...cache, at: 0 };
|
|
256
332
|
// Un command Shell completato rapidamente con exit 0 e' un one-shot
|
|
257
333
|
// riuscito. Qualunque altro exit immediato e' invece osservabile come
|
package/lib/mcp/tools.js
CHANGED
|
@@ -295,7 +295,8 @@ const TOOLS = [
|
|
|
295
295
|
const members = orderedDeckMembers(deck).map((member) => ({
|
|
296
296
|
...member,
|
|
297
297
|
ownerId: memberOwnerId(member, source.owner, source.ownerTopology),
|
|
298
|
-
}))
|
|
298
|
+
})).filter((member) => member.ownerId === localNodeId
|
|
299
|
+
|| (member.ownerId && viewerById.has(member.ownerId)));
|
|
299
300
|
if (!members.some((member) => member.ownerId === localNodeId && member.tmuxSession === tmuxSession)) continue;
|
|
300
301
|
decks.push({
|
|
301
302
|
id: `${source.owner.instanceId}:${deck.name}`,
|
package/lib/proxy/federation.js
CHANGED
|
@@ -310,7 +310,7 @@ async function reconcilePeerShare(opts = {}) {
|
|
|
310
310
|
throw new Error((health && health.detail) || 'hub non raggiungibile per riconciliare Share');
|
|
311
311
|
}
|
|
312
312
|
const attempts = Number.isInteger(opts.notifyAttempts)
|
|
313
|
-
? Math.max(1, Math.min(opts.notifyAttempts, 6)) :
|
|
313
|
+
? Math.max(1, Math.min(opts.notifyAttempts, 6)) : 3;
|
|
314
314
|
let lastError = null;
|
|
315
315
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
316
316
|
try {
|
|
@@ -324,6 +324,67 @@ async function reconcilePeerShare(opts = {}) {
|
|
|
324
324
|
throw lastError || new Error('riconciliazione Share fallita');
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
+
// Runner di riconciliazione Share OFF al boot (design piano §3.2.8). Per-peer,
|
|
328
|
+
// no-overlap (un solo runner attivo per nome), al massimo tre round per processo,
|
|
329
|
+
// backoff nominato 0/1000/5000 ms (iniettabile). Re-read dello stato desiderato
|
|
330
|
+
// (nodes.json) prima di ogni round: abort se non e' piu' shared:false (es. il peer
|
|
331
|
+
// e' stato ri-condiviso o rimosso). Emette SHARE_REVOKE_PENDING/RECOVERED/EXHAUSTED
|
|
332
|
+
// tramite il callback diagnostico (una transizione ciascuno, no spam). Nessun
|
|
333
|
+
// timer infinito, nessuna retry storm process-wide, nessun retry di ON sotto la
|
|
334
|
+
// policy OFF. Il backoff e' un delay nominato usato solo fra round di fallimento.
|
|
335
|
+
const SHARE_REVOKE_BACKOFF_MS = Object.freeze([0, 1000, 5000]);
|
|
336
|
+
const shareRevokeRunning = new Set(); // per-peer no-overlap (scope modulo)
|
|
337
|
+
function runShareRevokeBoot({
|
|
338
|
+
node, nodesPath, fetchImpl = fetch, diagnostics,
|
|
339
|
+
backoff = SHARE_REVOKE_BACKOFF_MS, delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
340
|
+
runningSet = shareRevokeRunning, reconcileImpl = reconcilePeerShare,
|
|
341
|
+
healthAttempts = 3, notifyAttempts = 3, delayMs = 200, timeoutMs = 5000,
|
|
342
|
+
}) {
|
|
343
|
+
const name = node && typeof node.name === 'string' ? node.name : '';
|
|
344
|
+
if (!store.NODE_NAME_RE.test(name) || !store.NODE_ID_RE.test(String(node && node.nodeId || ''))
|
|
345
|
+
|| !store.validToken(node && node.token) || typeof nodesPath !== 'string' || !nodesPath
|
|
346
|
+
|| typeof reconcileImpl !== 'function') {
|
|
347
|
+
return Promise.resolve({ status: 'skipped', reason: 'invalid-input' });
|
|
348
|
+
}
|
|
349
|
+
if (runningSet.has(name)) return Promise.resolve({ status: 'already-running' });
|
|
350
|
+
runningSet.add(name);
|
|
351
|
+
const emit = (level, code, message, state) => {
|
|
352
|
+
if (diagnostics && typeof diagnostics.record === 'function') {
|
|
353
|
+
try { diagnostics.record(level, 'share', code, message, { node: name, state }); } catch (_) { /* best-effort */ }
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
return (async () => {
|
|
357
|
+
emit('warn', 'SHARE_REVOKE_PENDING', 'Share OFF reconciliation pending', 'pending');
|
|
358
|
+
const slots = Array.from({ length: 3 }, (_, index) => {
|
|
359
|
+
const candidate = Array.isArray(backoff) ? backoff[index] : SHARE_REVOKE_BACKOFF_MS[index];
|
|
360
|
+
return Number.isFinite(candidate) ? Math.max(0, Math.min(candidate, 30000)) : SHARE_REVOKE_BACKOFF_MS[index];
|
|
361
|
+
});
|
|
362
|
+
for (let round = 0; round < slots.length; round += 1) {
|
|
363
|
+
try {
|
|
364
|
+
if (slots[round] > 0) await delay(slots[round]);
|
|
365
|
+
// Re-read dello stato desiderato immediatamente prima di ogni round.
|
|
366
|
+
// Un peer rimosso o nuovamente condiviso annulla la policy OFF in coda.
|
|
367
|
+
const st = store.loadStoreStrict(nodesPath);
|
|
368
|
+
const fresh = store.getNode(st, name);
|
|
369
|
+
if (!fresh || fresh.shared !== false) return { status: 'aborted', reason: 'desired-state-changed' };
|
|
370
|
+
await reconcileImpl({
|
|
371
|
+
node: fresh, shared: false, fetchImpl,
|
|
372
|
+
healthAttempts: Math.max(3, Math.min(Number(healthAttempts) || 3, 12)),
|
|
373
|
+
notifyAttempts: Math.max(3, Math.min(Number(notifyAttempts) || 3, 6)),
|
|
374
|
+
delayMs: Math.max(0, Math.min(Number(delayMs) || 0, 2000)), timeoutMs,
|
|
375
|
+
});
|
|
376
|
+
emit('warn', 'SHARE_REVOKE_RECOVERED', 'Share OFF reconciliation recovered', 'recovered');
|
|
377
|
+
return { status: 'recovered', rounds: round + 1 };
|
|
378
|
+
} catch (_) {
|
|
379
|
+
// Error text is intentionally not recorded: it can contain transport
|
|
380
|
+
// details. The stable transition code is sufficient for Diagnostics.
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
emit('error', 'SHARE_REVOKE_EXHAUSTED', 'Share OFF reconciliation exhausted', 'exhausted');
|
|
384
|
+
return { status: 'exhausted', rounds: 3 };
|
|
385
|
+
})().finally(() => { runningSet.delete(name); });
|
|
386
|
+
}
|
|
387
|
+
|
|
327
388
|
function controlledVisited(req, ingress, instanceId) {
|
|
328
389
|
const raw = ingress ? String(req.headers['x-nexuscrew-visited'] || '') : '';
|
|
329
390
|
const seen = raw ? raw.split(',').filter(Boolean) : [];
|
|
@@ -565,5 +626,5 @@ module.exports = {
|
|
|
565
626
|
MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS,
|
|
566
627
|
peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery,
|
|
567
628
|
collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
|
|
568
|
-
probeHealth, waitForHealthyPeer, notifyHubShare, reconcilePeerShare,
|
|
629
|
+
probeHealth, waitForHealthyPeer, notifyHubShare, reconcilePeerShare, runShareRevokeBoot,
|
|
569
630
|
};
|
package/lib/server.js
CHANGED
|
@@ -182,12 +182,28 @@ function createServer(opts = {}) {
|
|
|
182
182
|
// asincrona chiude la finestra di crash tra write locale e ACK hub
|
|
183
183
|
// senza ritardare il listen del server.
|
|
184
184
|
const reconcileShare = cfg.reconcilePeerShareImpl || federation.reconcilePeerShare;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
185
|
+
if (node.shared === true) {
|
|
186
|
+
Promise.resolve(reconcileShare({
|
|
187
|
+
node, shared: true, fetchImpl: healthFetch,
|
|
188
|
+
healthAttempts: 3, notifyAttempts: 3, delayMs: 200,
|
|
189
|
+
})).catch((e) => {
|
|
190
|
+
process.stderr.write(`[nexuscrew] peer ${node.name} Share reconcile pending: ${String(e && e.message || e).replace(/Bearer\s+\S+/gi, 'Bearer ***')}\n`);
|
|
191
|
+
});
|
|
192
|
+
} else {
|
|
193
|
+
const runRevoke = cfg.runShareRevokeBootImpl || federation.runShareRevokeBoot;
|
|
194
|
+
Promise.resolve(runRevoke({
|
|
195
|
+
node, nodesPath, fetchImpl: healthFetch, diagnostics,
|
|
196
|
+
reconcileImpl: reconcileShare,
|
|
197
|
+
healthAttempts: 3, notifyAttempts: 3, delayMs: 200,
|
|
198
|
+
...(Array.isArray(cfg.shareRevokeBackoff) ? { backoff: cfg.shareRevokeBackoff } : {}),
|
|
199
|
+
...(typeof cfg.shareRevokeDelay === 'function' ? { delay: cfg.shareRevokeDelay } : {}),
|
|
200
|
+
})).catch(() => {
|
|
201
|
+
// The production runner contains failures and records the stable
|
|
202
|
+
// transitions itself. This protects only an injected/custom runner.
|
|
203
|
+
diagnostics.record('error', 'share', 'SHARE_REVOKE_EXHAUSTED',
|
|
204
|
+
'Share OFF reconciliation exhausted', { node: node.name, state: 'exhausted' });
|
|
205
|
+
});
|
|
206
|
+
}
|
|
191
207
|
}
|
|
192
208
|
}
|
|
193
209
|
}
|
package/lib/settings/routes.js
CHANGED
|
@@ -48,7 +48,7 @@ const nodeAliases = require('../nodes/aliases.js');
|
|
|
48
48
|
const nodesCmds = require('../nodes/commands.js');
|
|
49
49
|
const nodesTunnel = require('../nodes/tunnel.js');
|
|
50
50
|
const peering = require('../nodes/peering.js');
|
|
51
|
-
const { waitForHealthyPeer, notifyHubShare } = require('../proxy/federation.js');
|
|
51
|
+
const { waitForHealthyPeer, notifyHubShare, reconcilePeerShare } = require('../proxy/federation.js');
|
|
52
52
|
const { rotateToken } = require('../auth/token.js');
|
|
53
53
|
const { generateService, installService, installPath: svcInstallPath } = require('../cli/service.js');
|
|
54
54
|
const { detectPlatform, nodeBin, repoRoot, uid } = require('../cli/platform.js');
|
|
@@ -573,8 +573,10 @@ function settingsRoutes(deps = {}) {
|
|
|
573
573
|
const fetchImpl = seams.fetchImpl || fetch;
|
|
574
574
|
const notifyHub = (shared) => notifyHubShare({ node, shared, fetchImpl, timeoutMs: 5000 });
|
|
575
575
|
const ensureLocal = async ({ restart = false } = {}) => {
|
|
576
|
-
|
|
577
|
-
const
|
|
576
|
+
const stopForward = seams.stopTunnelImpl || nodesTunnel.stopTunnel;
|
|
577
|
+
const startForward = seams.startForwardImpl || nodesTunnel.startForward;
|
|
578
|
+
if (restart) stopForward({ home, name });
|
|
579
|
+
const started = startForward({
|
|
578
580
|
home, node, localAppPort: runtimePort(),
|
|
579
581
|
spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
|
|
580
582
|
sshBin: seams.sshBin, logFd: seams.logFd,
|
|
@@ -606,27 +608,71 @@ function settingsRoutes(deps = {}) {
|
|
|
606
608
|
node = nodesStore.getNode(st, name);
|
|
607
609
|
await ensureLocal({ restart: true });
|
|
608
610
|
};
|
|
611
|
+
const revokeHub = () => reconcilePeerShare({
|
|
612
|
+
node, shared: false, fetchImpl,
|
|
613
|
+
healthAttempts: 6, delayMs: 200, notifyAttempts: 3, timeoutMs: 5000,
|
|
614
|
+
...(typeof seams.pairDelay === 'function' ? { delay: seams.pairDelay } : {}),
|
|
615
|
+
});
|
|
616
|
+
const reconcileOff = async ({ restart = false, unchanged = false } = {}) => {
|
|
617
|
+
try {
|
|
618
|
+
// The authenticated hub endpoint is reached through the existing -L.
|
|
619
|
+
// Revoke before any spec-aware replacement can tear that transport down.
|
|
620
|
+
await revokeHub();
|
|
621
|
+
} catch (revokeErr) {
|
|
622
|
+
return send(res, 502, {
|
|
623
|
+
error: 'Share disattivato localmente; hub non riconciliato',
|
|
624
|
+
shared: false, revoked: false, reconcilePending: true,
|
|
625
|
+
detail: scrubError(revokeErr),
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
try {
|
|
629
|
+
// A changed ON->OFF spec is restarted explicitly; same-state OFF uses
|
|
630
|
+
// the idempotent spec-aware path, which only replaces a stale -R.
|
|
631
|
+
await ensureLocal({ restart });
|
|
632
|
+
} catch (restartErr) {
|
|
633
|
+
return send(res, 502, {
|
|
634
|
+
error: 'Share revocato sul hub; riavvio locale non riconciliato',
|
|
635
|
+
shared: false, revoked: true, localReconcilePending: true,
|
|
636
|
+
detail: scrubError(restartErr),
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
return send(res, 200, {
|
|
640
|
+
name, shared: false, revoked: true,
|
|
641
|
+
...(unchanged ? { unchanged: true, reconciled: true } : {}),
|
|
642
|
+
});
|
|
643
|
+
};
|
|
609
644
|
|
|
610
645
|
try {
|
|
611
646
|
if (node.shared === body.shared) {
|
|
612
647
|
// Persisted intent is not proof of the detached process mode. A stale
|
|
613
648
|
// supervisor can survive an npm upgrade and keep retrying -R even when
|
|
614
|
-
// the store/UI says private.
|
|
615
|
-
//
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
649
|
+
// the store/UI says private. Same-state OFF revokes first, then enters
|
|
650
|
+
// the spec-aware start path without rewriting nodes.json.
|
|
651
|
+
if (body.shared) {
|
|
652
|
+
await ensureLocal();
|
|
653
|
+
await notifyHub(true);
|
|
654
|
+
return send(res, 200, { name, shared: true, unchanged: true, reconciled: true });
|
|
655
|
+
}
|
|
656
|
+
return reconcileOff({ unchanged: true });
|
|
619
657
|
}
|
|
620
658
|
if (body.shared) {
|
|
621
659
|
await applyLocal(true);
|
|
622
660
|
await notifyHub(true);
|
|
623
|
-
|
|
624
|
-
// Persist OFF first. If the process dies before the hub ACK, the next
|
|
625
|
-
// boot starts only -L and reconciles the stored false state.
|
|
626
|
-
await applyLocal(false);
|
|
627
|
-
await notifyHub(false);
|
|
661
|
+
return send(res, 200, { name, shared: true });
|
|
628
662
|
}
|
|
629
|
-
|
|
663
|
+
// OFF e' fail-safe e crash-durevole (design piano §3.2): persist desired
|
|
664
|
+
// false -> revoke autenticata sul canale -L ancora vivo -> restart locale
|
|
665
|
+
// senza -R. MAI restart-before-revoke: il -L e' il canale stesso della
|
|
666
|
+
// revoca, e un crash prima dell'ACK non puo' mai ripubblicare da intent
|
|
667
|
+
// stale (il file dice gia' false; il boot riparte -L only + reconcile).
|
|
668
|
+
// 1) persist desired false (snapshot dei soli campi gia' validati).
|
|
669
|
+
st = nodesStore.loadStoreStrict(nodesPath);
|
|
670
|
+
st = nodesStore.updateNode(st, name, { shared: false });
|
|
671
|
+
nodesStore.atomicWriteStore(nodesPath, st);
|
|
672
|
+
persistedDesired = false;
|
|
673
|
+
node = nodesStore.getNode(st, name);
|
|
674
|
+
// 2-3) revoke sul -L vivo, poi restart locale senza -R.
|
|
675
|
+
return reconcileOff({ restart: true });
|
|
630
676
|
} catch (e) {
|
|
631
677
|
// Share-on is transactional: a failed hub acknowledgement returns to the
|
|
632
678
|
// safe private -L-only state. Never include remote response bodies/tokens.
|
package/lib/tmux/lifecycle.js
CHANGED
|
@@ -43,6 +43,7 @@ function presetArgv(preset, extra) {
|
|
|
43
43
|
// Pure: argomenti tmux per la create, o null se input invalido.
|
|
44
44
|
function buildCreateArgs(name, realCwd, preset, extraPresets) {
|
|
45
45
|
if (!validSessionName(name) || typeof realCwd !== 'string' || !realCwd) return null;
|
|
46
|
+
if (name.includes('.')) return null; // tmux normalizza '.' in '_': rifiuta in scrittura
|
|
46
47
|
const argv = presetArgv(String(preset || 'shell'), extraPresets);
|
|
47
48
|
if (argv === undefined) return null;
|
|
48
49
|
const base = ['new-session', '-d', '-s', name, '-c', realCwd];
|