nio-cat 0.5.1 → 0.8.0

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/README.md CHANGED
@@ -1,14 +1,47 @@
1
1
  # nio
2
2
 
3
- Fes servir els agents d’IA del teu ordinador (Claude Code, Codex, Grok, Antigravity i Ollama) des del mòbil. Segueix-los en directe i aprova cada acció.
3
+ Els teus agents (Claude Code i Codex) als teus ordinadors, controlats des del mòbil. Personal i gratuït: fas servir els teus comptes i les teves claus.
4
+
5
+ ## Instal·lació (una ordre)
4
6
 
5
7
  ```sh
6
- npx nio-cat
8
+ curl -fsSL https://www.nio.cat/install.sh | sh
7
9
  ```
8
10
 
9
- Executa-ho a la carpeta dels teus projectes i escaneja el codi QR que apareix amb la càmera del mòbil.
11
+ Instal·la nio i `cloudflared` al teu usuari, el deixa com a servei (systemd o launchd) i mostra un codi QR. Escaneja’l amb el mòbil a [nio.cat/app](https://www.nio.cat/app). Per provar-lo sense instal·lar res: `npx nio-cat`.
12
+
13
+ ## Què fa
14
+
15
+ - **Sessions** de Claude Code i Codex en directe, a qualsevol dels teus ordinadors.
16
+ - **Aprovacions** amb el motiu, al mòbil, al push o a Telegram, i **registre** de totes les accions (`~/.nio/audit.jsonl`).
17
+ - **Feines**: cua, cadenes («quan acabi, que Codex ho revisi») i programació (cron).
18
+ - **Memòria compartida** a `~/.nio/memory`, amb un servidor MCP (`nio mcp`) registrat a Claude Code i Codex.
19
+ - **Telegram** (el teu bot) i **veu** (ElevenLabs o OpenAI), amb les claus a l’ordinador.
20
+ - **Navegador i escriptori** amb agent-browser i agent-desktop: mirar passa sol, actuar sempre demana permís.
21
+
22
+ ## Arquitectura
23
+
24
+ ```
25
+ Mòbil (nio.cat/app) ──┐ Telegram (long polling, sortida)
26
+ ▼ │
27
+ nio.cat: l’app i l’adreça signada de cada ordinador (res més)
28
+ │
29
+ túnel de Cloudflare (ràpid, o amb nom: nio tunnel setup)
30
+ ▼
31
+ ┌──────────── nio, a cada ordinador ────────────┐
32
+ │ agents: Claude Code · Codex (· altres) │
33
+ │ aprovacions · registre · avisos (Web Push) │
34
+ │ feines: cua · cadenes · cron · regles │
35
+ │ memòria ~/.nio/memory + servidor MCP │
36
+ │ veu · Telegram · agent-browser/agent-desktop │
37
+ └───────────────────────────────────────────────┘
38
+ ```
39
+
40
+ - Cada mòbil s’aparella amb un codi d’un sol ús i en rep una clau pròpia, revocable (`nio devices`).
41
+ - Si el túnel canvia d’adreça, el mòbil la retroba a nio.cat, signada amb la clau Ed25519 de l’ordinador.
42
+
43
+ ## Ordres
10
44
 
11
- - Sense `cloudflared`, et connectes per la wifi de casa.
12
- - Amb `cloudflared` (a macOS: `brew install cloudflared`), des de qualsevol lloc.
45
+ `nio [carpetes…]` · `nio pair` · `nio devices` · `nio service install` · `nio tunnel setup <host>` · `nio memory` · `nio directory on|off` · `nio doctor` · `nio help`
13
46
 
14
- Opcions: `npx nio-cat help`. Més informació a [nio.cat](https://www.nio.cat).
47
+ Privadesa: [nio.cat/privadesa](https://www.nio.cat/privadesa) · Contacte: hola@nio.cat · nio // un projecte de [f.cat](https://f.cat)
package/bin/nio.js CHANGED
@@ -9,7 +9,17 @@ 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 voice = require('../src/voice');
20
+ const { Telegram } = require('../src/telegram');
21
+ const { Control } = require('../src/control');
22
+ const { Jobs } = require('../src/jobs');
13
23
  const { addServer } = require('../src/ssh');
14
24
  const { pairLink, terminalQr, writePairPage, makeCode, codeLink, terminalQrSize } = require('../src/pair');
15
25
  const { which } = require('../src/proc');
@@ -22,11 +32,11 @@ for (let i = 0; i < argv.length; i++) {
22
32
  if (a.startsWith('--')) {
23
33
  const [k, v] = a.slice(2).split('=');
24
34
  if (v !== undefined) flags[k] = v;
25
- else if (['port', 'url', 'app', 'local-port', 'dir', 'tunnel-token'].includes(k)) flags[k] = argv[++i];
35
+ else if (['port', 'url', 'app', 'local-port', 'dir', 'tunnel-token', 'name'].includes(k)) flags[k] = argv[++i];
26
36
  else flags[k] = true;
27
37
  } else pos.push(a);
28
38
  }
29
- const cmd = ['start', 'pair', 'doctor', 'reset-token', 'help', 'ssh'].includes(pos[0]) ? pos.shift() : 'start';
39
+ const cmd = ['start', 'pair', 'doctor', 'reset-token', 'help', 'ssh', 'tunnel', 'service', 'version', 'devices', 'mcp', 'memory', 'directory'].includes(pos[0]) ? pos.shift() : 'start';
30
40
 
31
41
  const c = {
32
42
  dim: (s) => `\x1b[2m${s}\x1b[0m`,
@@ -36,6 +46,7 @@ const c = {
36
46
  };
37
47
 
38
48
  const cfg = config.load();
49
+ const auth = new Auth(undefined, cfg);
39
50
 
40
51
  async function doctor() {
41
52
  const agents = await detectAll(cfg, true);
@@ -87,7 +98,15 @@ function openDashboard() {
87
98
 
88
99
  async function showPair(publicUrl) {
89
100
  const app = flags.app || cfg.app;
90
- const link = pairLink(app, publicUrl, cfg.token), code = makeCode(publicUrl, cfg.token), qrLink = codeLink(app, code);
101
+ if (cfg.lastUrl !== publicUrl) {
102
+ cfg.lastUrl = publicUrl;
103
+ config.save(cfg);
104
+ }
105
+ // L'adreça es desa (signada) a nio.cat: si canvia, el mòbil la torna a trobar sol.
106
+ if (cmd === 'start') directory.publish(cfg, publicUrl, (t) => console.log(c.dim(` ${t}`)));
107
+ // Un secret d'un sol ús (no la clau principal): el mòbil el canvia per una clau pròpia i revocable.
108
+ const { code: secret } = auth.createCode({ secret: true });
109
+ const link = pairLink(app, publicUrl, secret), code = makeCode(publicUrl, secret), qrLink = codeLink(app, code);
91
110
  const file = writePairPage(link, code, qrLink);
92
111
  // 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
112
  const rows = process.stdout.rows || 999, cols = process.stdout.columns || 999;
@@ -101,7 +120,7 @@ async function showPair(publicUrl) {
101
120
  }
102
121
  console.log(`\n ${c.b('Codi:')} ${code}`);
103
122
  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'));
123
+ console.log(c.dim(' Serveix per a un sol dispositiu i caduca en 15 minuts. Per a un altre: nio pair.\n'));
105
124
  if (flags.open) {
106
125
  const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
107
126
  try {
@@ -134,6 +153,23 @@ async function sshStart() {
134
153
  if (canOpenBrowser() && openUrl(link)) console.log(c.dim(' He obert nio.cat amb el servidor ja afegit.\n'));
135
154
  }
136
155
 
156
+ // La memòria compartida (servidor MCP de nio) es registra sola a Claude Code i Codex.
157
+ function registerMemory(force) {
158
+ memory.ensure();
159
+ if (cfg.memory?.register === false && !force) return;
160
+ if (mcp.command().unstable && !force) {
161
+ 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».'));
162
+ return;
163
+ }
164
+ const out = [];
165
+ const claude = which(cfg.agents?.claude?.bin || 'claude');
166
+ if (claude) out.push(`Claude Code ${mcp.registerClaude(claude, { force })}`);
167
+ if (which(cfg.agents?.codex?.bin || 'codex')) {
168
+ try { out.push(`Codex ${mcp.registerCodex()}`); } catch (e) { out.push(`Codex: ${e.message}`); }
169
+ }
170
+ console.log(` ${c.dim('Memòria:')} ${memory.DIR}${out.length ? c.dim(` (${out.join(' · ')})`) : ''}`);
171
+ }
172
+
137
173
  async function start() {
138
174
  if (flags.port) cfg.port = Number(flags.port);
139
175
  if (flags.app) cfg.app = flags.app;
@@ -167,10 +203,21 @@ async function start() {
167
203
  }
168
204
  const fixed = cfg.tunnelToken && cfg.fixedUrl && !flags.url && !flags['no-tunnel'] && !flags.lan ? cfg : null;
169
205
 
170
- const hub = new Hub(cfg);
206
+ const audit = new Audit();
207
+ const hub = new Hub(cfg, { audit });
171
208
  let publicUrl = flags.url || null;
172
209
  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 });
210
+ registerMemory();
211
+ 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) });
212
+ jobs.hookUrl = () => `http://127.0.0.1:${cfg.port}/internal/approval`;
213
+ jobs.startTimer();
214
+ const tg = new Telegram({ cfg, hub, audit, available: jobs.available });
215
+ tg.hookUrl = jobs.hookUrl;
216
+ tg.start();
217
+ if (cfg.telegram?.token) console.log(` ${c.dim('Telegram:')} actiu ${c.dim(`(${(cfg.telegram.allowed || []).length} xats autoritzats)`)}`);
218
+ const control = new Control({ cfg, hub, audit });
219
+ const routes = [...memory.routes(), ...jobs.routes(), ...voice.routes(cfg), ...tg.routes(), ...control.routes()];
220
+ const server = createServer({ cfg, hub, auth, audit, routes, beforeApproval: (t, i) => control.beforeApproval(t, i), info: async () => ({ control: await control.caps().catch(() => null) }), getPublicUrl: () => publicUrl, getApp: () => flags.app || cfg.app });
174
221
 
175
222
  server.on('error', (e) => {
176
223
  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 +245,20 @@ async function start() {
198
245
  return showPair(publicUrl);
199
246
  }
200
247
 
248
+ // Adreça fixa amb un túnel configurat amb «nio tunnel setup <host>».
249
+ const named = cfg.tunnel && cfg.tunnel.mode === 'named' && !flags.quick && !flags.url && !flags['no-tunnel'] && !flags.lan ? cfg.tunnel : null;
250
+ if (named) {
251
+ const t = startConfiguredTunnel(named, cfg.port, {
252
+ onReady: () => console.log(` ${c.dim('Túnel:')} https://${named.host} ${c.ok('connectat')} ${c.dim('(adreça fixa)')}`),
253
+ 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.`)),
254
+ });
255
+ if (t) {
256
+ publicUrl = `https://${named.host}`;
257
+ console.log(c.dim(` Obrint el túnel cap a ${publicUrl}…`));
258
+ return showPair(publicUrl);
259
+ }
260
+ }
261
+
201
262
  if (fixed) {
202
263
  const nt = startNamedTunnel(cfg.tunnelToken, () => {
203
264
  publicUrl = cfg.fixedUrl;
@@ -242,19 +303,97 @@ async function start() {
242
303
  case 'doctor':
243
304
  await doctor();
244
305
  break;
306
+ case 'mcp':
307
+ mcp.serve();
308
+ return;
309
+ case 'memory':
310
+ if (pos[0] === 'register') registerMemory(true);
311
+ else if (pos[0] === 'off') { cfg.memory = { ...(cfg.memory || {}), register: false }; config.save(cfg); console.log(' No registraré la memòria automàticament.'); }
312
+ 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')); }
313
+ break;
314
+ case 'directory':
315
+ 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.'); }
316
+ 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.'); }
317
+ 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`);
318
+ break;
319
+ case 'version':
320
+ console.log(require('../package.json').version);
321
+ break;
245
322
  case 'pair':
323
+ if (flags.check) {
324
+ process.exitCode = cfg.lastUrl ? 0 : 1;
325
+ break;
326
+ }
246
327
  if (!flags.url && !cfg.lastUrl) {
247
328
  console.log(' Encara no hi ha cap adreça desada. Engega nio primer.');
248
329
  break;
249
330
  }
250
331
  await showPair(flags.url || cfg.lastUrl);
251
332
  break;
252
- case 'reset-token':
333
+ case 'reset-token': {
253
334
  cfg.token = crypto.randomBytes(24).toString('base64url');
254
335
  cfg.pushSubs = [];
255
336
  config.save(cfg);
256
- console.log(' Clau nova creada. Cap mòbil aparellat abans no hi té accés.');
337
+ const n = auth.revokeAll();
338
+ console.log(` Clau principal nova i ${n} dispositius desconnectats. Reinicia nio i torna a aparellar els mòbils.`);
257
339
  break;
340
+ }
341
+ case 'devices': {
342
+ const sub = pos.shift();
343
+ if (sub === 'revoke') {
344
+ const d = auth.devices.find((x) => x.id === pos[0]) ? auth.revoke(pos[0]) : null;
345
+ console.log(d ? ` Accés revocat: ${d.name}. Reinicia nio perquè es tanqui la connexió oberta.` : ' No hi ha cap dispositiu amb aquest identificador.');
346
+ break;
347
+ }
348
+ const list = auth.list().filter((d) => !d.primary);
349
+ if (!list.length) console.log(' Cap dispositiu aparellat amb codi. Fes «nio pair».');
350
+ 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')}`);
351
+ console.log(c.dim('\n nio devices revoke <id> · també des de l’app, a Ordinadors'));
352
+ break;
353
+ }
354
+ case 'tunnel': {
355
+ const sub = pos.shift();
356
+ if (sub === 'setup') {
357
+ try {
358
+ cfg.tunnel = setupNamedTunnel({ host: pos[0], name: flags.name });
359
+ config.save(cfg);
360
+ console.log(`\n ${c.ok('Fet.')} Aquest ordinador serà a ${c.b('https://' + cfg.tunnel.host)}.`);
361
+ 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'));
362
+ } catch (e) {
363
+ console.error(c.warn(`\n ${e.message}\n`));
364
+ process.exitCode = 1;
365
+ }
366
+ } else if (sub === 'off') {
367
+ delete cfg.tunnel;
368
+ config.save(cfg);
369
+ console.log(' Tornaré a fer servir el túnel ràpid (l’adreça canviarà a cada reinici).');
370
+ } else {
371
+ 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.');
372
+ console.log(c.dim(' nio tunnel setup <host> [--name <nom>] · nio tunnel off'));
373
+ }
374
+ break;
375
+ }
376
+ case 'service': {
377
+ const sub = pos.shift();
378
+ try {
379
+ if (sub === 'uninstall') {
380
+ console.log(` Servei esborrat (${service.uninstall()}).`);
381
+ } else if (sub === 'install') {
382
+ const roots = (pos.length ? pos.map((p) => path.resolve(p)) : cfg.roots.length ? cfg.roots : [process.cwd()]).filter((r) => fs.existsSync(r));
383
+ const r = service.install({ roots });
384
+ console.log(`\n ${c.ok('Fet.')} nio arrencarà sol amb l’ordinador. Carpetes: ${roots.join(', ')}`);
385
+ console.log(c.dim(` Fitxer: ${r.file}`));
386
+ if (r.linger === false) console.log(c.warn(' Per arrencar sense iniciar sessió, executa: sudo loginctl enable-linger $USER'));
387
+ console.log(c.dim(` Registre: ${r.logs} · Codi per al mòbil: nio pair\n`));
388
+ } else {
389
+ console.log(' nio service install [carpetes…] · nio service uninstall');
390
+ }
391
+ } catch (e) {
392
+ console.error(c.warn(`\n ${e.message}\n`));
393
+ process.exitCode = 1;
394
+ }
395
+ break;
396
+ }
258
397
  case 'help':
259
398
  console.log(`
260
399
  ${c.b('nio')} · agents d’IA al teu ordinador, des del mòbil
@@ -267,6 +406,7 @@ async function start() {
267
406
  --forget-fixed Oblida l'adreça fixa i torna al túnel temporal
268
407
  --port <n> Port local (7717 si no dius res)
269
408
  --no-open No obris el panell al navegador d'aquest ordinador
409
+ --quick Túnel ràpid encara que n'hi hagi un amb nom
270
410
  --local Obre la còpia del panell d'aquest ordinador (si el navegador bloqueja nio.cat, p. ex. Safari)
271
411
  --open Obre al navegador la pàgina del codi QR
272
412
  nio pair Mostra el codi QR una altra vegada
@@ -278,6 +418,15 @@ async function start() {
278
418
  Servidor amb adreça pública: nio --url https://la-teva-adreça --no-open
279
419
  Deixa'l sota tmux, pm2 o systemd perquè no s'aturi.
280
420
 
421
+ nio tunnel setup <host> [--name <nom>]
422
+ Adreça fixa amb un túnel de Cloudflare (la zona ha de ser a Cloudflare)
423
+ nio tunnel off Torna al túnel ràpid
424
+ nio service install [carpetes…]
425
+ Arrenca nio sol amb l'ordinador (systemd o launchd)
426
+ nio devices Dispositius aparellats · nio devices revoke <id>
427
+ nio memory Memòria compartida (~/.nio/memory) · register · off
428
+ nio mcp Servidor MCP de la memòria (l'engeguen Claude Code i Codex)
429
+ nio directory on|off Adreça recordada a nio.cat (el mòbil retroba l'ordinador si el túnel canvia)
281
430
  nio doctor Llista els agents instal·lats
282
431
  nio reset-token Crea una clau nova i desconnecta tots els mòbils
283
432
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.5.1",
3
+ "version": "0.8.0",
4
4
  "description": "nio: fes servir Claude Code, Codex, Grok, Antigravity i Ollama del teu ordinador des del mòbil.",
5
- "license": "MIT",
5
+ "license": "UNLICENSED",
6
6
  "author": "f.cat",
7
7
  "homepage": "https://www.nio.cat",
8
8
  "bin": {
@@ -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": [
@@ -25,10 +25,18 @@ module.exports = {
25
25
  }
26
26
 
27
27
  const env = {};
28
+ const gate = (extra = {}) => {
29
+ // Hook PreToolUse: envia al mòbil el que cal aprovar.
30
+ args.push('--settings', JSON.stringify({ hooks: { PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: `node "${HOOK}"`, timeout: 900 }] }] } }));
31
+ Object.assign(env, { NIO_HOOK_URL: ctx.hook.url, NIO_HOOK_KEY: ctx.hook.key, NIO_SESSION: session.id }, extra);
32
+ };
33
+ const control = cfg.control?.navegador || cfg.control?.escriptori;
28
34
  if (session.mode === 'llegir') {
29
35
  args.push('--permission-mode', 'plan');
30
36
  } else if (session.mode === 'tot') {
31
37
  args.push('--permission-mode', 'bypassPermissions');
38
+ // Amb el control del navegador o l'escriptori actiu, les ordres que hi actuen sempre es pregunten.
39
+ if (control) gate({ NIO_GATE: 'tot' });
32
40
  } else {
33
41
  // "preguntar": el hook envia cada acció que no sigui de lectura al mòbil.
34
42
  const settings = {
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 };
@@ -30,7 +30,13 @@ process.stdin.on('end', async () => {
30
30
  /* entrada buida */
31
31
  }
32
32
  const tool = input.tool_name || 'Eina';
33
+ // Navegador i escriptori (agent-browser, agent-desktop): mirar passa sol; actuar sempre demana permís,
34
+ // fins i tot en «Via lliure» (NIO_GATE=tot), on la resta d'eines passen soles.
35
+ let c = null;
36
+ try { c = tool === 'Bash' ? require('./control').classify(input.tool_input?.command) : null; } catch { c = null; }
37
+ if (process.env.NIO_GATE === 'tot' && !(c && c.act)) return reply('allow', 'nio: via lliure');
33
38
  if (READ_ONLY.has(tool)) return reply('allow', 'nio: només llegeix');
39
+ if (c && !c.act) return reply('allow', `nio: ${c.tool} només mira`);
34
40
 
35
41
  const url = process.env.NIO_HOOK_URL;
36
42
  if (!url) return reply('deny', 'nio no està configurat en aquesta sessió');