@mmmbuto/nexuscrew 0.9.28 → 0.9.30
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 +16 -0
- package/docs/LIVE_PROMPT.md +1 -1
- package/docs/NOTIFICATIONS.md +37 -0
- package/docs/OPERATIONS.md +20 -0
- package/frontend/dist/assets/index-BbcjoZS0.js +167 -0
- package/frontend/dist/assets/index-DqCfZFGi.css +32 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +91 -10
- package/frontend/dist/version.json +1 -1
- package/lib/audio/groups.js +1 -1
- package/lib/auth/token.js +4 -4
- package/lib/cli/commands.js +70 -15
- package/lib/cli/doctor.js +5 -5
- package/lib/cli/fleet-service.js +7 -7
- package/lib/cli/init.js +55 -31
- package/lib/cli/pidfile.js +1 -1
- package/lib/cli/service.js +124 -8
- package/lib/cli/stable-alias.js +4 -4
- package/lib/cli/url.js +1 -1
- package/lib/config.js +36 -6
- package/lib/files/routes.js +1 -1
- package/lib/fleet/boot.js +2 -2
- package/lib/fleet/builtin.js +15 -15
- package/lib/fleet/causes.js +1 -1
- package/lib/fleet/cell-exec.js +23 -23
- package/lib/fleet/cell-hold.js +6 -6
- package/lib/fleet/cell-lease-server.js +106 -106
- package/lib/fleet/cell-lease.js +21 -21
- package/lib/fleet/definitions.js +10 -10
- package/lib/fleet/identity-authority.js +1 -1
- package/lib/fleet/launch-broker.js +13 -13
- package/lib/fleet/launch.js +15 -15
- package/lib/fleet/lease-client.js +23 -23
- package/lib/fleet/lease-routes.js +3 -3
- package/lib/fleet/lease-verifier.js +22 -22
- package/lib/fleet/managed.js +35 -35
- package/lib/fleet/prompt-delivery.js +13 -13
- package/lib/fleet/routes.js +15 -10
- package/lib/fleet/runtime.js +24 -24
- package/lib/live-host/bridge.js +53 -43
- package/lib/live-host/federation.js +97 -0
- package/lib/live-host/routes.js +166 -8
- package/lib/live-host/store.js +44 -6
- package/lib/mcp/server.js +14 -14
- package/lib/mcp/tools.js +5 -5
- package/lib/nodes/access-presets.js +258 -0
- package/lib/nodes/commands.js +198 -4
- package/lib/nodes/store.js +152 -9
- package/lib/nodes/tunnel.js +7 -7
- package/lib/notify/ask-answer-service.js +184 -0
- package/lib/notify/ask-receipts.js +197 -0
- package/lib/notify/ask-relay.js +152 -0
- package/lib/notify/asks.js +28 -3
- package/lib/notify/event-feed-acl.js +95 -0
- package/lib/notify/event-feed-asks-routes.js +144 -0
- package/lib/notify/event-feed-client.js +434 -0
- package/lib/notify/event-feed-history.js +115 -0
- package/lib/notify/event-feed-producers.js +175 -0
- package/lib/notify/event-feed-routes.js +254 -0
- package/lib/notify/event-feed.js +328 -0
- package/lib/notify/events.js +1 -1
- package/lib/notify/notifier.js +3 -3
- package/lib/notify/push-relay.js +240 -0
- package/lib/notify/push.js +8 -8
- package/lib/notify/routes.js +62 -23
- package/lib/proxy/federation.js +132 -12
- package/lib/proxy/node-proxy.js +13 -13
- package/lib/proxy/panel-auth.js +4 -4
- package/lib/proxy/panel-proxy.js +2 -2
- package/lib/proxy/resource-acl.js +145 -0
- package/lib/server.js +396 -40
- package/lib/settings/pairing-coordinator.js +1 -1
- package/lib/settings/routes.js +45 -32
- package/lib/update/manager.js +1 -1
- package/lib/update/runner.js +1 -1
- package/lib/voice/transcribe.js +1 -1
- package/lib/ws/bridge.js +3 -3
- package/package.json +1 -1
- package/skills/live/SKILL.md +12 -0
- package/frontend/dist/assets/index-CWra6B79.js +0 -167
- package/frontend/dist/assets/index-D6CIfCjv.css +0 -32
package/lib/cli/fleet-service.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
//
|
|
2
|
+
// Service companion (boot) generation and migration gate.
|
|
3
3
|
//
|
|
4
4
|
// generateFleetService({platform, nodeBin, entryPath}) genera il contenuto del
|
|
5
5
|
// service UNICO di boot (NON una unit per cella) che al boot esegue
|
|
@@ -44,7 +44,7 @@ function generateFleetService(opts) {
|
|
|
44
44
|
function generateFleetLinux(opts) {
|
|
45
45
|
const home = opts.home || os.homedir();
|
|
46
46
|
const { nodeBin, entryPath } = opts;
|
|
47
|
-
// Parita' di hardening con service.js
|
|
47
|
+
// Parita' di hardening con service.js: reject char non gestibili in systemd.
|
|
48
48
|
assertSystemdSafe('home', home);
|
|
49
49
|
assertSystemdSafe('nodeBin', nodeBin);
|
|
50
50
|
assertSystemdSafe('entryPath', entryPath);
|
|
@@ -194,7 +194,7 @@ function selectProviderModeSync(cfg = {}) {
|
|
|
194
194
|
|
|
195
195
|
// --- Install (speculare a installService di lib/cli/service.js) ---
|
|
196
196
|
// Stesso hardening di service.js: no-symlink atomic (lstat + tmp+rename), failure
|
|
197
|
-
//
|
|
197
|
+
// Collection (NON ingoiare activation —), execImpl iniettabile (test). Differenze
|
|
198
198
|
// companion:
|
|
199
199
|
// - nomi 'nexuscrew-fleet.*' + mode termux 0o755 (boot script eseguibile)
|
|
200
200
|
// - linux: daemon-reload + enable, NESSUN restart (Type=oneshot -> parte al boot)
|
|
@@ -209,7 +209,7 @@ function fleetInstallPath(platform, home) {
|
|
|
209
209
|
}
|
|
210
210
|
|
|
211
211
|
function fleetFileMode(platform) {
|
|
212
|
-
if (platform === 'termux') return 0o755; // boot script
|
|
212
|
+
if (platform === 'termux') return 0o755; // boot script must stay executable (chmod 755)
|
|
213
213
|
return 0o644; // systemd unit + launchd plist (come service.js)
|
|
214
214
|
}
|
|
215
215
|
|
|
@@ -237,7 +237,7 @@ function fleetInstallCommands(platform, target, ctx) {
|
|
|
237
237
|
|
|
238
238
|
// Install no-symlink + atomic rename (come service.js). execImpl iniettabile per test;
|
|
239
239
|
// default execFileSync (argv diretto, MAI shell string). Su activation fallita il file
|
|
240
|
-
//
|
|
240
|
+
// E' PRESERVATO e le failure raccolte per diagnosi (it is never swallowed, non si rollback).
|
|
241
241
|
// da revisione: `activate: false` (speculare a installService) scrive la definizione SENZA
|
|
242
242
|
// toccare il service manager — per chi rigenera a meta' aggiornamento: il companion
|
|
243
243
|
// e' RunAtLoad/oneshot, un bootstrap adesso eseguirebbe le celle boot:true mentre
|
|
@@ -255,7 +255,7 @@ function installFleetService(platform, content, ctx, {
|
|
|
255
255
|
const target = ctx.installPath || fleetInstallPath(platform, home);
|
|
256
256
|
const mode = fleetFileMode(platform);
|
|
257
257
|
|
|
258
|
-
//
|
|
258
|
+
// Lstat: reject symlink preesistente (no-symlink atomic, parita' service.js)
|
|
259
259
|
try {
|
|
260
260
|
const st = fs.lstatSync(target);
|
|
261
261
|
if (st.isSymbolicLink()) {
|
|
@@ -288,7 +288,7 @@ function installFleetService(platform, content, ctx, {
|
|
|
288
288
|
return { target, mode, written: true, failures: [], skippedActivation: skipped };
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
-
//
|
|
291
|
+
// Exec service manager — raccogli failure per diagnosi (NON ingoiare)
|
|
292
292
|
const failures = [];
|
|
293
293
|
if (platform === 'mac') {
|
|
294
294
|
failures.push(...reinstallLaunchAgent(target, 'com.mmmbuto.nexuscrew-fleet', ctx, {
|
package/lib/cli/init.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
//
|
|
2
|
+
// Nexuscrew init: orchestrazione setup portatile.
|
|
3
3
|
// detectPlatform -> prereq (Node>=18 abort, tmux abort-before-service) ->
|
|
4
4
|
// migration rule (porta da service esistente) -> config.json (preserva) ->
|
|
5
5
|
// token (preserva) -> NexusFiles -> generateService -> installService (skip dry-run) ->
|
|
6
|
-
//
|
|
7
|
-
//
|
|
6
|
+
// fleet companion (only when the provider is builtin and the gate passes;
|
|
7
|
+
// never fails the main init) -> print URL #token. Termux boot: best-effort.
|
|
8
8
|
const fs = require('node:fs');
|
|
9
9
|
const os = require('node:os');
|
|
10
10
|
const path = require('node:path');
|
|
@@ -12,7 +12,7 @@ const crypto = require('node:crypto');
|
|
|
12
12
|
const { detectPlatform, nodeBin, repoRoot, uid } = require('./platform.js');
|
|
13
13
|
const { loadOrCreateToken } = require('../auth/token.js');
|
|
14
14
|
const { generateService, installService, fileMode, installPath: svcInstallPath } = require('./service.js');
|
|
15
|
-
//
|
|
15
|
+
// Fleet boot companion. Injectable seams for tests.
|
|
16
16
|
const {
|
|
17
17
|
generateFleetService, installFleetService, migrationGate,
|
|
18
18
|
selectProviderModeSync, fleetFileMode,
|
|
@@ -103,7 +103,7 @@ function ensureFleetDefaults(opts = {}) {
|
|
|
103
103
|
return { path: fleetDefsPath, created: Boolean(creato), enabled: true };
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
// Migration rule
|
|
106
|
+
// Migration rule: se non c'è config.json, parse la porta dal service file esistente.
|
|
107
107
|
function readExistingPort(platform, home, installPathOverride) {
|
|
108
108
|
const p = installPathOverride || svcInstallPath(platform, home);
|
|
109
109
|
let raw;
|
|
@@ -131,11 +131,15 @@ function runInit(opts = {}) {
|
|
|
131
131
|
const tokenPath = opts.tokenPath || path.join(configDir, 'token');
|
|
132
132
|
const filesRoot = opts.filesRoot || path.join(home, 'NexusFiles');
|
|
133
133
|
const dryRun = !!opts.dryRun;
|
|
134
|
+
// Write the boot definitions without asking the service manager to load them.
|
|
135
|
+
// Needed by smoke runs and temporary prefixes, where the per-UID manager would
|
|
136
|
+
// otherwise act on the real installation (see planActivation in service.js).
|
|
137
|
+
const noActivate = !!opts.noActivate || opts.activate === false;
|
|
134
138
|
const installBoot = opts.installBoot !== false;
|
|
135
139
|
const log = opts.log || (() => {});
|
|
136
140
|
const tmuxOk = opts.tmuxOk !== undefined ? opts.tmuxOk : haveTmux(opts.tmuxBin || 'tmux', opts.env || process.env);
|
|
137
141
|
|
|
138
|
-
//
|
|
142
|
+
// Prereq Node (abort before any write)
|
|
139
143
|
if (nodeMajor() < 18) {
|
|
140
144
|
throw new Error(`Node >= 18 richiesto (attuale ${process.versions.node}). Aggiorna Node prima di init.`);
|
|
141
145
|
}
|
|
@@ -144,6 +148,11 @@ function runInit(opts = {}) {
|
|
|
144
148
|
// Structured, value-free install outcomes for callers that must fail closed
|
|
145
149
|
// (notably smart-up migrations). Human-readable actions remain compatible.
|
|
146
150
|
const installFailures = [];
|
|
151
|
+
// Outcome of the boot-definition activation: 'activated', 'skipped-by-flag'
|
|
152
|
+
// (--no-activate) or 'skipped-foreign-manager' (the manager loads another
|
|
153
|
+
// definition). Declared to callers that must not confuse a deliberate skip
|
|
154
|
+
// with a fresh activation.
|
|
155
|
+
let serviceActivation = null;
|
|
147
156
|
|
|
148
157
|
// porta: opts.port > migration rule > config esistente > default 41820
|
|
149
158
|
let port = opts.port;
|
|
@@ -152,7 +161,7 @@ function runInit(opts = {}) {
|
|
|
152
161
|
if (migrated) { port = migrated; actions.push(`migration: porta ${port} letta dal service esistente`); }
|
|
153
162
|
}
|
|
154
163
|
|
|
155
|
-
//
|
|
164
|
+
// Config.json (scrivi se non esiste; preserva se esiste)
|
|
156
165
|
if (!dryRun) {
|
|
157
166
|
fs.mkdirSync(configDir, { recursive: true });
|
|
158
167
|
if (!fs.existsSync(configPath)) {
|
|
@@ -189,7 +198,7 @@ function runInit(opts = {}) {
|
|
|
189
198
|
}
|
|
190
199
|
if (!port) port = 41820;
|
|
191
200
|
|
|
192
|
-
//
|
|
201
|
+
// Token (preserva esistente)
|
|
193
202
|
let token;
|
|
194
203
|
if (!dryRun) {
|
|
195
204
|
token = loadOrCreateToken(tokenPath);
|
|
@@ -204,7 +213,7 @@ function runInit(opts = {}) {
|
|
|
204
213
|
actions.push(`files root ${filesRoot}`);
|
|
205
214
|
}
|
|
206
215
|
|
|
207
|
-
//
|
|
216
|
+
// Nodes.json: nodeId STABILE per installazione (generato qui se manca) +
|
|
208
217
|
// migrazione esplicita guarded dei nodes legacy da config.json. READONLY/dry-run
|
|
209
218
|
// saltano; ogni errore -> WARN, MAI far fallire l'init.
|
|
210
219
|
if (!dryRun && process.env.NEXUSCREW_READONLY !== '1' && !opts.readonly) {
|
|
@@ -235,7 +244,7 @@ function runInit(opts = {}) {
|
|
|
235
244
|
port, home, uid: uid(),
|
|
236
245
|
installPath: opts.installPath,
|
|
237
246
|
};
|
|
238
|
-
// 1-bis
|
|
247
|
+
// 1-bis: la STESSA classe di difetto del companion di boot — nodeBin
|
|
239
248
|
// e entry finiscono versionati nell'unit/plist del servizio per-nodo. Si
|
|
240
249
|
// risolve qui, alla scrittura, e si dichiara cio' che non ha alias stabile:
|
|
241
250
|
// i due punti di scrittura non devono divergere (correggerne uno solo
|
|
@@ -253,7 +262,7 @@ function runInit(opts = {}) {
|
|
|
253
262
|
} else if (dryRun) {
|
|
254
263
|
actions.push(`DRY-RUN service (${platform}) generato, NON installato`);
|
|
255
264
|
} else if (!tmuxOk) {
|
|
256
|
-
//
|
|
265
|
+
// Tmux mancante: abort before service install (config/token gia' creati)
|
|
257
266
|
// Se il chiamante ha imposto tmuxOk direttamente (test/seam), non
|
|
258
267
|
// abbiamo l'informazione per distinguere assenza da verifica bloccata:
|
|
259
268
|
// il messaggio resta quello storico.
|
|
@@ -264,26 +273,36 @@ function runInit(opts = {}) {
|
|
|
264
273
|
} else {
|
|
265
274
|
for (const w of svcBoot.warnings) actions.push(`WARN service: ${w}`);
|
|
266
275
|
try {
|
|
267
|
-
const r = installService(platform, content, svcCtx, {
|
|
276
|
+
const r = installService(platform, content, svcCtx, {
|
|
277
|
+
execImpl: opts.execImpl,
|
|
278
|
+
activate: !noActivate,
|
|
279
|
+
});
|
|
280
|
+
serviceActivation = r.activation || null;
|
|
281
|
+
if (r.activation && r.activation.warning) {
|
|
282
|
+
const skip = r.activation.outcome === 'skipped-by-flag'
|
|
283
|
+
? r.activation.warning
|
|
284
|
+
: `WARN: ${r.activation.warning}`;
|
|
285
|
+
actions.push(skip);
|
|
286
|
+
}
|
|
268
287
|
if (r.failures && r.failures.length) {
|
|
269
|
-
//
|
|
288
|
+
// File installato MA activation (systemctl/launchctl) fallita
|
|
270
289
|
installFailures.push({ component: 'service', phase: 'activation' });
|
|
271
290
|
actions.push(`WARN: service file installato ${r.target} MA activation fallita: ${r.failures.map((f) => f.cmd).join('; ')} (file preservato, diagnosi)`);
|
|
272
291
|
} else {
|
|
273
292
|
actions.push(`service installed ${r.target} (mode 0${fileMode(platform).toString(8)})`);
|
|
274
293
|
}
|
|
275
294
|
} catch (e) {
|
|
276
|
-
//
|
|
295
|
+
// Failure: preserve file + diagnosi (no rollback)
|
|
277
296
|
installFailures.push({ component: 'service', phase: 'install' });
|
|
278
297
|
actions.push(`WARN: service install fallito: ${e.message} (file generati preservati)`);
|
|
279
298
|
}
|
|
280
299
|
}
|
|
281
300
|
|
|
282
|
-
// ---
|
|
283
|
-
//
|
|
284
|
-
// gate
|
|
285
|
-
// READONLY
|
|
286
|
-
//
|
|
301
|
+
// --- Fleet boot companion service ---
|
|
302
|
+
// The companion installs ONLY when the provider is builtin and the migration
|
|
303
|
+
// gate finds no enabled legacy cloud-cell@*.service units (no double boot).
|
|
304
|
+
// READONLY blocks every companion action. Never let it fail the main init:
|
|
305
|
+
// any companion error -> WARN action + normal return.
|
|
287
306
|
try {
|
|
288
307
|
const readonly = process.env.NEXUSCREW_READONLY === '1' || opts.readonly;
|
|
289
308
|
if (!installBoot) {
|
|
@@ -291,9 +310,9 @@ function runInit(opts = {}) {
|
|
|
291
310
|
} else if (readonly) {
|
|
292
311
|
actions.push('fleet companion: READONLY, non installato');
|
|
293
312
|
} else {
|
|
294
|
-
// provider mode: companion
|
|
295
|
-
//
|
|
296
|
-
//
|
|
313
|
+
// provider mode: companion ONLY when builtin. selectProviderModeSync is
|
|
314
|
+
// the synchronous default (runInit is sync: the bin calls process.exit after
|
|
315
|
+
// dispatch, an async selectProvider would not survive). Seam opts.selectProvider for tests.
|
|
297
316
|
const selectProvider = opts.selectProvider || selectProviderModeSync;
|
|
298
317
|
const fleetCfg = opts.fleetCfg || {
|
|
299
318
|
home,
|
|
@@ -305,16 +324,16 @@ function runInit(opts = {}) {
|
|
|
305
324
|
if (sel.mode !== 'builtin') {
|
|
306
325
|
actions.push(`fleet companion: non installato (provider ${sel.mode})`);
|
|
307
326
|
} else {
|
|
308
|
-
// migration gate
|
|
309
|
-
//
|
|
310
|
-
//
|
|
327
|
+
// migration gate: seam opts.migrationGate injectable; the fleet-service.js
|
|
328
|
+
// one (injectable exec via opts.execImpl). Blocked -> do NOT
|
|
329
|
+
// install (never a silent double boot).
|
|
311
330
|
const gate = (opts.migrationGate || migrationGate)({ exec: opts.execImpl, platform });
|
|
312
331
|
if (gate.blocked) {
|
|
313
332
|
actions.push(`WARN: fleet companion NON installato — migration gate bloccato: ${(gate.units || []).join(', ')} (${gate.remediation || 'disabilita le unit legacy cloud-cell@*.service'})`);
|
|
314
333
|
} else if (dryRun) {
|
|
315
334
|
actions.push('DRY-RUN fleet companion generato, NON installato');
|
|
316
335
|
} else {
|
|
317
|
-
//
|
|
336
|
+
// I path del boot si risolvono alla SCRITTURA del servizio (mai
|
|
318
337
|
// a ogni avvio): un alias stabile che risolva allo stesso file, se
|
|
319
338
|
// c'e'; altrimenti il path attuale, DICHIARATO — un avviso adesso e'
|
|
320
339
|
// meglio di un path che scade in silenzio (vedi stable-alias.js).
|
|
@@ -331,10 +350,12 @@ function runInit(opts = {}) {
|
|
|
331
350
|
const fr = installFleetService(
|
|
332
351
|
platform, fleetContent,
|
|
333
352
|
{ home, uid: uid(), installPath: opts.fleetInstallPath },
|
|
334
|
-
{ execImpl: opts.execImpl },
|
|
353
|
+
{ execImpl: opts.execImpl, activate: !noActivate },
|
|
335
354
|
);
|
|
336
|
-
if (fr.
|
|
337
|
-
|
|
355
|
+
if (fr.skippedActivation) {
|
|
356
|
+
actions.push(`fleet companion ${fr.target}: activation skipped (--no-activate)`);
|
|
357
|
+
} else if (fr.failures && fr.failures.length) {
|
|
358
|
+
// File installato MA activation (systemctl/launchctl) fallita
|
|
338
359
|
installFailures.push({ component: 'fleet-companion', phase: 'activation' });
|
|
339
360
|
actions.push(`WARN: fleet companion file installato ${fr.target} MA activation fallita: ${fr.failures.map((f) => f.cmd).join('; ')} (file preservato, diagnosi)`);
|
|
340
361
|
} else {
|
|
@@ -349,7 +370,7 @@ function runInit(opts = {}) {
|
|
|
349
370
|
actions.push(`WARN: fleet companion fallito: ${e.message} (init principale prosegue)`);
|
|
350
371
|
}
|
|
351
372
|
|
|
352
|
-
// Termux:boot best-effort detection
|
|
373
|
+
// Termux:boot best-effort detection
|
|
353
374
|
if (platform === 'termux' && installBoot) {
|
|
354
375
|
const bootDir = path.join(home, '.termux', 'boot');
|
|
355
376
|
const bootOk = fs.existsSync(bootDir);
|
|
@@ -367,7 +388,10 @@ function runInit(opts = {}) {
|
|
|
367
388
|
|
|
368
389
|
for (const a of actions) log(a);
|
|
369
390
|
|
|
370
|
-
return {
|
|
391
|
+
return {
|
|
392
|
+
platform, port, token, url: urlWithToken, actions, installFailures,
|
|
393
|
+
serviceActivation, tmuxOk, dryRun,
|
|
394
|
+
};
|
|
371
395
|
}
|
|
372
396
|
|
|
373
397
|
module.exports = { runInit, readExistingPort, haveTmux, nodeMajor, writeConfigAtomic, ensureFleetDefaults };
|
package/lib/cli/pidfile.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Pidfile con verified kill: metadata {pid, cmd, startTs}; kill verifica cmd+pid
|
|
3
3
|
// prima di signalare (no broad match by name). Il controllo pid+nascita riduce
|
|
4
4
|
// il PID reuse a una finestra residua fra verifica e segnale — non lo elimina;
|
|
5
|
-
//
|
|
5
|
+
// Dettaglio e motivo nel commento sopra il kill in killPidfile.
|
|
6
6
|
// Primario su Termux (serve --pidfile); opzionale --manual su linux/mac.
|
|
7
7
|
const fs = require('node:fs');
|
|
8
8
|
const os = require('node:os');
|
package/lib/cli/service.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
// Service generation per-platform: systemd --user (linux), launchd plist (mac),
|
|
3
|
-
// Termux:boot script. Escaping per-platform + install no-symlink atomic.
|
|
3
|
+
// Termux:boot script. Escaping per-platform + install no-symlink atomic.
|
|
4
4
|
const fs = require('node:fs');
|
|
5
5
|
const os = require('node:os');
|
|
6
6
|
const path = require('node:path');
|
|
@@ -42,7 +42,7 @@ function shellQuote(s) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
// systemd ExecStart non gestisce bene alcuni char (", $, ;, backtick, newline, apice)
|
|
45
|
-
//
|
|
45
|
+
// Nemmeno con escaping ad hoc: reject esplicito con errore chiaro.
|
|
46
46
|
// space e % sono ok (escaped). Path repo normali non hanno questi char.
|
|
47
47
|
const SYSTEMD_FORBIDDEN = /[";$`\n']/;
|
|
48
48
|
function assertSystemdSafe(label, p) {
|
|
@@ -54,7 +54,7 @@ function assertSystemdSafe(label, p) {
|
|
|
54
54
|
// --- Templates ---
|
|
55
55
|
|
|
56
56
|
// Entry del servizio: di norma derivato da repoRoot; i punti di scrittura
|
|
57
|
-
//
|
|
57
|
+
// Che risolvono l'alias stabile (/1-bis, stable-alias.js) passano
|
|
58
58
|
// l'entryPath gia' risolto. Contratto vecchio intatto: senza entryPath si
|
|
59
59
|
// deriva da repoRoot come prima.
|
|
60
60
|
function serviceEntryPath(ctx) {
|
|
@@ -69,7 +69,7 @@ function generateService(platform, ctx) {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
function generateLinux(ctx) {
|
|
72
|
-
assertSystemdSafe('repoRoot', ctx.repoRoot); //
|
|
72
|
+
assertSystemdSafe('repoRoot', ctx.repoRoot); // Reject char non gestibili
|
|
73
73
|
assertSystemdSafe('nodeBin', ctx.nodeBin);
|
|
74
74
|
// HOME is stable across init/repair/update. The runtime directory may be
|
|
75
75
|
// atomically replaced; keeping it as the service cwd leaves the HTTP process
|
|
@@ -304,7 +304,7 @@ function reinstallLaunchAgent(target, serviceName, ctx, opts = {}) {
|
|
|
304
304
|
}
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
-
// Install no-symlink + atomic rename.
|
|
307
|
+
// Install no-symlink + atomic rename.
|
|
308
308
|
// - lstat target: reject symlink (no follow)
|
|
309
309
|
// - write temp file nella stessa dir -> chmod mode -> atomic rename
|
|
310
310
|
// - exec service manager (systemctl/launchctl); skip in dryRun
|
|
@@ -352,15 +352,20 @@ function installService(platform, content, ctx, {
|
|
|
352
352
|
throw e;
|
|
353
353
|
}
|
|
354
354
|
|
|
355
|
-
//
|
|
355
|
+
// Exec service manager — NON ingoiare failure: raccogli per diagnosi
|
|
356
356
|
const cmds = installCommands(platform, target, ctx);
|
|
357
|
-
|
|
357
|
+
const activation = planActivation(platform, target, ctx, { execImpl, activate });
|
|
358
|
+
if (!activation.activate) {
|
|
359
|
+
// The manager will not run what this HOME just wrote, or the caller asked to
|
|
360
|
+
// write without activating: the skipped plan stays declared, and a skip for a
|
|
361
|
+
// foreign HOME says why instead of touching a service that is not ours.
|
|
358
362
|
return {
|
|
359
363
|
target,
|
|
360
364
|
mode,
|
|
361
365
|
written: true,
|
|
362
366
|
failures: [],
|
|
363
367
|
skippedActivation: cmds.map(([bin, args]) => `${bin} ${args.join(' ')}`),
|
|
368
|
+
activation,
|
|
364
369
|
};
|
|
365
370
|
}
|
|
366
371
|
const failures = [];
|
|
@@ -380,7 +385,7 @@ function installService(platform, content, ctx, {
|
|
|
380
385
|
}
|
|
381
386
|
}
|
|
382
387
|
|
|
383
|
-
return { target, mode, written: true, failures };
|
|
388
|
+
return { target, mode, written: true, failures, activation };
|
|
384
389
|
}
|
|
385
390
|
|
|
386
391
|
function installCommands(platform, target, ctx) {
|
|
@@ -407,6 +412,116 @@ function installCommands(platform, target, ctx) {
|
|
|
407
412
|
}
|
|
408
413
|
|
|
409
414
|
|
|
415
|
+
// --- Activation guard -------------------------------------------------------
|
|
416
|
+
// The user service manager is per-UID, not per-HOME. `systemctl --user` and
|
|
417
|
+
// `launchctl` resolve our unit by NAME inside the namespace of the logged-in
|
|
418
|
+
// user, while this module writes the definition under the HOME it was given.
|
|
419
|
+
// A run with a temporary HOME therefore wrote a definition the manager never
|
|
420
|
+
// loads, and the activation commands still hit the real manager: the REAL
|
|
421
|
+
// service was restarted twice during a single smoke run on the live node.
|
|
422
|
+
//
|
|
423
|
+
// Decide ONCE, before the activation commands, reading back WHICH definition the
|
|
424
|
+
// manager has loaded. If it is not the one just written, activation is skipped
|
|
425
|
+
// and said out loud, instead of touching a service this HOME does not own.
|
|
426
|
+
const ACTIVATION_OUTCOMES = Object.freeze({
|
|
427
|
+
activated: 'activated',
|
|
428
|
+
skippedByFlag: 'skipped-by-flag',
|
|
429
|
+
skippedForeignManager: 'skipped-foreign-manager',
|
|
430
|
+
notApplicable: 'not-applicable',
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const NO_ACTIVATE_WARNING = (target) =>
|
|
434
|
+
`unit installed at ${target}, activation skipped: no service manager command was run`;
|
|
435
|
+
|
|
436
|
+
function foreignManagerWarning(target, managerPath) {
|
|
437
|
+
return `unit installed at ${target} but the user service manager loads ${managerPath}: `
|
|
438
|
+
+ 'activation skipped, run `nexuscrew restart` from that home or use --no-activate';
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Two paths name the same file when they resolve to the same inode path; the
|
|
442
|
+
// manager reports its own canonical form, so compare canonically on both sides.
|
|
443
|
+
function sameDefinitionPath(a, b) {
|
|
444
|
+
const canonical = (p) => {
|
|
445
|
+
try { return fs.realpathSync(p); } catch (_) { return path.resolve(String(p)); }
|
|
446
|
+
};
|
|
447
|
+
return canonical(a) === canonical(b);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Which definition does the user service manager have loaded for our unit?
|
|
451
|
+
// absent nothing loaded, or no readable answer: activation stays as it was
|
|
452
|
+
// same it loads the definition just written
|
|
453
|
+
// foreign it loads a DIFFERENT definition: this HOME does not own that service
|
|
454
|
+
//
|
|
455
|
+
// The guard fires on POSITIVE evidence only. An answer we cannot read as a
|
|
456
|
+
// definition path is never turned into a silent behaviour change: it keeps the
|
|
457
|
+
// previous activation path instead of guessing.
|
|
458
|
+
function readLoadedDefinition(platform, target, ctx, execImpl) {
|
|
459
|
+
if (platform === 'linux') {
|
|
460
|
+
let raw;
|
|
461
|
+
try {
|
|
462
|
+
raw = execImpl('systemctl', [
|
|
463
|
+
'--user', 'show', 'nexuscrew.service', '--property=FragmentPath', '--value',
|
|
464
|
+
], { encoding: 'utf8' });
|
|
465
|
+
} catch (_) {
|
|
466
|
+
// No probing possible (no user manager): the activation commands below fail
|
|
467
|
+
// the same way and are reported as failures, exactly as they were before.
|
|
468
|
+
return { state: 'absent', path: null };
|
|
469
|
+
}
|
|
470
|
+
const loaded = typeof raw === 'string' ? raw.trim() : '';
|
|
471
|
+
if (!loaded) return { state: 'absent', path: null };
|
|
472
|
+
return sameDefinitionPath(loaded, target)
|
|
473
|
+
? { state: 'same', path: loaded }
|
|
474
|
+
: { state: 'foreign', path: loaded };
|
|
475
|
+
}
|
|
476
|
+
if (platform === 'mac') {
|
|
477
|
+
const label = `gui/${ctx.uid || uid()}/com.mmmbuto.nexuscrew`;
|
|
478
|
+
let raw;
|
|
479
|
+
try {
|
|
480
|
+
raw = execImpl('launchctl', ['print', label], { encoding: 'utf8' });
|
|
481
|
+
} catch (_) {
|
|
482
|
+
return { state: 'absent', path: null }; // not loaded
|
|
483
|
+
}
|
|
484
|
+
const match = typeof raw === 'string' ? raw.match(/^\s*path = (.+?)\s*$/m) : null;
|
|
485
|
+
if (!match) return { state: 'absent', path: null };
|
|
486
|
+
return sameDefinitionPath(match[1], target)
|
|
487
|
+
? { state: 'same', path: match[1] }
|
|
488
|
+
: { state: 'foreign', path: match[1] };
|
|
489
|
+
}
|
|
490
|
+
return { state: 'absent', path: null };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// The single decision point for activation: callers write the definition, then
|
|
494
|
+
// ask this whether the manager may be touched at all.
|
|
495
|
+
function planActivation(platform, target, ctx, { execImpl = execFileSync, activate = true } = {}) {
|
|
496
|
+
if (platform === 'termux') {
|
|
497
|
+
return { activate: false, outcome: ACTIVATION_OUTCOMES.notApplicable, managerPath: null, warning: null };
|
|
498
|
+
}
|
|
499
|
+
if (!activate) {
|
|
500
|
+
return {
|
|
501
|
+
activate: false,
|
|
502
|
+
outcome: ACTIVATION_OUTCOMES.skippedByFlag,
|
|
503
|
+
managerPath: null,
|
|
504
|
+
warning: NO_ACTIVATE_WARNING(target),
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
const loaded = readLoadedDefinition(platform, target, ctx, execImpl);
|
|
508
|
+
if (loaded.state === 'foreign') {
|
|
509
|
+
return {
|
|
510
|
+
activate: false,
|
|
511
|
+
outcome: ACTIVATION_OUTCOMES.skippedForeignManager,
|
|
512
|
+
managerPath: loaded.path,
|
|
513
|
+
warning: foreignManagerWarning(target, loaded.path),
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
activate: true,
|
|
518
|
+
outcome: ACTIVATION_OUTCOMES.activated,
|
|
519
|
+
managerPath: loaded.path,
|
|
520
|
+
warning: null,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
|
|
410
525
|
// --- Riconoscimento di PROPRIETA' (da revisione) -------------------------
|
|
411
526
|
// La sanazione automatica riscrive una definizione solo se e' NOSTRA. La prima
|
|
412
527
|
// versione cercava /nexuscrew/i nel contenuto: un unit di terzi che ci nomina
|
|
@@ -456,6 +571,7 @@ module.exports = {
|
|
|
456
571
|
isOurService, OUR_SERVICE_ANCHOR,
|
|
457
572
|
generateService, generateLinux, generateMac, generateTermux,
|
|
458
573
|
installService, installPath, installCommands, fileMode,
|
|
574
|
+
planActivation, readLoadedDefinition, sameDefinitionPath, ACTIVATION_OUTCOMES,
|
|
459
575
|
ensureLinuxTmuxSurvival, linuxTmuxSurvivalPath, LINUX_TMUX_SURVIVAL_DROPIN,
|
|
460
576
|
reinstallLaunchAgent, waitForLaunchdUnload,
|
|
461
577
|
escapeSystemdPath, escapeSystemdExec, escapeXml, shellQuote,
|
package/lib/cli/stable-alias.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
//
|
|
2
|
+
// Alias stabili per i path del servizio di boot.
|
|
3
3
|
//
|
|
4
4
|
// Il companion di boot scrive nodeBin e entryPath LETTERALMENTE nel plist
|
|
5
5
|
// launchd (ProgramArguments[0]) e nell'unit systemd (ExecStart=). Quei path
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// Se c'e', scrivere quello; se no, scrivere il path attuale e DICHIARARLO:
|
|
18
18
|
// un avviso alla scrittura e' meglio di un path che scade in silenzio.
|
|
19
19
|
//
|
|
20
|
-
// Tre vincoli non negoziabili (briefing
|
|
20
|
+
// Tre vincoli non negoziabili (briefing):
|
|
21
21
|
// 1. La verifica «e' lo stesso file» si fa alla SCRITTURA del servizio, MAI a
|
|
22
22
|
// ogni avvio: dopo l'upgrade il symlink DEVE puntare a un node diverso —
|
|
23
23
|
// e' l'effetto voluto. Un controllo di identita' permanente si
|
|
@@ -116,7 +116,7 @@ function resolveBootPaths({ nodeBin, entryPath, env = process.env, realpath } =
|
|
|
116
116
|
// morto: il suo criterio e' «alias che realpath allo STESSO file», e un file
|
|
117
117
|
// morto non e' lo stesso file di nessuno. Il criterio qui e' diverso e
|
|
118
118
|
// dichiarato: un path VIVO — l'alias stabile dello stesso file quando
|
|
119
|
-
//
|
|
119
|
+
// L'attuale vive (intatto), altrimenti il primo candidato stabile che
|
|
120
120
|
// ESISTE, con la dichiarazione; se non c'e' niente di vivo, il path resta e
|
|
121
121
|
// la dichiarazione dice di rilanciare init. `env`, `realpath` e `exists`
|
|
122
122
|
// iniettabili per test.
|
|
@@ -127,7 +127,7 @@ function resolveLiveBootPaths({ nodeBin, entryPath, env = process.env, realpath,
|
|
|
127
127
|
const revive = (current, candidates, what) => {
|
|
128
128
|
try {
|
|
129
129
|
realpathImpl(current);
|
|
130
|
-
return null; //
|
|
130
|
+
return null; // Vivo: decide il criterio di resolveBootPaths
|
|
131
131
|
} catch (_) { /* morto su disco: cerca una alternativa viva */ }
|
|
132
132
|
for (const candidate of Array.isArray(candidates) ? candidates : []) {
|
|
133
133
|
if (existsImpl(candidate)) {
|
package/lib/cli/url.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// URL/token helpers
|
|
2
|
+
// URL/token helpers for the unified CLI.
|
|
3
3
|
// - resolvePaths: config/token path home-aware (seam per test con HOME temporanea).
|
|
4
4
|
// - readToken: legge il token esistente SENZA crearlo (no-follow symlink), null se assente.
|
|
5
5
|
// - loadPort: porta corrente (opts > env > config.json > default 41820).
|
package/lib/config.js
CHANGED
|
@@ -17,7 +17,7 @@ function baseDefaults() {
|
|
|
17
17
|
return {
|
|
18
18
|
bind: '127.0.0.1',
|
|
19
19
|
port: 41820,
|
|
20
|
-
// Seconda porta loopback, SOLO per /panel/* (
|
|
20
|
+
// Seconda porta loopback, SOLO per /panel/* (sicurezza 2026-08-16):
|
|
21
21
|
// un pannello vive su un'origin diversa dal control plane — la porta fa
|
|
22
22
|
// parte dell'origin, quindi un iframe cross-porta non legge il
|
|
23
23
|
// localStorage del padre, qualunque JS esegua il container. Nessuna API
|
|
@@ -57,14 +57,14 @@ function baseDefaults() {
|
|
|
57
57
|
// Il manager aggiorna solo verso una semver superiore: mai downgrade.
|
|
58
58
|
autoUpdate: true,
|
|
59
59
|
sessionPresets: {},
|
|
60
|
-
// Ponte Live (fetta 3, rev5
|
|
60
|
+
// Ponte Live (fetta 3, rev5): isolabile — a false il ponte non si
|
|
61
61
|
// connette mai e ogni avvio di Live resta sul comportamento standard.
|
|
62
62
|
liveBridgeEnabled: true,
|
|
63
63
|
// Socket di controllo dell'app-server (misurato 2026-08-15:
|
|
64
64
|
// $CODEX_HOME/app-server-control/app-server-control.sock, WebSocket sopra
|
|
65
65
|
// unix socket). Non cablare mai una porta al posto di questo path.
|
|
66
66
|
liveBridgeSocketPath: path.join(os.homedir(), '.codex', 'app-server-control', 'app-server-control.sock'),
|
|
67
|
-
//
|
|
67
|
+
// Limite dichiarato per OGNI fase del ponte (GET designazione e
|
|
68
68
|
// sessione sul socket). Oltre questo la Live parte senza puntamento.
|
|
69
69
|
liveBridgeTimeoutMs: 1500,
|
|
70
70
|
// PTY grace is finite and configurable for mobile handovers. The caps keep
|
|
@@ -82,7 +82,37 @@ function configJsonPath() {
|
|
|
82
82
|
return process.env.NEXUSCREW_CONFIG_FILE || path.join(os.homedir(), '.nexuscrew', 'config.json');
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
// Legge ~/.nexuscrew/config.json se esiste (nuovo
|
|
85
|
+
// Legge ~/.nexuscrew/config.json se esiste (nuovo). {} se assente/malformato.
|
|
86
|
+
// federation.events.enabled — the node-level kill switch for the event feed.
|
|
87
|
+
// STRICT boolean: only the literal false disables. A truthy string or any
|
|
88
|
+
// other type never enables anything beyond the default, and never disables by
|
|
89
|
+
// accident: "false" (string) is not a decision, it is noise, and the default
|
|
90
|
+
// holds. Without explicit grants nothing is enabled either way — the switch
|
|
91
|
+
// only stops what grants already allow.
|
|
92
|
+
function eventsEnabledFlag(raw, log = console.warn) {
|
|
93
|
+
// Fail-closed strictness: only the literal true/false is a decision. A
|
|
94
|
+
// missing value keeps the default (enabled); ANY other type — "false" as a
|
|
95
|
+
// string, 0, null, an object — is invalid config, is treated as DISABLED and
|
|
96
|
+
// is reported: silently enabling on garbage is how switches lie.
|
|
97
|
+
if (raw === undefined) return true;
|
|
98
|
+
if (raw === true) return true;
|
|
99
|
+
if (raw === false) return false;
|
|
100
|
+
try { log('config: federation.events.enabled must be a boolean; invalid value treated as disabled'); } catch (_) {}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function readFederationEventsEnabled(opts = {}) {
|
|
105
|
+
if (typeof opts.federationEventsEnabled === 'boolean') return opts.federationEventsEnabled;
|
|
106
|
+
let cfg = {};
|
|
107
|
+
try {
|
|
108
|
+
const raw = fs.readFileSync(opts.configJsonPath || configJsonPath(), 'utf8');
|
|
109
|
+
cfg = JSON.parse(raw) || {};
|
|
110
|
+
} catch (_) { cfg = {}; }
|
|
111
|
+
const fed = cfg && typeof cfg.federation === 'object' && cfg.federation ? cfg.federation : null;
|
|
112
|
+
const events = fed && typeof fed.events === 'object' && fed.events ? fed.events : null;
|
|
113
|
+
return eventsEnabledFlag(events ? events.enabled : undefined);
|
|
114
|
+
}
|
|
115
|
+
|
|
86
116
|
function readConfigJson() {
|
|
87
117
|
try {
|
|
88
118
|
const raw = fs.readFileSync(configJsonPath(), 'utf8');
|
|
@@ -134,7 +164,7 @@ function envOverrides() {
|
|
|
134
164
|
return e;
|
|
135
165
|
}
|
|
136
166
|
|
|
137
|
-
// Precedence: baseDefaults < config.json < env < opts.
|
|
167
|
+
// Precedence: baseDefaults < config.json < env < opts.
|
|
138
168
|
// Le credenziali authority vivono in due file 0600 nella dir
|
|
139
169
|
// dell'authority (default ~/.nexuscrew/identity-authority). Se esistono vengono
|
|
140
170
|
// caricate qui, senza MAI loggare i valori: builtin costruisce l'authority
|
|
@@ -195,4 +225,4 @@ function defaults() {
|
|
|
195
225
|
return { ...baseDefaults(), ...envOverrides() };
|
|
196
226
|
}
|
|
197
227
|
|
|
198
|
-
module.exports = { assertLoopback, baseDefaults, readConfigJson, loadConfig, defaults, LOOPBACK, configJsonPath };
|
|
228
|
+
module.exports = { assertLoopback, baseDefaults, readConfigJson, loadConfig, defaults, LOOPBACK, configJsonPath, eventsEnabledFlag, readFederationEventsEnabled };
|
package/lib/files/routes.js
CHANGED
|
@@ -55,7 +55,7 @@ function filesRoutes({
|
|
|
55
55
|
// paste=false (tasto allegati del composer): il client appende il path al
|
|
56
56
|
// testo del composer — niente scrittura PTY. Default = incolla (FilesPanel).
|
|
57
57
|
const wantPaste = String((req.body && req.body.paste) || '') !== 'false';
|
|
58
|
-
//
|
|
58
|
+
// : paste RICHIESTO ma non riuscito non e' un 200 con pasted:false —
|
|
59
59
|
// tre cause collassavano in quell'unica etichetta muta (non richiesto /
|
|
60
60
|
// tmux non ha ricevuto / testo respinto) e nessuno, risposta o UI, poteva
|
|
61
61
|
// accorgersene. Il vocabolario e' quello del ramo answer degli ask
|
package/lib/fleet/boot.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
//
|
|
2
|
+
// Fleet boot companion: bootCells(fleet, {log}) starts the boot:true cells
|
|
3
3
|
// boot:true di un fleet provider (builtin) chiamandone up(). Una cella gia' in
|
|
4
4
|
// esecuzione (up -> httpError 409 / "duplicate session") = SKIP non fatale;
|
|
5
5
|
// ogni altro errore viene raccolto in failed[] e NON ferma le altre celle.
|
|
6
|
-
//
|
|
6
|
+
// No side effects on import.
|
|
7
7
|
//
|
|
8
8
|
// Agnostico: usa solo il contratto fleet {available, status(), up()} — non legge
|
|
9
9
|
// internals delle definizioni. La lista delle celle boot:true arriva da status()
|