@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +22 -4
  3. package/frontend/dist/assets/index-uRh_hSop.js +93 -0
  4. package/frontend/dist/index.html +1 -1
  5. package/frontend/dist/version.json +1 -1
  6. package/lib/cli/commands.js +14 -0
  7. package/lib/config.js +9 -0
  8. package/lib/fleet/builtin.js +57 -2
  9. package/lib/live-host/routes.js +9 -3
  10. package/lib/nodes/commands.js +15 -4
  11. package/lib/nodes/store.js +43 -0
  12. package/lib/nodes/tunnel.js +19 -0
  13. package/lib/proxy/federation.js +25 -1
  14. package/lib/proxy/panel-auth.js +63 -33
  15. package/lib/proxy/panel-proxy.js +41 -10
  16. package/lib/server.js +128 -6
  17. package/lib/settings/pairing-coordinator.js +32 -0
  18. package/lib/settings/public-peering-routes.js +13 -1
  19. package/package.json +1 -1
  20. package/skills/aidesktop/SKILL.md +201 -0
  21. package/skills/aidesktop/docker/Dockerfile +21 -0
  22. package/skills/aidesktop/docker/custom-cont-init.d/10-cdp-relay.sh +20 -0
  23. package/skills/aidesktop/docker/docker-compose.example.yml +75 -0
  24. package/skills/live/SKILL.md +90 -0
  25. package/skills/nexuscrew/SKILL.md +113 -0
  26. package/frontend/dist/assets/index-zjL6kZ7J.js +0 -93
  27. package/skills/alibaba-token-media/SKILL.md +0 -152
  28. package/skills/alibaba-token-media/agents/openai.yaml +0 -4
  29. package/skills/alibaba-token-media/references/api-contract.md +0 -97
  30. package/skills/alibaba-token-media/scripts/alibaba_token_media.py +0 -550
  31. package/skills/fill-forms/SKILL.md +0 -177
  32. package/skills/fill-forms/agents/openai.yaml +0 -4
  33. package/skills/fill-forms/references/overlay-technique.md +0 -99
  34. package/skills/fill-forms/requirements.txt +0 -4
  35. package/skills/fill-forms/scripts/dump_docx.py +0 -70
  36. package/skills/fill-forms/scripts/fill_docx.py +0 -207
  37. package/skills/fill-forms/scripts/fill_pdf.py +0 -424
  38. package/skills/fill-forms/scripts/inspect_pdf.py +0 -188
  39. 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
- app.use('/pair', publicPeeringRoutes({ cfg, nodesPath }));
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); le route sono local-only e
863
- // il proxy nega /api/live-host via /node (federazione default-deny).
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 { app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP, updater, diagnostics };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {
@@ -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"