nio-cat 0.6.1 → 0.8.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/README.md +39 -6
- package/bin/nio.js +10 -2
- package/package.json +2 -2
- package/src/agents/claude.js +8 -0
- package/src/claude-hook.js +6 -0
- package/src/control.js +238 -0
- package/src/hub.js +4 -1
- package/src/server.js +5 -3
- package/src/telegram.js +311 -0
- package/src/voice.js +164 -0
- package/web/app/app.js +165 -47
- package/web/app/style.css +5 -0
- package/web/board.js +11 -11
- package/web/index.html +35 -17
- package/web/privadesa.html +148 -0
- package/web/sw.js +1 -1
package/README.md
CHANGED
|
@@ -1,14 +1,47 @@
|
|
|
1
1
|
# nio
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
8
|
+
curl -fsSL https://www.nio.cat/install.sh | sh
|
|
7
9
|
```
|
|
8
10
|
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -16,6 +16,9 @@ const { Audit } = require('../src/audit');
|
|
|
16
16
|
const memory = require('../src/memory');
|
|
17
17
|
const mcp = require('../src/mcp');
|
|
18
18
|
const directory = require('../src/directory');
|
|
19
|
+
const voice = require('../src/voice');
|
|
20
|
+
const { Telegram } = require('../src/telegram');
|
|
21
|
+
const { Control } = require('../src/control');
|
|
19
22
|
const { Jobs } = require('../src/jobs');
|
|
20
23
|
const { addServer } = require('../src/ssh');
|
|
21
24
|
const { pairLink, terminalQr, writePairPage, makeCode, codeLink, terminalQrSize } = require('../src/pair');
|
|
@@ -208,8 +211,13 @@ async function start() {
|
|
|
208
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) });
|
|
209
212
|
jobs.hookUrl = () => `http://127.0.0.1:${cfg.port}/internal/approval`;
|
|
210
213
|
jobs.startTimer();
|
|
211
|
-
const
|
|
212
|
-
|
|
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 });
|
|
213
221
|
|
|
214
222
|
server.on('error', (e) => {
|
|
215
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`));
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nio-cat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "nio: fes servir Claude Code, Codex, Grok, Antigravity i Ollama del teu ordinador des del mòbil.",
|
|
5
|
-
"license": "
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
6
|
"author": "f.cat",
|
|
7
7
|
"homepage": "https://www.nio.cat",
|
|
8
8
|
"bin": {
|
package/src/agents/claude.js
CHANGED
|
@@ -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/claude-hook.js
CHANGED
|
@@ -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ó');
|
package/src/control.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Control del navegador i de l'escriptori. nio no el construeix: Claude Code i Codex fan servir
|
|
3
|
+
// agent-browser (web) i agent-desktop (apps) per shell. nio s'encarrega de:
|
|
4
|
+
// · saber què hi ha instal·lat i activar-ho per ordinador (desactivat per defecte),
|
|
5
|
+
// · posar les guies oficials a l'abast dels agents (skill de Claude Code i AGENTS.md de Codex),
|
|
6
|
+
// · classificar les ordres: les de mirar passen soles; les que actuen demanen aprovació (amb captura),
|
|
7
|
+
// · fer captures sota demanda i l'aturada d'emergència.
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const crypto = require('crypto');
|
|
12
|
+
const { execFile } = require('child_process');
|
|
13
|
+
const { which, childEnv } = require('./proc');
|
|
14
|
+
const config = require('./config');
|
|
15
|
+
const { HOME } = config;
|
|
16
|
+
|
|
17
|
+
const SHOTS = path.join(HOME, 'shots');
|
|
18
|
+
const httpErr = (status, message) => Object.assign(new Error(message), { status });
|
|
19
|
+
|
|
20
|
+
// Subordres que només miren. Qualsevol altra cosa actua (clicar, escriure, omplir, pujar fitxers…).
|
|
21
|
+
const READ = {
|
|
22
|
+
'agent-browser': new Set(['snapshot', 'screenshot', 'status', 'wait', 'open', 'goto', 'navigate', 'get', 'is', 'skills', 'help', '--help', 'version', '--version', 'tabs', 'title', 'url']),
|
|
23
|
+
'agent-desktop': new Set(['snapshot', 'find', 'get', 'is', 'screenshot', 'list-surfaces', 'list-apps', 'list-windows', 'list-displays', 'list-notifications', 'status', 'version', 'skills', 'wait', 'permissions', 'help', '--help', '--version']),
|
|
24
|
+
};
|
|
25
|
+
const KIND = { 'agent-browser': 'navegador', 'agent-desktop': 'escriptori' };
|
|
26
|
+
const META = /[;&|`$<>(){}\n\r\\]/; // res de redireccions, subordres ni cadenes
|
|
27
|
+
|
|
28
|
+
// Classifica una ordre de shell. null si no és d'aquestes eines (o si és composta: llavors no ens en refiem).
|
|
29
|
+
function classify(command) {
|
|
30
|
+
const cmd = String(command || '').trim();
|
|
31
|
+
const m = cmd.match(/^(?:npx\s+(?:-y\s+)?)?(agent-browser|agent-desktop)(?:\s+(.*))?$/s);
|
|
32
|
+
if (!m) {
|
|
33
|
+
const k = cmd.match(/\b(agent-browser|agent-desktop)\b/);
|
|
34
|
+
return k ? { tool: k[1], kind: KIND[k[1]], act: true, composite: true } : null;
|
|
35
|
+
}
|
|
36
|
+
const tool = m[1];
|
|
37
|
+
const rest = (m[2] || '').trim();
|
|
38
|
+
if (META.test(rest)) return { tool, kind: KIND[tool], act: true, composite: true };
|
|
39
|
+
// Saltem opcions globals (--session x, --headed…) per trobar la subordre.
|
|
40
|
+
const words = rest.match(/"[^"]*"|'[^']*'|\S+/g) || [];
|
|
41
|
+
let i = 0;
|
|
42
|
+
while (i < words.length && words[i].startsWith('-') && !READ[tool].has(words[i])) i += /^--(session|session-name|profile|state|executable-path|app|window-id)$/.test(words[i]) ? 2 : 1;
|
|
43
|
+
const sub = words[i] || 'help';
|
|
44
|
+
let read = READ[tool].has(sub);
|
|
45
|
+
if (tool === 'agent-browser' && sub === 'stream') read = words[i + 1] === 'status';
|
|
46
|
+
return { tool, kind: KIND[tool], sub, act: !read };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function run(bin, args, timeout = 15000) {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
execFile(bin, args, { timeout, env: childEnv(), maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => resolve({ ok: !err, out: String(stdout || ''), err: String(stderr || err?.message || '') }));
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
class Control {
|
|
56
|
+
constructor({ cfg, hub, audit }) {
|
|
57
|
+
this.cfg = cfg;
|
|
58
|
+
this.hub = hub;
|
|
59
|
+
this.audit = audit || { add() {} };
|
|
60
|
+
this.capsAt = 0;
|
|
61
|
+
fs.mkdirSync(SHOTS, { recursive: true, mode: 0o700 });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
enabled(kind) {
|
|
65
|
+
return !!this.cfg.control?.[kind];
|
|
66
|
+
}
|
|
67
|
+
any() {
|
|
68
|
+
return this.enabled('navegador') || this.enabled('escriptori');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async caps(force) {
|
|
72
|
+
if (!force && this.capsCache && Date.now() - this.capsAt < 60000) return this.capsCache;
|
|
73
|
+
const ab = which('agent-browser'), ad = which('agent-desktop');
|
|
74
|
+
let desk = { installed: !!ad, ready: false, why: ad ? '' : 'No hi és' };
|
|
75
|
+
if (ad) {
|
|
76
|
+
const r = await run(ad, ['status'], 8000);
|
|
77
|
+
let j = null;
|
|
78
|
+
try { j = JSON.parse(r.out); } catch { /* text */ }
|
|
79
|
+
desk.ready = j ? j.ok !== false : r.ok;
|
|
80
|
+
desk.why = desk.ready ? '' : (j?.error?.message || r.err || r.out).split('\n')[0].slice(0, 200);
|
|
81
|
+
}
|
|
82
|
+
const shotTool = process.platform === 'darwin' ? which('screencapture') : which('grim') || which('scrot') || which('gnome-screenshot');
|
|
83
|
+
this.capsCache = {
|
|
84
|
+
navegador: { installed: !!ab, ready: !!ab, enabled: this.enabled('navegador') },
|
|
85
|
+
escriptori: { ...desk, enabled: this.enabled('escriptori'), screenshot: !!shotTool },
|
|
86
|
+
platform: process.platform,
|
|
87
|
+
};
|
|
88
|
+
this.capsAt = Date.now();
|
|
89
|
+
return this.capsCache;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------- guies per als agents
|
|
93
|
+
async installGuides(kind) {
|
|
94
|
+
const tool = kind === 'navegador' ? 'agent-browser' : 'agent-desktop';
|
|
95
|
+
const bin = which(tool);
|
|
96
|
+
if (!bin) throw httpErr(400, `${tool} no està instal·lat en aquest ordinador`);
|
|
97
|
+
const parts = [];
|
|
98
|
+
const tries = kind === 'navegador' ? [['skills', 'get', 'core'], ['skills', 'get', 'agent-browser']] : [['skills', 'get', 'desktop'], ['skills', 'get', 'platform']];
|
|
99
|
+
for (const args of tries) {
|
|
100
|
+
const r = await run(bin, args, 20000);
|
|
101
|
+
if (r.ok && r.out.trim()) {
|
|
102
|
+
parts.push(r.out.trim().replace(/^---[\s\S]*?\n---\s*/, ''));
|
|
103
|
+
if (kind === 'navegador') break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (!parts.length) parts.push(`Fes servir \`${tool}\`. Abans de començar, executa \`${tool} skills get ${kind === 'navegador' ? 'core' : 'desktop'}\` i segueix-ne les instruccions.`);
|
|
107
|
+
const rules = `## Normes de nio (obligatòries)
|
|
108
|
+
|
|
109
|
+
- Cada ordre de \`${tool}\` que actua (clicar, escriure, omplir, pujar fitxers, enviar formularis…) l'ha d'aprovar la persona des del mòbil. Les de mirar (snapshot, screenshot, status, wait, open) passen soles.
|
|
110
|
+
- Mai no facis compres, pagaments, transferències, canvis de contrasenya ni esborrats sense que la persona t'ho hagi demanat explícitament i ho aprovi.
|
|
111
|
+
- No escriguis contrasenyes ni dades de targetes. Si cal iniciar sessió, demana-ho.
|
|
112
|
+
- Executa \`${tool}\` sempre com a ordre sola (sense \`&&\`, \`|\`, \`;\` ni redireccions), una per pas, perquè nio la pugui revisar.`;
|
|
113
|
+
const title = kind === 'navegador' ? 'Navegador web (agent-browser)' : 'Apps de l’escriptori (agent-desktop)';
|
|
114
|
+
const body = `# ${title}\n\n${rules}\n\n${parts.join('\n\n')}\n`;
|
|
115
|
+
const name = `nio-${kind}`;
|
|
116
|
+
// Claude Code: skill d'usuari.
|
|
117
|
+
const sdir = path.join(os.homedir(), '.claude', 'skills', name);
|
|
118
|
+
fs.mkdirSync(sdir, { recursive: true });
|
|
119
|
+
fs.writeFileSync(path.join(sdir, 'SKILL.md'), `---\nname: ${name}\ndescription: Controla ${kind === 'navegador' ? 'el navegador web' : 'les apps de l’escriptori'} d'aquest ordinador amb ${tool}, amb les normes d'aprovació de nio. Fes-la servir quan calgui ${kind === 'navegador' ? 'obrir webs, llegir-les o actuar-hi' : 'mirar o fer servir apps de l’ordinador'}.\n---\n\n${body}`);
|
|
120
|
+
// Codex: bloc dins de ~/.codex/AGENTS.md entre marques (es reescriu, no es duplica).
|
|
121
|
+
const cdir = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
122
|
+
const af = path.join(cdir, 'AGENTS.md');
|
|
123
|
+
let txt = '';
|
|
124
|
+
try { txt = fs.readFileSync(af, 'utf8'); } catch { /* nou */ }
|
|
125
|
+
const start = `<!-- nio:${kind} -->`, end = `<!-- /nio:${kind} -->`;
|
|
126
|
+
const block = `${start}\n${body}${end}`;
|
|
127
|
+
const re = new RegExp(`${start}[\\s\\S]*?${end}`);
|
|
128
|
+
txt = re.test(txt) ? txt.replace(re, block) : `${txt}${txt && !txt.endsWith('\n') ? '\n' : ''}\n${block}\n`;
|
|
129
|
+
fs.mkdirSync(cdir, { recursive: true });
|
|
130
|
+
fs.writeFileSync(af, txt);
|
|
131
|
+
return { claude: path.join(sdir, 'SKILL.md'), codex: af };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
removeGuides(kind) {
|
|
135
|
+
fs.rmSync(path.join(os.homedir(), '.claude', 'skills', `nio-${kind}`), { recursive: true, force: true });
|
|
136
|
+
const af = path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'AGENTS.md');
|
|
137
|
+
try {
|
|
138
|
+
const txt = fs.readFileSync(af, 'utf8').replace(new RegExp(`\\n?<!-- nio:${kind} -->[\\s\\S]*?<!-- /nio:${kind} -->\\n?`), '\n');
|
|
139
|
+
fs.writeFileSync(af, txt);
|
|
140
|
+
} catch { /* no hi era */ }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------- captures
|
|
144
|
+
shotPath(id) {
|
|
145
|
+
if (!/^[\w-]{8,40}$/.test(String(id))) throw httpErr(400, 'Captura no vàlida');
|
|
146
|
+
return path.join(SHOTS, `${id}.png`);
|
|
147
|
+
}
|
|
148
|
+
async screenshot(prefer) {
|
|
149
|
+
const id = `${Date.now().toString(36)}-${crypto.randomBytes(4).toString('hex')}`;
|
|
150
|
+
const file = this.shotPath(id);
|
|
151
|
+
let ok = false, from = null;
|
|
152
|
+
const browser = which('agent-browser');
|
|
153
|
+
if ((prefer !== 'escriptori') && this.enabled('navegador') && browser) {
|
|
154
|
+
ok = (await run(browser, ['screenshot', file], 15000)).ok && fs.existsSync(file);
|
|
155
|
+
from = 'navegador';
|
|
156
|
+
}
|
|
157
|
+
if (!ok && this.enabled('escriptori')) {
|
|
158
|
+
const tool = process.platform === 'darwin' ? ['screencapture', ['-x', file]] : which('grim') ? ['grim', [file]] : which('scrot') ? ['scrot', ['-o', file]] : ['gnome-screenshot', ['-f', file]];
|
|
159
|
+
const bin = which(tool[0]);
|
|
160
|
+
if (bin) ok = (await run(bin, tool[1], 15000)).ok && fs.existsSync(file);
|
|
161
|
+
from = 'escriptori';
|
|
162
|
+
}
|
|
163
|
+
if (!ok) throw httpErr(409, this.any() ? 'No he pogut fer la captura (no hi ha navegador obert ni pantalla accessible).' : 'Activa primer el control del navegador o de l’escriptori en aquest ordinador.');
|
|
164
|
+
this.prune();
|
|
165
|
+
this.last = { id, t: Date.now(), from };
|
|
166
|
+
this.hub.emit('out', { type: 'screen', shot: this.last });
|
|
167
|
+
return this.last;
|
|
168
|
+
}
|
|
169
|
+
prune() {
|
|
170
|
+
const files = fs.readdirSync(SHOTS).filter((f) => f.endsWith('.png')).sort();
|
|
171
|
+
for (const f of files.slice(0, Math.max(0, files.length - 200))) fs.rmSync(path.join(SHOTS, f), { force: true });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Abans d'una aprovació d'una ordre que actua: captura (si es pot) per veure-ho al mòbil.
|
|
175
|
+
async beforeApproval(tool, input) {
|
|
176
|
+
if (tool !== 'Bash' || !this.any()) return {};
|
|
177
|
+
const c = classify(input?.command);
|
|
178
|
+
if (!c || !c.act) return {};
|
|
179
|
+
const extra = { control: c.kind };
|
|
180
|
+
try {
|
|
181
|
+
const shot = await Promise.race([this.screenshot(c.kind), new Promise((_, rej) => setTimeout(() => rej(new Error('temps')), 9000))]);
|
|
182
|
+
extra.shot = shot.id;
|
|
183
|
+
} catch { /* sense captura */ }
|
|
184
|
+
return extra;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async panic(by) {
|
|
188
|
+
let stopped = 0;
|
|
189
|
+
for (const [sid] of this.hub.runs) if (this.hub.stop(sid)) stopped++;
|
|
190
|
+
for (const a of [...this.hub.approvals.values()]) if (a.state === 'pending') this.hub.decide(a.id, 'deny', 'Aturada d’emergència', by);
|
|
191
|
+
const ab = which('agent-browser');
|
|
192
|
+
if (ab) await run(ab, ['close', '--all'], 10000);
|
|
193
|
+
this.audit.add({ type: 'control', what: `Aturada d’emergència: ${stopped} agents aturats i navegador tancat`, by });
|
|
194
|
+
return { stopped };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
routes() {
|
|
198
|
+
return [
|
|
199
|
+
{ method: 'GET', path: '/api/control', handler: async ({ url }) => this.caps(url.searchParams.has('refresh')) },
|
|
200
|
+
{
|
|
201
|
+
method: 'PUT',
|
|
202
|
+
path: '/api/control',
|
|
203
|
+
handler: async ({ body, device }) => {
|
|
204
|
+
const caps = await this.caps(true);
|
|
205
|
+
this.cfg.control = this.cfg.control || {};
|
|
206
|
+
const notes = [];
|
|
207
|
+
for (const kind of ['navegador', 'escriptori']) {
|
|
208
|
+
if (typeof body[kind] !== 'boolean' || body[kind] === this.enabled(kind)) continue;
|
|
209
|
+
if (body[kind]) {
|
|
210
|
+
if (!caps[kind].installed) throw httpErr(400, kind === 'navegador' ? 'agent-browser no està instal·lat. A l’ordinador: npm i -g agent-browser && agent-browser install' : 'agent-desktop no està instal·lat. A l’ordinador: npm i -g agent-desktop');
|
|
211
|
+
await this.installGuides(kind).catch((e) => notes.push(e.message));
|
|
212
|
+
} else this.removeGuides(kind);
|
|
213
|
+
this.cfg.control[kind] = body[kind];
|
|
214
|
+
this.audit.add({ type: 'control', what: `${kind === 'navegador' ? 'Control del navegador' : 'Control de l’escriptori'} ${body[kind] ? 'activat' : 'desactivat'}`, by: device?.name });
|
|
215
|
+
}
|
|
216
|
+
config.save(this.cfg);
|
|
217
|
+
return { ...(await this.caps(true)), notes };
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
{ method: 'POST', path: '/api/screen', handler: ({ body }) => this.screenshot(body?.from) },
|
|
221
|
+
{ method: 'GET', path: '/api/screen', handler: () => ({ last: this.last || null, enabled: this.any() }) },
|
|
222
|
+
{
|
|
223
|
+
method: 'GET',
|
|
224
|
+
path: /^\/api\/screen\/shot\/([\w-]+)$/,
|
|
225
|
+
handler: ({ res, params }) => {
|
|
226
|
+
const f = this.shotPath(params[0]);
|
|
227
|
+
if (!fs.existsSync(f)) throw httpErr(404, 'Aquesta captura ja no hi és');
|
|
228
|
+
const buf = fs.readFileSync(f);
|
|
229
|
+
res.writeHead(200, { 'content-type': 'image/png', 'content-length': buf.length, 'cache-control': 'private, max-age=3600' });
|
|
230
|
+
res.end(buf);
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
{ method: 'POST', path: '/api/panic', handler: ({ device }) => this.panic(device?.name) },
|
|
234
|
+
];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = { Control, classify };
|
package/src/hub.js
CHANGED
|
@@ -99,7 +99,10 @@ class Hub extends EventEmitter {
|
|
|
99
99
|
|
|
100
100
|
push(s, kind, data = {}) {
|
|
101
101
|
const last = s.events[s.events.length - 1];
|
|
102
|
-
if (last && last.open && kind !== 'text-chunk')
|
|
102
|
+
if (last && last.open && kind !== 'text-chunk') {
|
|
103
|
+
delete last.open;
|
|
104
|
+
this.emit('out', { type: 'event', session: s.id, event: last });
|
|
105
|
+
}
|
|
103
106
|
const ev = { seq: ++s.seq, t: Date.now(), kind, ...data };
|
|
104
107
|
s.events.push(ev);
|
|
105
108
|
if (s.events.length > MAX_EVENTS) s.events.splice(0, s.events.length - MAX_EVENTS);
|
package/src/server.js
CHANGED
|
@@ -149,7 +149,7 @@ function matchRoute(routes, method, p) {
|
|
|
149
149
|
return null;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
function createServer({ cfg, hub, auth, audit, getPublicUrl, getApp, routes = [], info = () => ({}) }) {
|
|
152
|
+
function createServer({ cfg, hub, auth, audit, getPublicUrl, getApp, routes = [], info = () => ({}), beforeApproval = null }) {
|
|
153
153
|
const streams = new Map(); // res -> id del dispositiu
|
|
154
154
|
auth.onRevoke((d) => {
|
|
155
155
|
for (const [res, dev] of streams) if (dev === d.id) { res.end(); streams.delete(res); }
|
|
@@ -361,7 +361,7 @@ function createServer({ cfg, hub, auth, audit, getPublicUrl, getApp, routes = []
|
|
|
361
361
|
throw httpErr(404, 'Aquesta ruta no existeix');
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
-
const PUBLIC = /^\/(?:$|index\.html$|app(?:\/|$)|
|
|
364
|
+
const PUBLIC = /^\/(?:$|index\.html$|app(?:\/|$)|privadesa$|[\w.-]+\.(?:css|js|svg|png|webmanifest|json|ico|txt|html)$)/;
|
|
365
365
|
function serveStatic(req, res, url) {
|
|
366
366
|
let rel = decodeURIComponent(url.pathname);
|
|
367
367
|
if (!PUBLIC.test(rel) || rel.split('/').some((p) => p.startsWith('.')) || /^\/(daemon|node_modules)\b/.test(rel)) {
|
|
@@ -408,7 +408,9 @@ function createServer({ cfg, hub, auth, audit, getPublicUrl, getApp, routes = []
|
|
|
408
408
|
if (!isLoopbackDirect(req)) throw httpErr(403, 'Només s’accepta des d’aquest ordinador');
|
|
409
409
|
const b = await readBody(req);
|
|
410
410
|
if (!hub.checkHookKey(b.session, req.headers['x-nio-key'])) throw httpErr(403, 'La clau no és correcta');
|
|
411
|
-
|
|
411
|
+
// Si l'ordre actua sobre el navegador o l'escriptori, hi afegim una captura de la pantalla.
|
|
412
|
+
const extra = beforeApproval ? await beforeApproval(b.tool, b.input).catch(() => ({})) : {};
|
|
413
|
+
const decision = await hub.requestApproval({ session: b.session, tool: b.tool, input: b.input, extra });
|
|
412
414
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
413
415
|
return res.end(JSON.stringify({ decision }));
|
|
414
416
|
} catch (e) {
|