@mmmbuto/nexuscrew 0.9.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +91 -0
- package/README.md +22 -4
- package/frontend/dist/assets/index-uRh_hSop.js +93 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +14 -0
- package/lib/config.js +9 -0
- package/lib/fleet/builtin.js +57 -2
- package/lib/live-host/routes.js +9 -3
- package/lib/nodes/commands.js +15 -4
- package/lib/nodes/store.js +43 -0
- package/lib/nodes/tunnel.js +19 -0
- package/lib/proxy/federation.js +25 -1
- package/lib/proxy/panel-auth.js +63 -33
- package/lib/proxy/panel-proxy.js +41 -10
- package/lib/server.js +128 -6
- package/lib/settings/pairing-coordinator.js +32 -0
- package/lib/settings/public-peering-routes.js +13 -1
- package/package.json +1 -1
- package/skills/aidesktop/SKILL.md +201 -0
- package/skills/aidesktop/docker/Dockerfile +21 -0
- package/skills/aidesktop/docker/custom-cont-init.d/10-cdp-relay.sh +20 -0
- package/skills/aidesktop/docker/docker-compose.example.yml +75 -0
- package/skills/live/SKILL.md +90 -0
- package/skills/nexuscrew/SKILL.md +113 -0
- package/frontend/dist/assets/index-zjL6kZ7J.js +0 -93
- package/skills/alibaba-token-media/SKILL.md +0 -152
- package/skills/alibaba-token-media/agents/openai.yaml +0 -4
- package/skills/alibaba-token-media/references/api-contract.md +0 -97
- package/skills/alibaba-token-media/scripts/alibaba_token_media.py +0 -550
- package/skills/fill-forms/SKILL.md +0 -177
- package/skills/fill-forms/agents/openai.yaml +0 -4
- package/skills/fill-forms/references/overlay-technique.md +0 -99
- package/skills/fill-forms/requirements.txt +0 -4
- package/skills/fill-forms/scripts/dump_docx.py +0 -70
- package/skills/fill-forms/scripts/fill_docx.py +0 -207
- package/skills/fill-forms/scripts/fill_pdf.py +0 -424
- package/skills/fill-forms/scripts/inspect_pdf.py +0 -188
- package/skills/fill-forms/scripts/prepare_signature.py +0 -171
package/lib/server.js
CHANGED
|
@@ -532,6 +532,14 @@ function createServer(opts = {}) {
|
|
|
532
532
|
}
|
|
533
533
|
|
|
534
534
|
const app = express();
|
|
535
|
+
// CSP frame-ancestors (P0 sicurezza 2026-08-16, rimedio 4 del report): la
|
|
536
|
+
// pagina dell'app non deve poter essere incorporata in un iframe altrui —
|
|
537
|
+
// clickjacking sul control plane. Ortogonale alla porta pannello: quello
|
|
538
|
+
// difende CHI E' NEL FRAME, questo difende l'app dall'ESSERE il frame.
|
|
539
|
+
app.use((_req, res, next) => {
|
|
540
|
+
res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
|
|
541
|
+
next();
|
|
542
|
+
});
|
|
535
543
|
reverseSlotListeners = createReverseSlotListeners({
|
|
536
544
|
app, diagnostics, createServerImpl: cfg.reverseSlotCreateServerImpl,
|
|
537
545
|
// `routeUpgrade` e' hoisted: qui si cattura solo il riferimento, la
|
|
@@ -541,7 +549,19 @@ function createServer(opts = {}) {
|
|
|
541
549
|
const distDir = path.join(__dirname, '..', 'frontend', 'dist');
|
|
542
550
|
// no-store on everything (HTML+assets+API): this is a local, token-adjacent tool.
|
|
543
551
|
app.use((_req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
|
|
544
|
-
|
|
552
|
+
// Il panelServer nasce piu' avanti nella stessa funzione: il pairing legge
|
|
553
|
+
// la sua porta VIVA tramite questo ref, risolto a ogni join (pattern dello
|
|
554
|
+
// stesso tipo dell'hoisting di routeUpgrade qui sopra).
|
|
555
|
+
const panelListenerRef = { server: null };
|
|
556
|
+
app.use('/pair', publicPeeringRoutes({
|
|
557
|
+
cfg, nodesPath,
|
|
558
|
+
panelPort: () => {
|
|
559
|
+
const srv = panelListenerRef.server;
|
|
560
|
+
if (!srv || !srv.listening) return null;
|
|
561
|
+
const addr = srv.address();
|
|
562
|
+
return addr && Number.isInteger(addr.port) && addr.port > 0 ? addr.port : null;
|
|
563
|
+
},
|
|
564
|
+
}));
|
|
545
565
|
// VL micro-device nodes use a separate, scoped credential surface. These
|
|
546
566
|
// routes never accept the NexusCrew UI bearer and expose only pair, poll and
|
|
547
567
|
// self-unpair. Operator management remains behind /api + federation ACL.
|
|
@@ -608,6 +628,46 @@ function createServer(opts = {}) {
|
|
|
608
628
|
cell: entry.cell, reason: entry.reason, state: entry.outcome,
|
|
609
629
|
}),
|
|
610
630
|
}));
|
|
631
|
+
// Porta pannello dedicata (P0 sicurezza 2026-08-16): un iframe
|
|
632
|
+
// same-origin senza sandbox legge il localStorage del padre — il JS di un
|
|
633
|
+
// pannello ostile puo' prendersi il token e agire come l'operatore. La
|
|
634
|
+
// porta fa parte dell'origin: spostando SOLO il consumo (ticket in query,
|
|
635
|
+
// cookie di visione) su una porta sua, il browser vede un'origin diversa e
|
|
636
|
+
// non c'e' piu' nulla da rubare. Nessuna API di controllo qui, nessun
|
|
637
|
+
// Bearer verificato: l'emissione del ticket resta sopra, dietro
|
|
638
|
+
// requireToken — e' l'unica operazione che richiede provare CHI chiede.
|
|
639
|
+
const panelApp = express();
|
|
640
|
+
panelApp.use('/panel', panelAuth.consumeMiddleware, createPanelProxy({
|
|
641
|
+
resolveCellPanel,
|
|
642
|
+
log: (entry) => diagnostics.record('panel-proxy', 'info', entry.outcome, {
|
|
643
|
+
cell: entry.cell, reason: entry.reason, state: entry.outcome,
|
|
644
|
+
}),
|
|
645
|
+
}));
|
|
646
|
+
// Qualunque altra cosa (niente /api, niente SPA): 404 secco. Questa porta
|
|
647
|
+
// non ha nient'altro da offrire, e non deve mai imparare a offrirlo per
|
|
648
|
+
// errore — un catch-all che rispondesse con l'app sarebbe esattamente il
|
|
649
|
+
// difetto che questa porta esiste per chiudere.
|
|
650
|
+
panelApp.use((_req, res) => res.status(404).json({ error: 'not found' }));
|
|
651
|
+
const panelServer = http.createServer(panelApp);
|
|
652
|
+
const panelServerV6 = http.createServer(panelApp);
|
|
653
|
+
panelListenerRef.server = panelServer;
|
|
654
|
+
function routePanelUpgrade(req, socket, head) {
|
|
655
|
+
let pathname;
|
|
656
|
+
try { pathname = new URL(req.url, 'http://127.0.0.1').pathname; }
|
|
657
|
+
catch (_) { try { socket.destroy(); } catch (_e) {} return; }
|
|
658
|
+
if (!pathname.startsWith('/panel/')) { try { socket.destroy(); } catch (_) {} return; }
|
|
659
|
+
handlePanelUpgrade({
|
|
660
|
+
req, socket, head, resolveCellPanel,
|
|
661
|
+
verifyToken: (t) => verify(tokenHolder.value, t),
|
|
662
|
+
authorize: panelAuth.authorizeUpgrade,
|
|
663
|
+
log: (entry) => diagnostics.record('panel-proxy', 'info', entry.outcome, {
|
|
664
|
+
cell: entry.cell, reason: entry.reason, state: entry.outcome,
|
|
665
|
+
}),
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
panelServer.on('upgrade', routePanelUpgrade);
|
|
669
|
+
panelServerV6.on('upgrade', routePanelUpgrade);
|
|
670
|
+
panelServer.on('close', () => { try { panelServerV6.close(); } catch (_) {} });
|
|
611
671
|
// L'ingresso ai pannelli REMOTI ha la stessa malattia dell'iframe locale: la
|
|
612
672
|
// via federata /api/route/<nodi>/_/panel/... sta per finire sotto requireToken
|
|
613
673
|
// e una navigazione del browser non porta header. QUI il ticket non si può
|
|
@@ -734,11 +794,26 @@ function createServer(opts = {}) {
|
|
|
734
794
|
res.json(await setSessionVisibility(cfg.tmuxBin, name, req.body?.technical === true));
|
|
735
795
|
} catch (e) { res.status(e.status || 500).json({ error: String(e.message || e) }); }
|
|
736
796
|
});
|
|
797
|
+
// Mappa nome-nodo -> porta pannello inoltrata sul NOSTRO loopback (coppia
|
|
798
|
+
// panel negoziata nel pairing). E' la fonte con cui il frontend mette il
|
|
799
|
+
// frame di una cella REMOTA su un'origin diversa: la lettura e' questa, non
|
|
800
|
+
// /api/nodes, perche' qui non c'e' nessun probe di health federato — la
|
|
801
|
+
// mappa costa quanto la lettura dello store. Un nodo assente dalla mappa e
|
|
802
|
+
// un peer accoppiato prima di questa negoziazione: via storica.
|
|
803
|
+
const nodePanelPorts = () => {
|
|
804
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
805
|
+
const out = {};
|
|
806
|
+
if (st) for (const n of st.nodes) {
|
|
807
|
+
if (n.panelLocalPort !== undefined) out[n.name] = n.panelLocalPort;
|
|
808
|
+
}
|
|
809
|
+
return out;
|
|
810
|
+
};
|
|
737
811
|
api.get('/config', (_req, res) => res.json({
|
|
738
812
|
readonlyDefault: cfg.readonlyDefault, version: VERSION, uiVersion: uiBuildVersion(distDir),
|
|
739
|
-
bind: cfg.bind, port: cfg.port,
|
|
813
|
+
bind: cfg.bind, port: cfg.port, panelPort: cfg.panelPort,
|
|
740
814
|
protectSharedTmuxServer: cfg.protectSharedTmuxServer !== false,
|
|
741
815
|
instanceId: (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
|
|
816
|
+
nodePanelPorts: nodePanelPorts(),
|
|
742
817
|
presets: ['shell', 'claude', 'codex-vl', 'pi', ...Object.keys(cfg.sessionPresets || {})],
|
|
743
818
|
}));
|
|
744
819
|
api.use('/files', filesRoutes({
|
|
@@ -859,8 +934,9 @@ function createServer(opts = {}) {
|
|
|
859
934
|
readonly: proxyReadonly,
|
|
860
935
|
}));
|
|
861
936
|
// Cella ospite Live (contratto rev6 §2): hostCell unico per nodo con CAS. Lo store
|
|
862
|
-
// vive accanto al token (stessa dir isolata nei test);
|
|
863
|
-
//
|
|
937
|
+
// vive accanto al token (stessa dir isolata nei test); il proxy nega /api/live-host
|
|
938
|
+
// via /node (local-only), ma la via /api/route la instrada (0.9.1) dietro un
|
|
939
|
+
// permesso per-peer negato di default (liveHostAccess, v. lib/proxy/federation.js).
|
|
864
940
|
// Fetta 3: il ponte risolve il puntamento all'avvio di una Live (POST /bridge
|
|
865
941
|
// nello stesso gruppo, stessa auth, stesso local-only). Isolabile da cfg
|
|
866
942
|
// (rev5 MC0): spento non crea connessioni e la Live resta standard.
|
|
@@ -1023,6 +1099,10 @@ function createServer(opts = {}) {
|
|
|
1023
1099
|
watcher.close(); previews.close(); eventsHub.closeAll(); updater.close();
|
|
1024
1100
|
for (const timer of reverseWatchers.values()) clearInterval(timer);
|
|
1025
1101
|
reverseWatchers.clear(); rotatableReverse.clear(); void reverseSlotListeners?.closeAll();
|
|
1102
|
+
// Il pannello non sopravvive al control plane: senza requireToken sopra,
|
|
1103
|
+
// non c'e' un secondo lifecycle da tenere in vita da soli. panelServerV6
|
|
1104
|
+
// si chiude a cascata dal listener 'close' gia' registrato su panelServer.
|
|
1105
|
+
try { panelServer.close(); } catch (_) {}
|
|
1026
1106
|
});
|
|
1027
1107
|
// noServer: gestiamo l'upgrade a mano per instradare /ws (locale) e /node/*
|
|
1028
1108
|
// (proxy). Il WS locale resta identico; il proxy WS applica gli STESSI check
|
|
@@ -1143,11 +1223,14 @@ function createServer(opts = {}) {
|
|
|
1143
1223
|
fleetP.then((fleet) => (typeof fleet.close === 'function' ? fleet.close() : null)).catch(() => {});
|
|
1144
1224
|
});
|
|
1145
1225
|
|
|
1146
|
-
return {
|
|
1226
|
+
return {
|
|
1227
|
+
app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP, updater, diagnostics,
|
|
1228
|
+
panelApp, panelServer, panelServerV6,
|
|
1229
|
+
};
|
|
1147
1230
|
}
|
|
1148
1231
|
|
|
1149
1232
|
function start(opts = {}) {
|
|
1150
|
-
const { server, cfg } = createServer({ ...opts, cellLeaseEnabled: true });
|
|
1233
|
+
const { server, cfg, panelServer, panelServerV6 } = createServer({ ...opts, cellLeaseEnabled: true });
|
|
1151
1234
|
const log = opts.log || console.log;
|
|
1152
1235
|
const requestedPort = cfg.port;
|
|
1153
1236
|
const nodesPath = opts.nodesPath || nodesStore.defaultNodesPath(cfg.home || os.homedir());
|
|
@@ -1156,12 +1239,51 @@ function start(opts = {}) {
|
|
|
1156
1239
|
if (typeof opts.onListenError === 'function') return opts.onListenError(error);
|
|
1157
1240
|
throw error;
|
|
1158
1241
|
};
|
|
1242
|
+
// Porta pannello (P0 sicurezza 2026-08-16): scelta libera con lo STESSO
|
|
1243
|
+
// meccanismo di fallback della principale, ma piu' leggero — nessun peer
|
|
1244
|
+
// dipende ancora da un valore stabile persistito (a differenza di cfg.port,
|
|
1245
|
+
// che i tunnel pairati referenziano), quindi niente scrittura su
|
|
1246
|
+
// config.json: il frontend la scopre da /api/config a ogni avvio. Un
|
|
1247
|
+
// fallimento qui non deve MAI abbattere il control plane: e' un log, non
|
|
1248
|
+
// un throw — il pannello resta un'estensione, non un requisito di avvio.
|
|
1249
|
+
const startPanelV6 = () => {
|
|
1250
|
+
panelServerV6.once('error', (error) => {
|
|
1251
|
+
log(`panel: IPv6 loopback listener not available (${(error && error.code) || error}); IPv4 still serves the panel.`);
|
|
1252
|
+
});
|
|
1253
|
+
panelServerV6.listen(cfg.panelPort, '::1');
|
|
1254
|
+
};
|
|
1255
|
+
const startPanelServer = () => {
|
|
1256
|
+
const requestedPanelPort = cfg.panelPort;
|
|
1257
|
+
const tryPanelFallback = (candidate, remaining) => {
|
|
1258
|
+
panelServer.once('error', (error) => {
|
|
1259
|
+
if (error && error.code === 'EADDRINUSE' && remaining > 1) {
|
|
1260
|
+
tryPanelFallback(candidate >= 65535 ? 41821 : candidate + 1, remaining - 1);
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
log(`panel: preferred port ${requestedPanelPort} busy and no fallback available (${(error && error.code) || error}); panel disabled this run.`);
|
|
1264
|
+
});
|
|
1265
|
+
panelServer.listen(candidate, cfg.bind, () => {
|
|
1266
|
+
cfg.panelPort = panelServer.address().port;
|
|
1267
|
+
log(`panel port ${requestedPanelPort} busy; selected ${cfg.panelPort}`);
|
|
1268
|
+
startPanelV6();
|
|
1269
|
+
});
|
|
1270
|
+
};
|
|
1271
|
+
panelServer.once('error', (error) => {
|
|
1272
|
+
if (error && error.code === 'EADDRINUSE') { tryPanelFallback(requestedPanelPort >= 65535 ? 41821 : requestedPanelPort + 1, 200); return; }
|
|
1273
|
+
log(`panel: failed to start (${(error && error.code) || error}); panel disabled this run.`);
|
|
1274
|
+
});
|
|
1275
|
+
panelServer.listen(requestedPanelPort, cfg.bind, () => {
|
|
1276
|
+
cfg.panelPort = panelServer.address().port;
|
|
1277
|
+
startPanelV6();
|
|
1278
|
+
});
|
|
1279
|
+
};
|
|
1159
1280
|
const onListening = () => {
|
|
1160
1281
|
cfg.port = server.address().port;
|
|
1161
1282
|
// Il token NON si stampa allo startup: finirebbe nei log del servizio
|
|
1162
1283
|
// (journalctl/logfile). L'apertura autenticata passa da `nexuscrew show`.
|
|
1163
1284
|
log(`nexuscrew on http://${cfg.bind}:${cfg.port} (open with \`nexuscrew show\`)`);
|
|
1164
1285
|
log('localhost-only — reach it via a user-controlled SSH or VPN channel.');
|
|
1286
|
+
startPanelServer();
|
|
1165
1287
|
};
|
|
1166
1288
|
const persistFallback = () => {
|
|
1167
1289
|
const selected = server.address().port;
|
|
@@ -87,6 +87,7 @@ function createPairHandler(deps) {
|
|
|
87
87
|
|
|
88
88
|
let provisionalPort = null;
|
|
89
89
|
let portReservation = null;
|
|
90
|
+
let panelPortReservation = null;
|
|
90
91
|
let rollbackCredential = null;
|
|
91
92
|
let created = false;
|
|
92
93
|
let rolledBack = false;
|
|
@@ -98,6 +99,10 @@ function createPairHandler(deps) {
|
|
|
98
99
|
try { await portReservation.release(); } catch (_) { /* best-effort */ }
|
|
99
100
|
portReservation = null;
|
|
100
101
|
}
|
|
102
|
+
if (panelPortReservation) {
|
|
103
|
+
try { await panelPortReservation.release(); } catch (_) { /* best-effort */ }
|
|
104
|
+
panelPortReservation = null;
|
|
105
|
+
}
|
|
101
106
|
if (rollbackCredential && provisionalPort) {
|
|
102
107
|
try {
|
|
103
108
|
await pairFetch(`http://127.0.0.1:${provisionalPort}/pair/cancel`, {
|
|
@@ -296,6 +301,26 @@ function createPairHandler(deps) {
|
|
|
296
301
|
// --- tunnel-final: connessione privata, solo -L -------------------------
|
|
297
302
|
// reversePort resta negoziata per un futuro Share opt-in, ma il builder
|
|
298
303
|
// non emette -R finche' shared non diventa true.
|
|
304
|
+
//
|
|
305
|
+
// Porta pannello del peer (P0 stessa-origin, meta' remota): se il peer
|
|
306
|
+
// l'ha annunciata nel join, si riserva una controparte locale e la
|
|
307
|
+
// coppia entra nel record — il supervisor finale inoltrera' entrambe.
|
|
308
|
+
// Nessuna porta disponibile NON fa fallire il pairing: il pannello e
|
|
309
|
+
// un'estensione, il legame viene prima. Stesso patto di publicKey: un
|
|
310
|
+
// peer piu' vecchio non annuncia e si accoppia esattamente come prima.
|
|
311
|
+
const peerPanelPort = nodesStore.isPort(joined.panelPort) ? joined.panelPort : null;
|
|
312
|
+
let panelLocalPort = null;
|
|
313
|
+
if (peerPanelPort) {
|
|
314
|
+
try {
|
|
315
|
+
panelPortReservation = await nodesCmds.reserveLocalPort(
|
|
316
|
+
nodesStore.loadStoreStrict(nodesPath),
|
|
317
|
+
{ createServerImpl: seams.createPanelPortServer || seams.createPortServer },
|
|
318
|
+
);
|
|
319
|
+
panelLocalPort = panelPortReservation.port;
|
|
320
|
+
} catch (_) {
|
|
321
|
+
panelPortReservation = null; panelLocalPort = null; // fail-open dichiarato: via storica
|
|
322
|
+
}
|
|
323
|
+
}
|
|
299
324
|
st = nodesStore.loadStoreStrict(nodesPath);
|
|
300
325
|
if (joinedPool && st.schemaVersion < nodesStore.SCHEMA_VERSION) {
|
|
301
326
|
// This device is a client of the hub-owned pool. It persists the
|
|
@@ -313,8 +338,15 @@ function createPairHandler(deps) {
|
|
|
313
338
|
: {}),
|
|
314
339
|
...(joinedPool ? { reversePool: joinedPool } : {}),
|
|
315
340
|
...(joinedRoles ? { roles: joinedRoles, rolesKnown: true } : {}),
|
|
341
|
+
...(panelLocalPort ? { panelLocalPort, panelRemotePort: peerPanelPort } : {}),
|
|
316
342
|
});
|
|
317
343
|
nodesStore.atomicWriteStore(nodesPath, st);
|
|
344
|
+
// La riserva della porta pannello ha protetto la scelta fino alla
|
|
345
|
+
// scrittura: come la porta di controllo, si rilascia prima dello spawn.
|
|
346
|
+
if (panelPortReservation) {
|
|
347
|
+
try { await panelPortReservation.release(); } catch (_) { /* best-effort */ }
|
|
348
|
+
panelPortReservation = null;
|
|
349
|
+
}
|
|
318
350
|
nodesTunnel.stopTunnel({ home, name: b.name });
|
|
319
351
|
const finalStart = nodesTunnel.startForward({
|
|
320
352
|
home, node: nodesStore.getNode(st, b.name), localAppPort: runtimePort(),
|
|
@@ -28,6 +28,12 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
28
28
|
const invitesPath = cfg.invitesPath || peering.defaultInvitesPath(home);
|
|
29
29
|
const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
|
|
30
30
|
const reversePoolLedgerPath = deps.reversePoolLedgerPath || cfg.reversePoolLedgerPath || reversePool.defaultLedgerPath(home);
|
|
31
|
+
// Porta pannello di QUESTA installazione, letta al momento del join: non
|
|
32
|
+
// cfg.panelPort (che resta al valore richiesto anche quando il listener non
|
|
33
|
+
// e' mai partito), ma la porta su cui il panelServer ascolta ADESSO. Null
|
|
34
|
+
// quando il pannello e' spento per questo run: meglio il silenzio che far
|
|
35
|
+
// inoltrare al client una porta che nessuno ascolta.
|
|
36
|
+
const panelPortLive = typeof deps.panelPort === 'function' ? deps.panelPort : () => null;
|
|
31
37
|
// La pubblica di questa installazione, per il passo 1 del modello di
|
|
32
38
|
// autorita'. Si legge PIGRAMENTE e non all'avvio del router: un errore sul
|
|
33
39
|
// file di chiave non deve impedire di montare le route di pairing — il passo
|
|
@@ -170,13 +176,19 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
170
176
|
return res.status(410).json({ error: 'invito scaduto o gia usato' });
|
|
171
177
|
}
|
|
172
178
|
const mia = localPublicKey();
|
|
179
|
+
const pannello = panelPortLive();
|
|
173
180
|
res.json({ paired: true, instanceId: poolStore.nodeId, reversePort,
|
|
174
181
|
reversePool: { base: assignedPool.base, slots: assignedPool.slots.map((slot) => slot.port) },
|
|
175
182
|
credential, roles: readRoles(configPath),
|
|
176
183
|
// Lo scambio e' simmetrico e avviene QUI, dentro l'atto che consuma
|
|
177
184
|
// l'invito monouso: e' il solo momento in cui l'operatore ha deciso,
|
|
178
185
|
// su entrambe le macchine, che questi due nodi si conoscono.
|
|
179
|
-
...(mia ? { publicKey: mia } : {})
|
|
186
|
+
...(mia ? { publicKey: mia } : {}),
|
|
187
|
+
// Stesso patto della pubblica: la porta pannello si annuncia nel
|
|
188
|
+
// join, e un peer piu' vecchio che non la capisce si accoppia come
|
|
189
|
+
// sempre. Il campo assente non e' un errore, e' una versione (o un
|
|
190
|
+
// pannello spento per questo run).
|
|
191
|
+
...(pannello ? { panelPort: pannello } : {}) });
|
|
180
192
|
} catch (e) {
|
|
181
193
|
if (credential) try { peering.consumePending({ pendingPath, credential, now }); } catch (_) {}
|
|
182
194
|
res.status(e.status || 500).json({ error: String(e.message || e), ...(e.code ? { code: e.code } : {}) });
|
package/package.json
CHANGED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: aidesktop
|
|
3
|
+
description: Use when a user wants to give a cell a web panel via panelUrl, asks why a panel URL or panel request was rejected, wants to build the AI Desktop container recipe for an isolated browser and desktop, or needs to connect a Playwright MCP client to that desktop's Chromium through its CDP relay.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# AI Desktop
|
|
7
|
+
|
|
8
|
+
A desktop you can watch and a browser you can drive are two faces of the same
|
|
9
|
+
container: watching it is the **panel**, driving it is the **CDP relay** a
|
|
10
|
+
Playwright MCP client attaches to. This skill covers both together, plus the
|
|
11
|
+
recipe that builds the container itself.
|
|
12
|
+
|
|
13
|
+
## The panel
|
|
14
|
+
|
|
15
|
+
`panelUrl` is a **property of the cell** (or its engine, as a default for
|
|
16
|
+
every cell using it) — a sibling of `engine` and `cwd`, not a command and not
|
|
17
|
+
something the cell runs. The app's cell/engine editor has a `panelUrl` field
|
|
18
|
+
for this; do not write the URL into the command field, which makes the cell
|
|
19
|
+
try to execute it as a program and fails to start, while no panel button
|
|
20
|
+
appears either, since `panelUrl` was never actually set.
|
|
21
|
+
|
|
22
|
+
The value must be an `http:`/`https:` URL pointing at **loopback** —
|
|
23
|
+
`127.0.0.1`, `[::1]`, or `localhost`. Anything else is rejected when the
|
|
24
|
+
definition is read, and the cell keeps working, just without a panel button.
|
|
25
|
+
**A container's own address is not loopback** — publish its port to
|
|
26
|
+
`127.0.0.1` on the host that runs the node, and point `panelUrl` there. This
|
|
27
|
+
is not a nitpick: the forwarder resolves *which cell*, never *where to
|
|
28
|
+
connect*, so allowing a non-loopback destination would turn it into a way to
|
|
29
|
+
reach any address the node can reach. See
|
|
30
|
+
[The cell panel](../../docs/CELL_PANEL.md) for the full explanation and the
|
|
31
|
+
worked example.
|
|
32
|
+
|
|
33
|
+
### Origin separation (0.9.1)
|
|
34
|
+
|
|
35
|
+
The panel used to be served from the same origin as the control plane, which
|
|
36
|
+
meant a compromised or hostile page mounted in the panel iframe could, in
|
|
37
|
+
principle, read the operator's token straight out of the app's own
|
|
38
|
+
`localStorage`. As of 0.9.1, the panel is served from a **second loopback
|
|
39
|
+
port** — the browser's Same-Origin Policy treats a different port as a
|
|
40
|
+
different origin, so a document in that frame has nothing of the control
|
|
41
|
+
plane's to read or write. The app also sends
|
|
42
|
+
`Content-Security-Policy: frame-ancestors` on its own page, which is a
|
|
43
|
+
separate, orthogonal protection: it stops the app itself from being embedded
|
|
44
|
+
as someone else's iframe.
|
|
45
|
+
|
|
46
|
+
This covers **remote** cells too, not just local ones. Two paired nodes
|
|
47
|
+
negotiate a second port pair the same way they already negotiate the control
|
|
48
|
+
plane's own tunnel: when the hub side has a panel server running, it
|
|
49
|
+
announces its panel port at join time, the joining side reserves a local port
|
|
50
|
+
for it, and the supervisor forwards both destinations over the same SSH
|
|
51
|
+
connection — one `-L` for control, one for the panel, never sharing a port.
|
|
52
|
+
`GET /api/config` exposes the result as `nodePanelPorts` (node name → the
|
|
53
|
+
locally forwarded port), and the app resolves the right one per cell from its
|
|
54
|
+
route before ever opening the frame.
|
|
55
|
+
|
|
56
|
+
A node with no port pair on record — paired **before** this existed, or
|
|
57
|
+
paired since but with the local port reservation having failed at the time
|
|
58
|
+
(pairing still succeeds; the port pair is treated as an extension, never a
|
|
59
|
+
reason to fail the bond) — is not treated as an error either way: its cells
|
|
60
|
+
fall back to the old federated path (same origin as the control plane)
|
|
61
|
+
exactly as before, and a cell never borrows another node's port to paper over
|
|
62
|
+
the gap — the absence stays visible as the old behavior, not a silent, wrong
|
|
63
|
+
origin. The pair is only established at pairing time, not by reconnecting an
|
|
64
|
+
existing tunnel; re-pair the node to pick one up.
|
|
65
|
+
|
|
66
|
+
None of this changes how a request actually gets into the frame: an
|
|
67
|
+
authenticated call asks for a one-use ticket bound to one cell, the frame's
|
|
68
|
+
first request spends it and receives a viewing cookie scoped to that cell's
|
|
69
|
+
panel path, and the app's own token never reaches the frame. See
|
|
70
|
+
[How the browser gets in](../../docs/CELL_PANEL.md#how-the-browser-gets-in)
|
|
71
|
+
for the full mechanism — it is unchanged by the port move, only relocated.
|
|
72
|
+
|
|
73
|
+
### Panels on another node
|
|
74
|
+
|
|
75
|
+
Opening a panel that lives on a paired node needs that node's permission,
|
|
76
|
+
**granted there, never by the requester**:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
nexuscrew nodes panel <node> on
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Denied by default. Without it, the rejection names itself —
|
|
83
|
+
`panel-not-granted` — instead of leaving a blank rectangle to debug.
|
|
84
|
+
|
|
85
|
+
## The Docker recipe
|
|
86
|
+
|
|
87
|
+
**This repository ships the recipe, not an image.** The base,
|
|
88
|
+
`lscr.io/linuxserver/webtop`, is GPL-3.0; a `FROM` line pointing at its public
|
|
89
|
+
source is a recipe, not a distributed derivative, and the only thing this
|
|
90
|
+
project actually owns is the two added lines below and their reasons. It also
|
|
91
|
+
means no registry to host, nothing to keep patched on your behalf, and a
|
|
92
|
+
recipe you can read before you run it.
|
|
93
|
+
|
|
94
|
+
Build it yourself from [`docker/`](docker/):
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
cd <skill-dir>/docker
|
|
98
|
+
cp docker-compose.example.yml docker-compose.yml
|
|
99
|
+
# create ./.gui_password yourself — see the comments in the compose file
|
|
100
|
+
docker compose up -d --build
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
First start creates an **empty** browser profile: no logins, no history,
|
|
104
|
+
nothing carried over. The example compose ships no branding and no
|
|
105
|
+
pre-populated `/config` — you get a bare webtop desktop plus the two
|
|
106
|
+
additions below.
|
|
107
|
+
|
|
108
|
+
### The two additions, and why each exists
|
|
109
|
+
|
|
110
|
+
- **`socat`** — modern Chrome ignores `--remote-debugging-address` and binds
|
|
111
|
+
its DevTools/CDP port to `127.0.0.1` *inside* the container, so a Docker
|
|
112
|
+
port mapping alone never reaches it. `socat` relays
|
|
113
|
+
`0.0.0.0:9223 -> 127.0.0.1:9222` inside the container's own network
|
|
114
|
+
namespace; the published host port still only opens on `127.0.0.1`, so the
|
|
115
|
+
loopback-only rule is unchanged — nothing new is exposed, only bridged.
|
|
116
|
+
- **the init script** also clears `Singleton{Lock,Cookie,Socket}` files an
|
|
117
|
+
unclean shutdown leaves behind, which otherwise stop Chromium from starting
|
|
118
|
+
again with no visible error.
|
|
119
|
+
|
|
120
|
+
Notably absent: anything that tries to sandbox the browser. That is the next
|
|
121
|
+
section, and it is the part worth reading before you adapt this recipe.
|
|
122
|
+
|
|
123
|
+
### Where the boundary is
|
|
124
|
+
|
|
125
|
+
**The browser in this container runs with `--no-sandbox`, and this recipe
|
|
126
|
+
keeps it that way.** That is a trade, made deliberately, and you should
|
|
127
|
+
understand it before deciding whether it fits your situation.
|
|
128
|
+
|
|
129
|
+
The base image launches Chromium through `/usr/local/bin/wrapped-chromium`,
|
|
130
|
+
which hardcodes the flag:
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
${BIN} --password-store=basic --no-sandbox --test-type "$@"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Installing Chromium's setuid helper does not change this, and overriding the
|
|
137
|
+
wrapper to drop the flag does not produce a sandbox either. Measured inside a
|
|
138
|
+
running container, launching the browser without it aborts:
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
The setuid sandbox is not running as root. Common causes:
|
|
142
|
+
* A parent process set prctl(PR_SET_NO_NEW_PRIVS, ...)
|
|
143
|
+
Failed to move to new namespace: PID namespaces supported,
|
|
144
|
+
Network namespace supported, but failed: errno = Operation not permitted
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Both routes are closed, and for opposite reasons. The **setuid** sandbox needs
|
|
148
|
+
its helper to elevate, which `no-new-privileges` exists to prevent. The
|
|
149
|
+
**namespace** sandbox needs unprivileged user namespaces, which the container
|
|
150
|
+
denies. Opening either one means handing the container `CAP_SYS_ADMIN` or
|
|
151
|
+
`seccomp=unconfined`.
|
|
152
|
+
|
|
153
|
+
That is the trade: **you would weaken the boundary that is actually holding in
|
|
154
|
+
order to build one inside it.** For a desktop reachable only over loopback,
|
|
155
|
+
the container is the stronger of the two — so the recipe keeps the container
|
|
156
|
+
hard and accepts the browser soft. `no-new-privileges` stays on.
|
|
157
|
+
|
|
158
|
+
**What this costs you, stated plainly:** a renderer exploit lands in the
|
|
159
|
+
container. Everything the browser can reach is in the blast radius — its
|
|
160
|
+
profile, its logged-in sessions, and anything you mount. Mount as little as
|
|
161
|
+
possible, keep the published ports on loopback, and do not treat this desktop
|
|
162
|
+
as isolation between *sites*: it is isolation between the desktop and the
|
|
163
|
+
host.
|
|
164
|
+
|
|
165
|
+
**If your situation differs** — an untrusted desktop, or a host where the
|
|
166
|
+
container boundary matters less than the browser one — the inverse trade is
|
|
167
|
+
legitimate: grant `seccomp=unconfined`, override the wrapper, and verify you
|
|
168
|
+
actually got a sandbox rather than assuming it. Check the running process, not
|
|
169
|
+
the launch log:
|
|
170
|
+
|
|
171
|
+
- open `chrome://sandbox` inside the desktop and read what it reports, or
|
|
172
|
+
- confirm the zygote no longer carries `--no-sandbox` (`ps -eo args | grep zygote`).
|
|
173
|
+
|
|
174
|
+
Until one of those confirms it, assume the browser is unsandboxed — which,
|
|
175
|
+
with this recipe as shipped, it is.
|
|
176
|
+
### Commanding the browser
|
|
177
|
+
|
|
178
|
+
The relay's published port, `127.0.0.1:9222` on the host, is the CDP
|
|
179
|
+
endpoint a Playwright MCP client attaches to — for example:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
playwright-mcp --cdp-endpoint http://127.0.0.1:9222
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Register that command as its own MCP server in the AI client's configuration;
|
|
186
|
+
NexusCrew does not bundle browser automation itself, it only gives the
|
|
187
|
+
container a loopback CDP port to attach one to. It is the same Chromium the
|
|
188
|
+
panel shows over the desktop, so it carries the same logins — treat driving
|
|
189
|
+
it with the same care as watching it.
|
|
190
|
+
|
|
191
|
+
## Dependencies
|
|
192
|
+
|
|
193
|
+
**Bundled:** the `docker/` recipe (`Dockerfile`, `docker-compose.example.yml`,
|
|
194
|
+
`custom-cont-init.d/10-cdp-relay.sh`) ships with this skill.
|
|
195
|
+
|
|
196
|
+
**External (you must provide):**
|
|
197
|
+
|
|
198
|
+
| Need | Install | Probe / failure mode |
|
|
199
|
+
|---|---|---|
|
|
200
|
+
| Docker with Compose v2 | your platform's Docker install | `docker compose version` fails → nothing in `docker/` builds or runs |
|
|
201
|
+
| A Playwright-capable MCP client, to drive the browser | install separately, point it at the CDP port | if no such client is registered, no `browser_*`-style tools exist in that session — the desktop and its panel still work without it |
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# AI Desktop = a webtop base plus the one thing the base is missing: a way to
|
|
2
|
+
# reach Chrome DevTools Protocol from outside the container.
|
|
3
|
+
#
|
|
4
|
+
# WHAT THIS IMAGE DELIBERATELY DOES NOT DO
|
|
5
|
+
# It does not try to give the browser its own sandbox. That is a decision, not
|
|
6
|
+
# an omission, and it was made after measuring — see ../SKILL.md, "Where the
|
|
7
|
+
# boundary is". In short: inside this container the browser's own sandbox
|
|
8
|
+
# cannot start (setuid helper blocked by no-new-privileges, unprivileged user
|
|
9
|
+
# namespaces denied), and the changes that would let it start are exactly the
|
|
10
|
+
# ones that weaken the container — the boundary that actually holds here.
|
|
11
|
+
FROM lscr.io/linuxserver/webtop:debian-xfce
|
|
12
|
+
|
|
13
|
+
RUN apt-get update \
|
|
14
|
+
&& apt-get install -y --no-install-recommends socat \
|
|
15
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
16
|
+
|
|
17
|
+
# socat: modern Chrome IGNORES --remote-debugging-address and binds CDP to
|
|
18
|
+
# 127.0.0.1 inside the container, so a Docker port mapping alone never
|
|
19
|
+
# reaches it. The relay 0.0.0.0:9223 -> 127.0.0.1:9222 is the only bridge,
|
|
20
|
+
# and it still only leaves the container through a port you publish to the
|
|
21
|
+
# host's own loopback (see docker-compose.example.yml).
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Runs at every container start, as root, BEFORE the desktop session starts.
|
|
3
|
+
#
|
|
4
|
+
# 1) Orphaned profile locks: if the container dies while Chromium is still
|
|
5
|
+
# running, Singleton{Lock,Cookie,Socket} files are left behind pointing at
|
|
6
|
+
# a dead pid, and Chromium then REFUSES to start on the next boot.
|
|
7
|
+
# Observed symptom: autostart present, zero processes, no visible error.
|
|
8
|
+
# It is safe to remove them here: the desktop session has not started yet,
|
|
9
|
+
# so no browser can possibly be alive.
|
|
10
|
+
rm -f /config/browser-profile/Singleton* 2>/dev/null || true
|
|
11
|
+
|
|
12
|
+
# 2) CDP relay: modern Chrome ignores --remote-debugging-address and binds
|
|
13
|
+
# DevTools to 127.0.0.1 inside the container, unreachable through a Docker
|
|
14
|
+
# port mapping. socat bridges it. What leaves the container through this
|
|
15
|
+
# port still only reaches the host's own loopback — see the port mapping
|
|
16
|
+
# in docker-compose.example.yml.
|
|
17
|
+
pkill -f "TCP-LISTEN:9223" 2>/dev/null || true
|
|
18
|
+
nohup socat TCP-LISTEN:9223,fork,reuseaddr TCP:127.0.0.1:9222 >/dev/null 2>&1 &
|
|
19
|
+
|
|
20
|
+
echo "[cdp-relay] stale profile locks cleared; socat 9223 -> 9222 started"
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Example compose for the AI Desktop recipe. Copy this next to the Dockerfile
|
|
2
|
+
# and custom-cont-init.d/, adjust the marked values, then:
|
|
3
|
+
#
|
|
4
|
+
# docker compose up -d --build
|
|
5
|
+
#
|
|
6
|
+
# First start creates an EMPTY browser profile under ./config — no logins,
|
|
7
|
+
# no history, nothing carried over from anywhere else. Expect a self-signed
|
|
8
|
+
# certificate warning on the HTTPS port on first connect: normal for a
|
|
9
|
+
# loopback-only service, and safe to trust locally.
|
|
10
|
+
services:
|
|
11
|
+
ai-desktop:
|
|
12
|
+
build: .
|
|
13
|
+
image: ai-desktop:local
|
|
14
|
+
container_name: ai-desktop
|
|
15
|
+
restart: unless-stopped
|
|
16
|
+
|
|
17
|
+
environment:
|
|
18
|
+
- PUID=1000
|
|
19
|
+
- PGID=1000
|
|
20
|
+
- TZ=Etc/UTC # set your own timezone
|
|
21
|
+
- TITLE=AI Desktop
|
|
22
|
+
# GUI login: without a password, anyone who reaches the port gets a
|
|
23
|
+
# terminal with passwordless sudo INSIDE the container. Create your own
|
|
24
|
+
# ./.gui_password file (mode 600, one line, no trailing newline needed)
|
|
25
|
+
# before the first start — do not put the password in this file.
|
|
26
|
+
- CUSTOM_USER=admin # set your own login name
|
|
27
|
+
- FILE__PASSWORD=/run/gui_password
|
|
28
|
+
|
|
29
|
+
volumes:
|
|
30
|
+
- ./config:/config
|
|
31
|
+
- ./.gui_password:/run/gui_password:ro
|
|
32
|
+
- ./custom-cont-init.d:/custom-cont-init.d:ro
|
|
33
|
+
|
|
34
|
+
# Loopback only, like everything else in NexusCrew: reach this from
|
|
35
|
+
# elsewhere through an SSH tunnel or VPN you control, never by publishing
|
|
36
|
+
# these ports beyond 127.0.0.1.
|
|
37
|
+
# 6900 = http, 6901 = https. The https port is the one worth using day to
|
|
38
|
+
# day — full desktop features (clipboard, audio/video codecs) need it.
|
|
39
|
+
ports:
|
|
40
|
+
- "127.0.0.1:6900:3000"
|
|
41
|
+
- "127.0.0.1:6901:3001"
|
|
42
|
+
# CDP of the Chromium running on the desktop: the channel a Playwright
|
|
43
|
+
# MCP client attaches to (see SKILL.md). Same browser you see and use
|
|
44
|
+
# over the desktop, so same logins.
|
|
45
|
+
- "127.0.0.1:9222:9223"
|
|
46
|
+
|
|
47
|
+
# Chromium crashes on tab open without adequate /dev/shm.
|
|
48
|
+
shm_size: "1gb"
|
|
49
|
+
mem_limit: 4g # tune to your machine
|
|
50
|
+
mem_reservation: 1g
|
|
51
|
+
cpus: 2.0
|
|
52
|
+
|
|
53
|
+
# KEEP THIS. The container is the boundary that holds here, and this line
|
|
54
|
+
# is part of it. The browser inside runs with --no-sandbox — that is not
|
|
55
|
+
# an accident, it is the trade this image makes: giving Chromium its own
|
|
56
|
+
# sandbox would require handing the container CAP_SYS_ADMIN or
|
|
57
|
+
# seccomp=unconfined, weakening the one boundary that is actually doing
|
|
58
|
+
# the work in order to add one inside it.
|
|
59
|
+
#
|
|
60
|
+
# Measured, not assumed: with this line set, launching the browser without
|
|
61
|
+
# --no-sandbox aborts — the setuid helper cannot elevate (PR_SET_NO_NEW_PRIVS)
|
|
62
|
+
# and the namespace sandbox is denied ("failed to move to new namespace:
|
|
63
|
+
# Operation not permitted"). See ../SKILL.md, "Where the boundary is".
|
|
64
|
+
#
|
|
65
|
+
# The consequence is real and you should plan for it: a renderer exploit
|
|
66
|
+
# lands in this container. Treat what the browser can reach — its profile,
|
|
67
|
+
# its sessions, anything you mount — as being inside the blast radius.
|
|
68
|
+
security_opt:
|
|
69
|
+
- no-new-privileges:true
|
|
70
|
+
|
|
71
|
+
logging:
|
|
72
|
+
driver: json-file
|
|
73
|
+
options:
|
|
74
|
+
max-size: "10m"
|
|
75
|
+
max-file: "3"
|