@dotrino/remote-agent 0.15.1 → 0.17.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 +4 -0
- package/package.json +3 -3
- package/protocol.js +4 -0
- package/src/agent.js +58 -2
package/README.md
CHANGED
|
@@ -63,6 +63,10 @@ const ra = await startRemoteAgent({
|
|
|
63
63
|
onRevoked () { console.log('me revocaron') }
|
|
64
64
|
})
|
|
65
65
|
// ra.machine, ra.machineId, ra.master, ra.close()
|
|
66
|
+
// ra.reportIncident({ kind, about, tries }) → { id, approvers, blocked } (0.16.0): avisa a la
|
|
67
|
+
// bóveda de que OTRO aparato de la cuenta hizo algo (la terminal: tres claves mal seguidas).
|
|
68
|
+
// Quien aprueba lo ve y bloquea o ignora; bloqueado, no saluda a ningún agente (`code: 'blocked'`)
|
|
69
|
+
// y la lista viaja con la de revocados y queda en el enlace (`link.blocked`). ra.isBlocked(pub).
|
|
66
70
|
// ra.client → el WebSocketProxyClient ya conectado e identificado bajo esta
|
|
67
71
|
// máquina. Si tu agente necesita algo más del transporte (anunciarse en un
|
|
68
72
|
// canal, por ejemplo), REUSA este: abrir una segunda conexión sería un segundo
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/remote-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Middleware compartido del ecosistema Dotrino para apps de agente remoto cifrado punto a punto (handshake E2E, emparejamiento SAS con vault, revocación, descubrimiento). Lo consumen dotrino-terminal (PTY) y dotrino-ia (chat con IA).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
|
@@ -47,12 +47,12 @@
|
|
|
47
47
|
],
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"@dotrino/identity": ">=0.92.0",
|
|
50
|
-
"@dotrino/proxy-client": ">=0.
|
|
50
|
+
"@dotrino/proxy-client": ">=0.30.0",
|
|
51
51
|
"@dotrino/vault": ">=0.64.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@dotrino/identity": "0.105.0",
|
|
55
|
-
"@dotrino/proxy-client": "0.
|
|
55
|
+
"@dotrino/proxy-client": "0.30.0",
|
|
56
56
|
"@dotrino/vault": "0.75.0"
|
|
57
57
|
}
|
|
58
58
|
}
|
package/protocol.js
CHANGED
|
@@ -26,6 +26,10 @@ export const VMSG = {
|
|
|
26
26
|
REVOKED: 'vault.revoked',
|
|
27
27
|
RENEW: 'vault.renew',
|
|
28
28
|
RENEWED: 'vault.renewed',
|
|
29
|
+
// Un INCIDENTE sobre otro aparato de la cuenta (la terminal: tres claves mal seguidas).
|
|
30
|
+
// La bóveda lo pone en la mesa del aprobador, que bloquea o ignora.
|
|
31
|
+
INCIDENT: 'vault.incident',
|
|
32
|
+
INCIDENT_RESULT: 'vault.incident.result',
|
|
29
33
|
ERROR: 'vault.error'
|
|
30
34
|
}
|
|
31
35
|
|
package/src/agent.js
CHANGED
|
@@ -65,7 +65,7 @@ class AgentSession {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
|
-
* Arranca el agente. Devuelve `{ machine, machineId, master, close }`.
|
|
68
|
+
* Arranca el agente. Devuelve `{ machine, machineId, master, client, reportIncident, isBlocked, close }`.
|
|
69
69
|
*
|
|
70
70
|
* @param {object} opts
|
|
71
71
|
* @param {string} [opts.label] qué agente es (`terminal-agent`, `ia-agent`…): lo contesta el pong
|
|
@@ -77,6 +77,7 @@ class AgentSession {
|
|
|
77
77
|
* @param {()=>void} [opts.onRevoked] se llamó al auto-borrarse por revocación.
|
|
78
78
|
* @param {()=>void} [opts.onReady] agente listo y escuchando.
|
|
79
79
|
* @param {boolean} [opts.quiet] sin logs.
|
|
80
|
+
* @param {number} [opts.netLogMs] cada cuánto van al log las estadísticas de red (5 min).
|
|
80
81
|
* @param {object} [opts.client] transporte ya conectado (SOLO pruebas).
|
|
81
82
|
*
|
|
82
83
|
* Devuelve además el **`client`** (el `WebSocketProxyClient` ya conectado e
|
|
@@ -162,10 +163,16 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
162
163
|
|
|
163
164
|
// --- Revocación: refrescar la lista del vault por el proxy (best-effort) ---
|
|
164
165
|
let revokedSet = new Set()
|
|
166
|
+
// LOS BLOQUEADOS (dueño, 2026-10-07): un aprobador bloqueó a ese aparato tras un incidente.
|
|
167
|
+
// No es una revocación —sigue en el acta, y se deshace en la bóveda—, así que va aparte y
|
|
168
|
+
// viaja con la lista de revocados. Se PERSISTE en el enlace: con la bóveda apagada, el
|
|
169
|
+
// agente tiene que seguir cerrándole la puerta a quien ya estaba bloqueado.
|
|
170
|
+
let blockedSet = new Set(Array.isArray(link.blocked) ? link.blocked : [])
|
|
165
171
|
async function refreshRevocations () {
|
|
166
172
|
try {
|
|
167
173
|
const res = await vaultRpc(VMSG.DEVICES, VMSG.DEVICES_RESULT, { op: 'devices', publickey: myPub, ts: Date.now() })
|
|
168
174
|
revokedSet = new Set((res.revoked || []).map((r) => r.nonce || r))
|
|
175
|
+
if (Array.isArray(res.blocked)) takeBlocked(res.blocked)
|
|
169
176
|
// El acta viaja con la lista. Apuntar su `seq` es lo que dispara la renovación en el
|
|
170
177
|
// mismo tic si el dueño nos cambió los permisos.
|
|
171
178
|
await anotarActa(res.acta)
|
|
@@ -174,6 +181,35 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
174
181
|
}
|
|
175
182
|
}
|
|
176
183
|
|
|
184
|
+
/** La lista nueva de bloqueados: se guarda, y a quien acaba de entrar en ella se le cierra la sesión. */
|
|
185
|
+
function takeBlocked (pubs) {
|
|
186
|
+
const next = new Set(pubs.filter((p) => typeof p === 'string'))
|
|
187
|
+
const same = next.size === blockedSet.size && [...next].every((p) => blockedSet.has(p))
|
|
188
|
+
blockedSet = next
|
|
189
|
+
if (same) return
|
|
190
|
+
link.blocked = [...next]
|
|
191
|
+
try { saveLink(dir, link) } catch (_) {}
|
|
192
|
+
for (const [sid, s] of sessions) {
|
|
193
|
+
if (!blockedSet.has(s.device)) continue
|
|
194
|
+
s.close(); sessions.delete(sid)
|
|
195
|
+
audit('session-blocked', { sid: sid.slice(0, 8) })
|
|
196
|
+
if (!opts.quiet) console.log(`[remote-agent] session ${sid.slice(0, 8)} closed: that device is blocked`)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* REPORTAR UN INCIDENTE sobre otro aparato de la cuenta (`about`, su pubkey): la bóveda lo
|
|
202
|
+
* pone en la mesa de quien aprueba, que bloquea o ignora. Devuelve lo que contestó la
|
|
203
|
+
* bóveda: `{ id, approvers, blocked }` (`id: null` si nadie aprueba o ya está bloqueado).
|
|
204
|
+
* No decide nada aquí: un incidente es información, y el freno local es cosa de la app.
|
|
205
|
+
*/
|
|
206
|
+
async function reportIncident ({ kind, about, tries = null }) {
|
|
207
|
+
if (typeof kind !== 'string' || !kind || typeof about !== 'string' || !about) throw new Error('reportIncident: kind and about are required')
|
|
208
|
+
audit('incident', { kind, device: (await pubkeyId(about).catch(() => '')).slice(0, 8).toUpperCase(), tries })
|
|
209
|
+
const res = await vaultRpc(VMSG.INCIDENT, VMSG.INCIDENT_RESULT, { op: 'incident', kind, about, tries, publickey: myPub, ts: Date.now() })
|
|
210
|
+
return { id: res.id ?? null, approvers: res.approvers ?? 0, blocked: !!res.blocked }
|
|
211
|
+
}
|
|
212
|
+
|
|
177
213
|
/**
|
|
178
214
|
* RENOVACIÓN del cert (`vault.renew`). YA NO ES UNA TAREA DE CALENDARIO: el papel no
|
|
179
215
|
* vence, lleva el `seq` del acta con el que se emitió. Se pide uno nuevo cuando el ACTA
|
|
@@ -321,6 +357,12 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
321
357
|
if (!acta || !isMember || !memberCan(acta, chk.device, 'sign')) {
|
|
322
358
|
return send(from, { type: ERROR, error: 'no autorizado: acta — este perfil no reconoce a ese aparato' })
|
|
323
359
|
}
|
|
360
|
+
// BLOQUEADO por un aprobador: está en el acta y su papel vale, y aun así no entra. Se le
|
|
361
|
+
// dice con su código, que no es lo mismo que un papel viejo ni que una revocación.
|
|
362
|
+
if (blockedSet.has(chk.device)) {
|
|
363
|
+
audit('session-refused', { reason: 'blocked', device: (await pubkeyId(chk.device)).slice(0, 8).toUpperCase() })
|
|
364
|
+
return send(from, { type: ERROR, code: 'blocked', error: 'no autorizado: bloqueado — un aprobador bloqueó este aparato; se desbloquea en la bóveda' })
|
|
365
|
+
}
|
|
324
366
|
if (data.op !== HS || typeof data.eph !== 'string') return send(from, { type: ERROR, error: 'handshake inválido' })
|
|
325
367
|
|
|
326
368
|
const eph = await makeEphemeral()
|
|
@@ -353,8 +395,18 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
353
395
|
s._ingest(p.env).catch(() => send(from, { type: ERROR, error: 'sobre inválido' }))
|
|
354
396
|
}
|
|
355
397
|
|
|
398
|
+
// POR DÓNDE VA EL TRÁFICO, AL LOG. Un agente no tiene topbar: sin esto, saber si una
|
|
399
|
+
// sesión subió a WebRTC o sigue dando la vuelta por el proxio pedía una prueba aparte.
|
|
400
|
+
// Lo escribe el pilar (`logStats`), que lee los clientes del proceso; aquí solo se enciende.
|
|
401
|
+
let net = null
|
|
402
|
+
if (!opts.quiet) {
|
|
403
|
+
const { logStats } = await import('@dotrino/proxy-client')
|
|
404
|
+
net = logStats({ label: 'remote-agent', everyMs: opts.netLogMs })
|
|
405
|
+
}
|
|
406
|
+
|
|
356
407
|
const stop = () => {
|
|
357
408
|
clearInterval(revTimer); clearInterval(sweeper)
|
|
409
|
+
if (net) net.stop().catch(() => {})
|
|
358
410
|
for (const s of sessions.values()) s.close()
|
|
359
411
|
try { client.close() } catch {}
|
|
360
412
|
}
|
|
@@ -386,6 +438,10 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
386
438
|
// tiene en el acta lo pone el dueño al emparejar y no sirve para saber qué atiende.
|
|
387
439
|
else if (payload.type === PING) send(from, { type: PONG, n: payload.n, kind: opts.label || null })
|
|
388
440
|
else if (payload.type === VMSG.REVOKED) handleRevoked(payload).catch(() => {})
|
|
441
|
+
// Un aviso de bloqueo o desbloqueo (`vault.admin.event`) no se cree: dispara traer la
|
|
442
|
+
// lista de la bóveda, que sí va firmada. Así el bloqueo cierra la sesión en el acto, en
|
|
443
|
+
// vez de esperar al tic de cinco minutos.
|
|
444
|
+
else if (payload.type === 'vault.admin.event' && (payload.body?.ev === 'blocked' || payload.body?.ev === 'unblocked')) refreshRevocations().catch(() => {})
|
|
389
445
|
})
|
|
390
446
|
|
|
391
447
|
if (!opts.quiet) {
|
|
@@ -393,7 +449,7 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
393
449
|
}
|
|
394
450
|
if (opts.onReady) { try { opts.onReady({ machine: myPub, machineId: myId, master }) } catch (_) {} }
|
|
395
451
|
|
|
396
|
-
return { machine: myPub, machineId: myId, master, client, close: stop }
|
|
452
|
+
return { machine: myPub, machineId: myId, master, client, reportIncident, isBlocked: (pub) => blockedSet.has(pub), close: stop }
|
|
397
453
|
}
|
|
398
454
|
|
|
399
455
|
export default { startRemoteAgent }
|