nio-cat 0.5.1 → 0.6.1

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/bin/nio.js CHANGED
@@ -9,7 +9,14 @@ const config = require('../src/config');
9
9
  const { Hub } = require('../src/hub');
10
10
  const { createServer } = require('../src/server');
11
11
  const { detectAll } = require('../src/agents');
12
- const { startTunnel, startNamedTunnel } = require('../src/tunnel');
12
+ const { startTunnel, startNamedTunnel, startConfiguredTunnel, setupNamedTunnel } = require('../src/tunnel');
13
+ const service = require('../src/service');
14
+ const { Auth } = require('../src/auth');
15
+ const { Audit } = require('../src/audit');
16
+ const memory = require('../src/memory');
17
+ const mcp = require('../src/mcp');
18
+ const directory = require('../src/directory');
19
+ const { Jobs } = require('../src/jobs');
13
20
  const { addServer } = require('../src/ssh');
14
21
  const { pairLink, terminalQr, writePairPage, makeCode, codeLink, terminalQrSize } = require('../src/pair');
15
22
  const { which } = require('../src/proc');
@@ -22,11 +29,11 @@ for (let i = 0; i < argv.length; i++) {
22
29
  if (a.startsWith('--')) {
23
30
  const [k, v] = a.slice(2).split('=');
24
31
  if (v !== undefined) flags[k] = v;
25
- else if (['port', 'url', 'app', 'local-port', 'dir', 'tunnel-token'].includes(k)) flags[k] = argv[++i];
32
+ else if (['port', 'url', 'app', 'local-port', 'dir', 'tunnel-token', 'name'].includes(k)) flags[k] = argv[++i];
26
33
  else flags[k] = true;
27
34
  } else pos.push(a);
28
35
  }
29
- const cmd = ['start', 'pair', 'doctor', 'reset-token', 'help', 'ssh'].includes(pos[0]) ? pos.shift() : 'start';
36
+ const cmd = ['start', 'pair', 'doctor', 'reset-token', 'help', 'ssh', 'tunnel', 'service', 'version', 'devices', 'mcp', 'memory', 'directory'].includes(pos[0]) ? pos.shift() : 'start';
30
37
 
31
38
  const c = {
32
39
  dim: (s) => `\x1b[2m${s}\x1b[0m`,
@@ -36,6 +43,7 @@ const c = {
36
43
  };
37
44
 
38
45
  const cfg = config.load();
46
+ const auth = new Auth(undefined, cfg);
39
47
 
40
48
  async function doctor() {
41
49
  const agents = await detectAll(cfg, true);
@@ -87,7 +95,15 @@ function openDashboard() {
87
95
 
88
96
  async function showPair(publicUrl) {
89
97
  const app = flags.app || cfg.app;
90
- const link = pairLink(app, publicUrl, cfg.token), code = makeCode(publicUrl, cfg.token), qrLink = codeLink(app, code);
98
+ if (cfg.lastUrl !== publicUrl) {
99
+ cfg.lastUrl = publicUrl;
100
+ config.save(cfg);
101
+ }
102
+ // L'adreça es desa (signada) a nio.cat: si canvia, el mòbil la torna a trobar sol.
103
+ if (cmd === 'start') directory.publish(cfg, publicUrl, (t) => console.log(c.dim(` ${t}`)));
104
+ // Un secret d'un sol ús (no la clau principal): el mòbil el canvia per una clau pròpia i revocable.
105
+ const { code: secret } = auth.createCode({ secret: true });
106
+ const link = pairLink(app, publicUrl, secret), code = makeCode(publicUrl, secret), qrLink = codeLink(app, code);
91
107
  const file = writePairPage(link, code, qrLink);
92
108
  // El QR ha de cabre al terminal: marge normal, marge d'1 mòdul i, si no hi cap, la pàgina del navegador.
93
109
  const rows = process.stdout.rows || 999, cols = process.stdout.columns || 999;
@@ -101,7 +117,7 @@ async function showPair(publicUrl) {
101
117
  }
102
118
  console.log(`\n ${c.b('Codi:')} ${code}`);
103
119
  console.log(c.dim(' Copia\'l i enganxa\'l a Sessions → Tria un ordinador.'));
104
- console.log(c.dim(' Aquest codi dona accés als teus agents. Guarda\'l com una contrasenya.\n'));
120
+ console.log(c.dim(' Serveix per a un sol dispositiu i caduca en 15 minuts. Per a un altre: nio pair.\n'));
105
121
  if (flags.open) {
106
122
  const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
107
123
  try {
@@ -134,6 +150,23 @@ async function sshStart() {
134
150
  if (canOpenBrowser() && openUrl(link)) console.log(c.dim(' He obert nio.cat amb el servidor ja afegit.\n'));
135
151
  }
136
152
 
153
+ // La memòria compartida (servidor MCP de nio) es registra sola a Claude Code i Codex.
154
+ function registerMemory(force) {
155
+ memory.ensure();
156
+ if (cfg.memory?.register === false && !force) return;
157
+ if (mcp.command().unstable && !force) {
158
+ console.log(c.dim(' Memòria: per registrar-la a Claude Code i Codex, instal·la nio (npm i -g nio-cat) i fes «nio memory register».'));
159
+ return;
160
+ }
161
+ const out = [];
162
+ const claude = which(cfg.agents?.claude?.bin || 'claude');
163
+ if (claude) out.push(`Claude Code ${mcp.registerClaude(claude, { force })}`);
164
+ if (which(cfg.agents?.codex?.bin || 'codex')) {
165
+ try { out.push(`Codex ${mcp.registerCodex()}`); } catch (e) { out.push(`Codex: ${e.message}`); }
166
+ }
167
+ console.log(` ${c.dim('Memòria:')} ${memory.DIR}${out.length ? c.dim(` (${out.join(' · ')})`) : ''}`);
168
+ }
169
+
137
170
  async function start() {
138
171
  if (flags.port) cfg.port = Number(flags.port);
139
172
  if (flags.app) cfg.app = flags.app;
@@ -167,10 +200,16 @@ async function start() {
167
200
  }
168
201
  const fixed = cfg.tunnelToken && cfg.fixedUrl && !flags.url && !flags['no-tunnel'] && !flags.lan ? cfg : null;
169
202
 
170
- const hub = new Hub(cfg);
203
+ const audit = new Audit();
204
+ const hub = new Hub(cfg, { audit });
171
205
  let publicUrl = flags.url || null;
172
206
  const host = flags.lan ? '0.0.0.0' : '127.0.0.1';
173
- const server = createServer({ cfg, hub, getPublicUrl: () => publicUrl, getApp: () => flags.app || cfg.app });
207
+ registerMemory();
208
+ const jobs = new Jobs({ cfg, hub, audit, available: () => require('../src/agents').AGENTS.filter((a) => a.id === 'demo' || require('../src/agents').binPath(cfg, a.id)).map((a) => a.id) });
209
+ jobs.hookUrl = () => `http://127.0.0.1:${cfg.port}/internal/approval`;
210
+ jobs.startTimer();
211
+ const routes = [...memory.routes(), ...jobs.routes()];
212
+ const server = createServer({ cfg, hub, auth, audit, routes, getPublicUrl: () => publicUrl, getApp: () => flags.app || cfg.app });
174
213
 
175
214
  server.on('error', (e) => {
176
215
  if (e.code === 'EADDRINUSE') console.error(c.warn(`\n El port ${cfg.port} ja el fa servir un altre programa. Tria\'n un altre: nio --port 7718\n`));
@@ -198,6 +237,20 @@ async function start() {
198
237
  return showPair(publicUrl);
199
238
  }
200
239
 
240
+ // Adreça fixa amb un túnel configurat amb «nio tunnel setup <host>».
241
+ const named = cfg.tunnel && cfg.tunnel.mode === 'named' && !flags.quick && !flags.url && !flags['no-tunnel'] && !flags.lan ? cfg.tunnel : null;
242
+ if (named) {
243
+ const t = startConfiguredTunnel(named, cfg.port, {
244
+ onReady: () => console.log(` ${c.dim('Túnel:')} https://${named.host} ${c.ok('connectat')} ${c.dim('(adreça fixa)')}`),
245
+ onDown: (code, wait) => console.log(c.warn(` El túnel s’ha tallat (codi ${code}). El torno a obrir d’aquí a ${Math.round(wait / 1000)} s.`)),
246
+ });
247
+ if (t) {
248
+ publicUrl = `https://${named.host}`;
249
+ console.log(c.dim(` Obrint el túnel cap a ${publicUrl}…`));
250
+ return showPair(publicUrl);
251
+ }
252
+ }
253
+
201
254
  if (fixed) {
202
255
  const nt = startNamedTunnel(cfg.tunnelToken, () => {
203
256
  publicUrl = cfg.fixedUrl;
@@ -242,19 +295,97 @@ async function start() {
242
295
  case 'doctor':
243
296
  await doctor();
244
297
  break;
298
+ case 'mcp':
299
+ mcp.serve();
300
+ return;
301
+ case 'memory':
302
+ if (pos[0] === 'register') registerMemory(true);
303
+ else if (pos[0] === 'off') { cfg.memory = { ...(cfg.memory || {}), register: false }; config.save(cfg); console.log(' No registraré la memòria automàticament.'); }
304
+ else { memory.ensure(); console.log(` ${memory.DIR}\n`); for (const f of memory.list()) console.log(` ${f.path}`); console.log(c.dim('\n nio memory register · nio memory off')); }
305
+ break;
306
+ case 'directory':
307
+ if (pos[0] === 'off') { cfg.directory = false; config.save(cfg); console.log(' No desaré l’adreça a nio.cat. Si el túnel canvia, caldrà tornar a aparellar el mòbil.'); }
308
+ else if (pos[0] === 'on') { delete cfg.directory; config.save(cfg); console.log(' Desaré l’adreça (signada) a nio.cat perquè el mòbil la trobi si canvia.'); }
309
+ else console.log(` Adreça recordada a nio.cat: ${cfg.directory === false ? 'desactivada' : 'activada'} (ordinador ${directory.identity(cfg).loc}).\n nio directory on · nio directory off`);
310
+ break;
311
+ case 'version':
312
+ console.log(require('../package.json').version);
313
+ break;
245
314
  case 'pair':
315
+ if (flags.check) {
316
+ process.exitCode = cfg.lastUrl ? 0 : 1;
317
+ break;
318
+ }
246
319
  if (!flags.url && !cfg.lastUrl) {
247
320
  console.log(' Encara no hi ha cap adreça desada. Engega nio primer.');
248
321
  break;
249
322
  }
250
323
  await showPair(flags.url || cfg.lastUrl);
251
324
  break;
252
- case 'reset-token':
325
+ case 'reset-token': {
253
326
  cfg.token = crypto.randomBytes(24).toString('base64url');
254
327
  cfg.pushSubs = [];
255
328
  config.save(cfg);
256
- console.log(' Clau nova creada. Cap mòbil aparellat abans no hi té accés.');
329
+ const n = auth.revokeAll();
330
+ console.log(` Clau principal nova i ${n} dispositius desconnectats. Reinicia nio i torna a aparellar els mòbils.`);
257
331
  break;
332
+ }
333
+ case 'devices': {
334
+ const sub = pos.shift();
335
+ if (sub === 'revoke') {
336
+ const d = auth.devices.find((x) => x.id === pos[0]) ? auth.revoke(pos[0]) : null;
337
+ console.log(d ? ` Accés revocat: ${d.name}. Reinicia nio perquè es tanqui la connexió oberta.` : ' No hi ha cap dispositiu amb aquest identificador.');
338
+ break;
339
+ }
340
+ const list = auth.list().filter((d) => !d.primary);
341
+ if (!list.length) console.log(' Cap dispositiu aparellat amb codi. Fes «nio pair».');
342
+ for (const d of list) console.log(` ${d.id.padEnd(14)} ${d.name.padEnd(28)} ${c.dim(d.lastSeen ? 'vist ' + new Date(d.lastSeen).toLocaleString('ca') : 'mai vist')}`);
343
+ console.log(c.dim('\n nio devices revoke <id> · també des de l’app, a Ordinadors'));
344
+ break;
345
+ }
346
+ case 'tunnel': {
347
+ const sub = pos.shift();
348
+ if (sub === 'setup') {
349
+ try {
350
+ cfg.tunnel = setupNamedTunnel({ host: pos[0], name: flags.name });
351
+ config.save(cfg);
352
+ console.log(`\n ${c.ok('Fet.')} Aquest ordinador serà a ${c.b('https://' + cfg.tunnel.host)}.`);
353
+ console.log(c.dim(' Reinicia nio (o el servei) perquè faci servir l’adreça fixa. El mòbil ja no s’haurà de tornar a aparellar.\n'));
354
+ } catch (e) {
355
+ console.error(c.warn(`\n ${e.message}\n`));
356
+ process.exitCode = 1;
357
+ }
358
+ } else if (sub === 'off') {
359
+ delete cfg.tunnel;
360
+ config.save(cfg);
361
+ console.log(' Tornaré a fer servir el túnel ràpid (l’adreça canviarà a cada reinici).');
362
+ } else {
363
+ console.log(cfg.tunnel ? ` Túnel amb nom: ${cfg.tunnel.name} → https://${cfg.tunnel.host}` : ' Sense túnel amb nom: faig servir el túnel ràpid de Cloudflare.');
364
+ console.log(c.dim(' nio tunnel setup <host> [--name <nom>] · nio tunnel off'));
365
+ }
366
+ break;
367
+ }
368
+ case 'service': {
369
+ const sub = pos.shift();
370
+ try {
371
+ if (sub === 'uninstall') {
372
+ console.log(` Servei esborrat (${service.uninstall()}).`);
373
+ } else if (sub === 'install') {
374
+ const roots = (pos.length ? pos.map((p) => path.resolve(p)) : cfg.roots.length ? cfg.roots : [process.cwd()]).filter((r) => fs.existsSync(r));
375
+ const r = service.install({ roots });
376
+ console.log(`\n ${c.ok('Fet.')} nio arrencarà sol amb l’ordinador. Carpetes: ${roots.join(', ')}`);
377
+ console.log(c.dim(` Fitxer: ${r.file}`));
378
+ if (r.linger === false) console.log(c.warn(' Per arrencar sense iniciar sessió, executa: sudo loginctl enable-linger $USER'));
379
+ console.log(c.dim(` Registre: ${r.logs} · Codi per al mòbil: nio pair\n`));
380
+ } else {
381
+ console.log(' nio service install [carpetes…] · nio service uninstall');
382
+ }
383
+ } catch (e) {
384
+ console.error(c.warn(`\n ${e.message}\n`));
385
+ process.exitCode = 1;
386
+ }
387
+ break;
388
+ }
258
389
  case 'help':
259
390
  console.log(`
260
391
  ${c.b('nio')} · agents d’IA al teu ordinador, des del mòbil
@@ -267,6 +398,7 @@ async function start() {
267
398
  --forget-fixed Oblida l'adreça fixa i torna al túnel temporal
268
399
  --port <n> Port local (7717 si no dius res)
269
400
  --no-open No obris el panell al navegador d'aquest ordinador
401
+ --quick Túnel ràpid encara que n'hi hagi un amb nom
270
402
  --local Obre la còpia del panell d'aquest ordinador (si el navegador bloqueja nio.cat, p. ex. Safari)
271
403
  --open Obre al navegador la pàgina del codi QR
272
404
  nio pair Mostra el codi QR una altra vegada
@@ -278,6 +410,15 @@ async function start() {
278
410
  Servidor amb adreça pública: nio --url https://la-teva-adreça --no-open
279
411
  Deixa'l sota tmux, pm2 o systemd perquè no s'aturi.
280
412
 
413
+ nio tunnel setup <host> [--name <nom>]
414
+ Adreça fixa amb un túnel de Cloudflare (la zona ha de ser a Cloudflare)
415
+ nio tunnel off Torna al túnel ràpid
416
+ nio service install [carpetes…]
417
+ Arrenca nio sol amb l'ordinador (systemd o launchd)
418
+ nio devices Dispositius aparellats · nio devices revoke <id>
419
+ nio memory Memòria compartida (~/.nio/memory) · register · off
420
+ nio mcp Servidor MCP de la memòria (l'engeguen Claude Code i Codex)
421
+ nio directory on|off Adreça recordada a nio.cat (el mòbil retroba l'ordinador si el túnel canvia)
281
422
  nio doctor Llista els agents instal·lats
282
423
  nio reset-token Crea una clau nova i desconnecta tots els mòbils
283
424
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "nio: fes servir Claude Code, Codex, Grok, Antigravity i Ollama del teu ordinador des del mòbil.",
5
5
  "license": "MIT",
6
6
  "author": "f.cat",
@@ -20,6 +20,7 @@
20
20
  "scripts": {
21
21
  "start": "node bin/nio.js",
22
22
  "demo": "node bin/nio.js --lan",
23
+ "test": "node --test test/*.test.js",
23
24
  "prepack": "node scripts/copy-web.js"
24
25
  },
25
26
  "keywords": [
package/src/audit.js ADDED
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+ // Registre d'accions: què, per què, quan i qui ho ha decidit. Es guarda només en aquest
3
+ // ordinador (~/.nio/audit.jsonl), una línia JSON per entrada, i mai no s'esborra sol
4
+ // (quan passa de 5 MB es mou a audit.1.jsonl).
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { HOME } = require('./config');
8
+
9
+ const MAX = 5 * 1024 * 1024;
10
+
11
+ class Audit {
12
+ constructor(home = HOME) {
13
+ this.file = path.join(home, 'audit.jsonl');
14
+ this.listeners = new Set();
15
+ }
16
+
17
+ add(entry) {
18
+ const e = { t: Date.now(), ...entry };
19
+ try {
20
+ const st = fs.existsSync(this.file) ? fs.statSync(this.file) : null;
21
+ if (st && st.size > MAX) fs.renameSync(this.file, this.file.replace(/\.jsonl$/, '.1.jsonl'));
22
+ fs.appendFileSync(this.file, `${JSON.stringify(e)}\n`, { mode: 0o600 });
23
+ } catch {
24
+ /* disc ple o sense permís: no aturem l'agent per això */
25
+ }
26
+ for (const fn of this.listeners) fn(e);
27
+ return e;
28
+ }
29
+
30
+ // Les últimes entrades, de més nova a més antiga.
31
+ recent(limit = 200, filter) {
32
+ let lines = [];
33
+ try {
34
+ lines = fs.readFileSync(this.file, 'utf8').split('\n').filter(Boolean);
35
+ } catch {
36
+ return [];
37
+ }
38
+ const out = [];
39
+ for (let i = lines.length - 1; i >= 0 && out.length < limit; i--) {
40
+ try {
41
+ const e = JSON.parse(lines[i]);
42
+ if (!filter || filter(e)) out.push(e);
43
+ } catch {
44
+ /* línia malmesa */
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ on(fn) {
51
+ this.listeners.add(fn);
52
+ }
53
+ }
54
+
55
+ module.exports = { Audit };
package/src/auth.js ADDED
@@ -0,0 +1,175 @@
1
+ 'use strict';
2
+ // Dispositius aparellats i codis d'aparellament.
3
+ //
4
+ // · La clau principal (cfg.token) és la del propietari: l'obre el panell local en engegar nio i
5
+ // la fa servir «nio ssh». Es canvia amb `nio reset-token`.
6
+ // · Cada mòbil aparellat amb un codi té la seva clau. Al disc només en desem el hash (SHA-256).
7
+ // · El codi (dins del «nio-…» i del QR) és un secret d'un sol ús que caduca als 15 minuts.
8
+ // Viatja al fragment (#) de l'enllaç, que el navegador no envia a cap servidor.
9
+ // · Un dispositiu es pot revocar en qualsevol moment (des de l'app o amb `nio devices revoke`).
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const crypto = require('crypto');
13
+ const { HOME } = require('./config');
14
+
15
+ const CODE_TTL = 15 * 60 * 1000;
16
+ const ALPHA = 'abcdefghjkmnpqrstuvwxyz23456789'; // sense 0/o, 1/l/i
17
+ const MAX_FAILS = 10;
18
+ const FAIL_WINDOW = 10 * 60 * 1000;
19
+
20
+ const sha = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
21
+ const rid = (n = 9) => crypto.randomBytes(n).toString('base64url');
22
+
23
+ function readJson(file, d) {
24
+ try {
25
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
26
+ } catch {
27
+ return d;
28
+ }
29
+ }
30
+ function writeJson(file, v) {
31
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
32
+ const tmp = `${file}.${process.pid}.tmp`;
33
+ fs.writeFileSync(tmp, JSON.stringify(v, null, 2), { mode: 0o600 });
34
+ fs.renameSync(tmp, file);
35
+ }
36
+
37
+ function newCode() {
38
+ const bytes = crypto.randomBytes(12);
39
+ let s = '';
40
+ for (const b of bytes) s += ALPHA[b % ALPHA.length];
41
+ return `nio-${s.slice(0, 4)}-${s.slice(4, 8)}-${s.slice(8, 12)}`;
42
+ }
43
+ // Els codis «nio-xxxx-xxxx-xxxx» no distingeixen majúscules; els secrets en base64url, sí.
44
+ const normCode = (c) => { const t = String(c || '').replace(/\s+/g, ''); return /^nio-[a-z2-9]{4}-[a-z2-9]{4}-[a-z2-9]{4}$/i.test(t) ? t.toLowerCase() : t; };
45
+
46
+ class Auth {
47
+ constructor(home = HOME, cfg = null) {
48
+ this.cfg = cfg; // per a la clau principal
49
+ this.devFile = path.join(home, 'devices.json');
50
+ this.codeFile = path.join(home, 'pairing.json');
51
+ this.devices = readJson(this.devFile, []);
52
+ this.fails = [];
53
+ this.seenDirty = false;
54
+ this.listeners = new Set();
55
+ }
56
+
57
+ persist() {
58
+ writeJson(this.devFile, this.devices);
59
+ }
60
+
61
+ list(currentId) {
62
+ const out = this.devices.map(({ hash, ...d }) => ({ ...d, current: d.id === currentId }));
63
+ if (this.cfg?.token) out.unshift({ id: 'principal', name: 'Clau principal (aquest ordinador i SSH)', primary: true, created: null, lastSeen: this.primarySeen || null, current: currentId === 'principal' });
64
+ return out;
65
+ }
66
+
67
+ // Retorna el dispositiu d'un testimoni o null.
68
+ check(token) {
69
+ if (!token || typeof token !== 'string' || token.length > 200) return null;
70
+ const h = Buffer.from(sha(token), 'hex');
71
+ if (this.cfg?.token) {
72
+ const p = Buffer.from(sha(this.cfg.token), 'hex');
73
+ if (crypto.timingSafeEqual(p, h)) {
74
+ this.primarySeen = Date.now();
75
+ return { id: 'principal', name: 'Clau principal', primary: true };
76
+ }
77
+ }
78
+ let found = null;
79
+ for (const d of this.devices) {
80
+ const b = Buffer.from(d.hash, 'hex');
81
+ if (b.length === h.length && crypto.timingSafeEqual(b, h)) found = d;
82
+ }
83
+ if (found) {
84
+ const now = Date.now();
85
+ if (!found.lastSeen || now - found.lastSeen > 60000) {
86
+ found.lastSeen = now;
87
+ this.seenDirty = true;
88
+ clearTimeout(this.seenT);
89
+ this.seenT = setTimeout(() => this.seenDirty && (this.persist(), (this.seenDirty = false)), 2000);
90
+ if (this.seenT.unref) this.seenT.unref();
91
+ }
92
+ }
93
+ return found;
94
+ }
95
+
96
+ fromRequest(req) {
97
+ const h = req.headers.authorization || '';
98
+ return this.check(h.startsWith('Bearer ') ? h.slice(7) : '');
99
+ }
100
+
101
+ // ---------- codis d'aparellament (fitxer compartit: `nio pair` i el daemon poden ser processos diferents)
102
+ // `secret: true` dona un secret de 24 bytes en base64url, que cap compacte dins del codi «nio-…».
103
+ createCode({ secret = false } = {}) {
104
+ const code = secret ? crypto.randomBytes(24).toString('base64url') : newCode();
105
+ const now = Date.now();
106
+ const list = readJson(this.codeFile, []).filter((c) => c.expires > now);
107
+ list.push({ hash: sha(code), expires: now + CODE_TTL });
108
+ writeJson(this.codeFile, list.slice(-5));
109
+ return { code, expires: now + CODE_TTL };
110
+ }
111
+
112
+ blocked() {
113
+ const now = Date.now();
114
+ this.fails = this.fails.filter((t) => now - t < FAIL_WINDOW);
115
+ return this.fails.length >= MAX_FAILS;
116
+ }
117
+
118
+ // Canvia un codi vàlid per un testimoni nou. Llença un error amb .status si no es pot.
119
+ redeem(code, name) {
120
+ if (this.blocked()) throw Object.assign(new Error('Massa intents. Espera uns minuts i torna-ho a provar.'), { status: 429 });
121
+ const now = Date.now();
122
+ const h = sha(normCode(code));
123
+ const list = readJson(this.codeFile, []).filter((c) => c.expires > now);
124
+ const i = list.findIndex((c) => c.hash === h);
125
+ if (i < 0) {
126
+ this.fails.push(now);
127
+ throw Object.assign(new Error('Aquest codi no és vàlid o ha caducat. Demana’n un de nou amb «nio pair».'), { status: 403 });
128
+ }
129
+ list.splice(i, 1);
130
+ writeJson(this.codeFile, list);
131
+ const token = crypto.randomBytes(32).toString('base64url');
132
+ const d = { id: rid(), name: cleanName(name), hash: sha(token), created: now, lastSeen: now };
133
+ this.devices.push(d);
134
+ this.persist();
135
+ return { token, device: { id: d.id, name: d.name, created: d.created } };
136
+ }
137
+
138
+ rename(id, name) {
139
+ const d = this.devices.find((x) => x.id === id);
140
+ if (!d) return null;
141
+ d.name = cleanName(name);
142
+ this.persist();
143
+ return { id: d.id, name: d.name };
144
+ }
145
+
146
+ revoke(id) {
147
+ if (id === 'principal') throw Object.assign(new Error('La clau principal es canvia a l’ordinador amb «nio reset-token».'), { status: 400 });
148
+ const d = this.devices.find((x) => x.id === id);
149
+ if (!d) return null;
150
+ this.devices = this.devices.filter((x) => x.id !== id);
151
+ this.persist();
152
+ for (const fn of this.listeners) fn(d);
153
+ return { id: d.id, name: d.name };
154
+ }
155
+
156
+ revokeAll() {
157
+ const all = this.devices;
158
+ this.devices = [];
159
+ this.persist();
160
+ writeJson(this.codeFile, []);
161
+ for (const d of all) for (const fn of this.listeners) fn(d);
162
+ return all.length;
163
+ }
164
+
165
+ onRevoke(fn) {
166
+ this.listeners.add(fn);
167
+ }
168
+ }
169
+
170
+ function cleanName(name) {
171
+ const n = String(name || '').replace(/[\u0000-\u001f<>]/g, '').trim().slice(0, 60);
172
+ return n || 'Dispositiu';
173
+ }
174
+
175
+ module.exports = { Auth, newCode, sha, CODE_TTL };
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+ // Adreça recordada a nio.cat: l'ordinador publica la seva adreça actual (la del túnel), signada
3
+ // amb una clau Ed25519 pròpia. Si l'adreça canvia, el mòbil la troba allà i comprova la signatura.
4
+ // nio.cat no rep cap altra dada. Es desactiva amb `nio directory off` (cfg.directory = false).
5
+ const crypto = require('crypto');
6
+ const config = require('./config');
7
+
8
+ const b64u = (b) => Buffer.from(b).toString('base64url');
9
+
10
+ function identity(cfg) {
11
+ if (!cfg.identity) {
12
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
13
+ cfg.identity = { pub: publicKey.export({ format: 'jwk' }).x, priv: privateKey.export({ format: 'der', type: 'pkcs8' }).toString('base64url') };
14
+ config.save(cfg);
15
+ }
16
+ const pub = cfg.identity.pub;
17
+ const loc = b64u(crypto.createHash('sha256').update(Buffer.from(pub, 'base64url')).digest().subarray(0, 16));
18
+ return { pub, loc };
19
+ }
20
+
21
+ function sign(cfg, url, t = Date.now()) {
22
+ const { pub, loc } = identity(cfg);
23
+ const key = crypto.createPrivateKey({ key: Buffer.from(cfg.identity.priv, 'base64url'), format: 'der', type: 'pkcs8' });
24
+ const sig = b64u(crypto.sign(null, Buffer.from(`nio-node-v1\n${loc}\n${url}\n${t}`), key));
25
+ return { pub, url, t, sig };
26
+ }
27
+
28
+ const publicUrl = (u) => /^https:\/\//.test(u || '') && !/^https:\/\/(127\.|localhost|\[)/.test(u);
29
+
30
+ let current = null;
31
+ let timer = null;
32
+ let retry = 0;
33
+
34
+ async function publish(cfg, url, log = () => {}) {
35
+ if (cfg.directory === false || !publicUrl(url)) return false;
36
+ current = url.replace(/\/$/, '');
37
+ clearTimeout(timer);
38
+ const base = (cfg.directoryUrl || 'https://www.nio.cat').replace(/\/$/, '');
39
+ try {
40
+ const res = await fetch(`${base}/api/node`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sign(cfg, current)), signal: AbortSignal.timeout(10000) });
41
+ if (res.status === 503) return false; // nio.cat encara no té la llibreta configurada
42
+ if (!res.ok && res.status !== 409) throw new Error(`nio.cat ${res.status}`);
43
+ retry = 0;
44
+ timer = setTimeout(() => publish(cfg, current, log), 6 * 3600 * 1000); // renova abans que caduqui
45
+ if (timer.unref) timer.unref();
46
+ return true;
47
+ } catch (e) {
48
+ const wait = Math.min(30 * 60 * 1000, 5000 * 2 ** retry++);
49
+ if (retry === 1) log(`No he pogut desar l’adreça a nio.cat (${e.message}). Ho torno a provar.`);
50
+ timer = setTimeout(() => current === url.replace(/\/$/, '') && publish(cfg, current, log), wait);
51
+ if (timer.unref) timer.unref();
52
+ return false;
53
+ }
54
+ }
55
+
56
+ module.exports = { identity, sign, publish };
package/src/hub.js CHANGED
@@ -19,9 +19,10 @@ const clip = (s, n = MAX_OUT) => {
19
19
  };
20
20
 
21
21
  class Hub extends EventEmitter {
22
- constructor(cfg) {
22
+ constructor(cfg, { audit } = {}) {
23
23
  super();
24
24
  this.cfg = cfg;
25
+ this.audit = audit || { add() {} };
25
26
  this.sessions = new Map();
26
27
  this.runs = new Map(); // sessionId -> { kill, key }
27
28
  this.approvals = new Map(); // approvalId -> { ..., resolve }
@@ -258,7 +259,9 @@ class Hub extends EventEmitter {
258
259
  stream: (t) => this.chunk(s, String(t)),
259
260
  tool: ({ id: tid, name, input }) => {
260
261
  toolNames.set(tid, name);
261
- this.push(s, 'tool', { id: tid, name, input: summarizeInput(input) });
262
+ const inp = summarizeInput(input);
263
+ this.push(s, 'tool', { id: tid, name, input: inp });
264
+ this.audit.add({ type: 'tool', session: s.id, agent: s.agent, title: s.title, cwd: s.cwd, tool: name, what: describe(name, inp), mode: s.mode });
262
265
  },
263
266
  toolResult: ({ id: tid, output, error }) =>
264
267
  this.push(s, 'tool_result', { id: tid, name: toolNames.get(tid), output: clip(output), error: !!error }),
@@ -287,7 +290,7 @@ class Hub extends EventEmitter {
287
290
  finished = true;
288
291
  this.runs.delete(sid);
289
292
  for (const a of this.approvals.values()) {
290
- if (a.session === sid && a.state === 'pending') this.decide(a.id, 'deny', 'La sessió s’ha acabat');
293
+ if (a.session === sid && a.state === 'pending') this.decide(a.id, 'deny', 'La sessió s’ha acabat', 'sessió acabada');
291
294
  }
292
295
  if (err) this.push(s, 'error', { text: `No s’ha pogut engegar: ${err.message}` });
293
296
  else if (code && code !== 0 && code !== 130 && code !== 143 && !reported) {
@@ -328,7 +331,15 @@ class Hub extends EventEmitter {
328
331
  return a.length === b.length && crypto.timingSafeEqual(a, b);
329
332
  }
330
333
 
331
- requestApproval({ session, tool, input }) {
334
+ // El motiu: la descripció que dona l'agent (p. ex. el camp «description» de Bash a Claude Code)
335
+ // o, si no n'hi ha, l'últim que ha dit abans de demanar-ho.
336
+ reasonFor(s, input) {
337
+ if (input && typeof input.description === 'string' && input.description.trim()) return clip(input.description, 400);
338
+ const last = s ? [...s.events].reverse().find((e) => e.kind === 'text' || e.kind === 'user') : null;
339
+ return last && last.kind === 'text' ? clip(last.text.trim(), 400) : '';
340
+ }
341
+
342
+ requestApproval({ session, tool, input, extra }) {
332
343
  const s = this.get(session);
333
344
  const a = {
334
345
  id: id(),
@@ -337,29 +348,38 @@ class Hub extends EventEmitter {
337
348
  title: s?.title,
338
349
  tool,
339
350
  input: summarizeInput(input),
351
+ reason: this.reasonFor(s, input),
340
352
  state: 'pending',
341
353
  created: Date.now(),
354
+ ...(extra || {}),
342
355
  };
343
356
  const p = new Promise((resolve) => {
344
357
  a.resolve = resolve;
345
- a.timer = setTimeout(() => this.decide(a.id, 'deny', 'Ningú no ha respost en 15 minuts'), APPROVAL_TIMEOUT);
358
+ a.timer = setTimeout(() => this.decide(a.id, 'deny', 'Ningú no ha respost en 15 minuts', 'temps esgotat'), APPROVAL_TIMEOUT);
346
359
  });
347
360
  this.approvals.set(a.id, a);
348
361
  if (s) {
349
362
  s.state = 'waiting';
350
- this.push(s, 'approval', { approval: a.id, tool, input: a.input, state: 'pending' });
363
+ this.push(s, 'approval', { approval: a.id, tool, input: a.input, reason: a.reason, shot: a.shot, state: 'pending' });
351
364
  }
352
365
  this.emit('out', { type: 'approval', approval: publicApproval(a) });
353
- this.notice(s, `${agentName(s)} et demana permís`, `${tool}: ${describe(tool, a.input)}`);
366
+ this.notice(s, `${agentName(s)} et demana permís`, `${tool}: ${describe(tool, a.input)}`, { approval: a.id });
354
367
  return p;
355
368
  }
356
369
 
357
- decide(aid, decision, reason) {
370
+ // `by`: qui ho ha decidit (nom del dispositiu, «Telegram · …», «temps esgotat»…).
371
+ decide(aid, decision, reason, by) {
358
372
  const a = this.approvals.get(aid);
359
373
  if (!a || a.state !== 'pending') return null;
360
374
  clearTimeout(a.timer);
361
375
  a.state = decision === 'allow' ? 'allowed' : 'denied';
362
376
  a.decided = Date.now();
377
+ a.by = by || 'nio';
378
+ this.audit.add({
379
+ type: 'approval', approval: a.id, session: a.session, agent: a.agent, title: a.title,
380
+ tool: a.tool, what: describe(a.tool, a.input), why: a.reason, decision: a.state, by: a.by,
381
+ asked: a.created, note: reason || undefined,
382
+ });
363
383
  const s = this.get(a.session);
364
384
  if (s) {
365
385
  const ev = s.events.find((e) => e.kind === 'approval' && e.approval === aid);
@@ -381,12 +401,13 @@ class Hub extends EventEmitter {
381
401
  }
382
402
 
383
403
  // ---------- avisos (push) ----------
384
- notice(s, title, body) {
385
- const n = { id: id(6), t: Date.now(), title, body: clip(body || '', 180), session: s?.id };
404
+ notice(s, title, body, extra = {}) {
405
+ const n = { id: id(6), t: Date.now(), title, body: clip(body || '', 180), session: s?.id, node: this.cfg.id, ...extra };
386
406
  this.notices.unshift(n);
387
407
  this.notices.length = Math.min(this.notices.length, 20);
408
+ this.emit('notice', n, s);
388
409
  if (!this.cfg.pushSubs?.length) return;
389
- push.notifyAll(this.cfg).then((dead) => {
410
+ push.notifyAll(this.cfg, n).then((dead) => {
390
411
  if (dead.length) {
391
412
  this.cfg.pushSubs = this.cfg.pushSubs.filter((x) => !dead.includes(x.endpoint));
392
413
  save(this.cfg);
@@ -434,4 +455,4 @@ function httpErr(status, message) {
434
455
  return e;
435
456
  }
436
457
 
437
- module.exports = { Hub, httpErr };
458
+ module.exports = { Hub, httpErr, describe };