@dotrino/remote-agent 0.15.0 → 0.16.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 +1 -1
- package/protocol.js +4 -0
- package/src/agent.js +69 -4
- package/src/catch-up.js +29 -0
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.16.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",
|
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
|
@@ -26,6 +26,7 @@ import { sealersOf, memberCan, checkVaultReply } from '@dotrino/identity/acta'
|
|
|
26
26
|
import { adoptRecord } from './record.js'
|
|
27
27
|
import { installNodeGlobals } from '../node-globals.js'
|
|
28
28
|
import { makeEphemeral, deriveKey, seal, open } from '../e2e.js'
|
|
29
|
+
import { makeCatchUp } from './catch-up.js'
|
|
29
30
|
import { HS, ACK, DATA, PING, PONG, ERROR, VMSG, SIGN_SCOPE, SESSION_TTL_MS, REVOKE_REFRESH_MS } from '../protocol.js'
|
|
30
31
|
import { loadLink, saveLink, dataDir } from './link.js'
|
|
31
32
|
|
|
@@ -64,7 +65,7 @@ class AgentSession {
|
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
/**
|
|
67
|
-
* Arranca el agente. Devuelve `{ machine, machineId, master, close }`.
|
|
68
|
+
* Arranca el agente. Devuelve `{ machine, machineId, master, client, reportIncident, isBlocked, close }`.
|
|
68
69
|
*
|
|
69
70
|
* @param {object} opts
|
|
70
71
|
* @param {string} [opts.label] qué agente es (`terminal-agent`, `ia-agent`…): lo contesta el pong
|
|
@@ -161,10 +162,16 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
161
162
|
|
|
162
163
|
// --- Revocación: refrescar la lista del vault por el proxy (best-effort) ---
|
|
163
164
|
let revokedSet = new Set()
|
|
165
|
+
// LOS BLOQUEADOS (dueño, 2026-10-07): un aprobador bloqueó a ese aparato tras un incidente.
|
|
166
|
+
// No es una revocación —sigue en el acta, y se deshace en la bóveda—, así que va aparte y
|
|
167
|
+
// viaja con la lista de revocados. Se PERSISTE en el enlace: con la bóveda apagada, el
|
|
168
|
+
// agente tiene que seguir cerrándole la puerta a quien ya estaba bloqueado.
|
|
169
|
+
let blockedSet = new Set(Array.isArray(link.blocked) ? link.blocked : [])
|
|
164
170
|
async function refreshRevocations () {
|
|
165
171
|
try {
|
|
166
172
|
const res = await vaultRpc(VMSG.DEVICES, VMSG.DEVICES_RESULT, { op: 'devices', publickey: myPub, ts: Date.now() })
|
|
167
173
|
revokedSet = new Set((res.revoked || []).map((r) => r.nonce || r))
|
|
174
|
+
if (Array.isArray(res.blocked)) takeBlocked(res.blocked)
|
|
168
175
|
// El acta viaja con la lista. Apuntar su `seq` es lo que dispara la renovación en el
|
|
169
176
|
// mismo tic si el dueño nos cambió los permisos.
|
|
170
177
|
await anotarActa(res.acta)
|
|
@@ -173,6 +180,35 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
173
180
|
}
|
|
174
181
|
}
|
|
175
182
|
|
|
183
|
+
/** La lista nueva de bloqueados: se guarda, y a quien acaba de entrar en ella se le cierra la sesión. */
|
|
184
|
+
function takeBlocked (pubs) {
|
|
185
|
+
const next = new Set(pubs.filter((p) => typeof p === 'string'))
|
|
186
|
+
const same = next.size === blockedSet.size && [...next].every((p) => blockedSet.has(p))
|
|
187
|
+
blockedSet = next
|
|
188
|
+
if (same) return
|
|
189
|
+
link.blocked = [...next]
|
|
190
|
+
try { saveLink(dir, link) } catch (_) {}
|
|
191
|
+
for (const [sid, s] of sessions) {
|
|
192
|
+
if (!blockedSet.has(s.device)) continue
|
|
193
|
+
s.close(); sessions.delete(sid)
|
|
194
|
+
audit('session-blocked', { sid: sid.slice(0, 8) })
|
|
195
|
+
if (!opts.quiet) console.log(`[remote-agent] session ${sid.slice(0, 8)} closed: that device is blocked`)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* REPORTAR UN INCIDENTE sobre otro aparato de la cuenta (`about`, su pubkey): la bóveda lo
|
|
201
|
+
* pone en la mesa de quien aprueba, que bloquea o ignora. Devuelve lo que contestó la
|
|
202
|
+
* bóveda: `{ id, approvers, blocked }` (`id: null` si nadie aprueba o ya está bloqueado).
|
|
203
|
+
* No decide nada aquí: un incidente es información, y el freno local es cosa de la app.
|
|
204
|
+
*/
|
|
205
|
+
async function reportIncident ({ kind, about, tries = null }) {
|
|
206
|
+
if (typeof kind !== 'string' || !kind || typeof about !== 'string' || !about) throw new Error('reportIncident: kind and about are required')
|
|
207
|
+
audit('incident', { kind, device: (await pubkeyId(about).catch(() => '')).slice(0, 8).toUpperCase(), tries })
|
|
208
|
+
const res = await vaultRpc(VMSG.INCIDENT, VMSG.INCIDENT_RESULT, { op: 'incident', kind, about, tries, publickey: myPub, ts: Date.now() })
|
|
209
|
+
return { id: res.id ?? null, approvers: res.approvers ?? 0, blocked: !!res.blocked }
|
|
210
|
+
}
|
|
211
|
+
|
|
176
212
|
/**
|
|
177
213
|
* RENOVACIÓN del cert (`vault.renew`). YA NO ES UNA TAREA DE CALENDARIO: el papel no
|
|
178
214
|
* vence, lleva el `seq` del acta con el que se emitió. Se pide uno nuevo cuando el ACTA
|
|
@@ -259,6 +295,8 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
259
295
|
}
|
|
260
296
|
|
|
261
297
|
const vaultTick = async () => { await refreshRevocations(); await renewCertIfNeeded() }
|
|
298
|
+
/** Trae el acta ahora (con freno) y dice si AVANZÓ: solo entonces merece juzgar otra vez. */
|
|
299
|
+
const catchUp = makeCatchUp({ refresh: vaultTick, seq: () => link.actaSeq })
|
|
262
300
|
vaultTick()
|
|
263
301
|
const revTimer = setInterval(vaultTick, REVOKE_REFRESH_MS); revTimer.unref?.()
|
|
264
302
|
|
|
@@ -280,8 +318,25 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
280
318
|
// Manda el acta, no una llave fija: con varias selladoras el papel de un peer puede
|
|
281
319
|
// venir firmado por otra bóveda del mismo perfil. Sin acta no se atiende — no hay con
|
|
282
320
|
// qué decidir, así que no se decide que sí.
|
|
283
|
-
|
|
284
|
-
|
|
321
|
+
// SE JUZGA, Y SI NO PASA SE PREGUNTA A LA BÓVEDA ANTES DE DECIR QUE NO. El acta se trae cada
|
|
322
|
+
// `REVOKE_REFRESH_MS` (5 min), así que un aparato recién emparejado era un desconocido hasta
|
|
323
|
+
// el siguiente tic: «este perfil no reconoce a ese aparato» nada más emparejar (un iPhone, el
|
|
324
|
+
// 2026-10-07). Ahora, ante un rechazo, se trae el acta EN EL MOMENTO y se juzga otra vez, una
|
|
325
|
+
// sola — y solo si el acta avanzó. Con freno (`catchUp`): cualquiera puede mandar saludos, y
|
|
326
|
+
// no por eso se le pregunta a la bóveda cada vez.
|
|
327
|
+
const judge = async () => {
|
|
328
|
+
const chk = await verifyChain({ data, signature, cert, expectedScope: SIGN_SCOPE, ...contextoActa(), revoked: revokedSet })
|
|
329
|
+
if (!chk.ok) return { error: 'no autorizado: ' + chk.reason }
|
|
330
|
+
return { chk }
|
|
331
|
+
}
|
|
332
|
+
let verdict = await judge()
|
|
333
|
+
const known = (v) => {
|
|
334
|
+
const acta = actaDeConfianza()
|
|
335
|
+
return !!acta && (acta.members || []).some((m) => m?.pub === v.chk.device) && memberCan(acta, v.chk.device, 'sign')
|
|
336
|
+
}
|
|
337
|
+
if ((verdict.error || !known(verdict)) && await catchUp()) verdict = await judge()
|
|
338
|
+
if (verdict.error) return send(from, { type: ERROR, error: verdict.error })
|
|
339
|
+
const chk = verdict.chk
|
|
285
340
|
// CERT ∩ ACTA. `verifyChain` comprueba QUIÉN EMITIÓ el papel; que el APARATO siga siendo
|
|
286
341
|
// isMember y pueda firmar lo dice el acta, y hay que preguntárselo aparte. Sin esto, un
|
|
287
342
|
// papel bien firmado por una selladora abría sesión aunque el acta no nombrara a ese
|
|
@@ -301,6 +356,12 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
301
356
|
if (!acta || !isMember || !memberCan(acta, chk.device, 'sign')) {
|
|
302
357
|
return send(from, { type: ERROR, error: 'no autorizado: acta — este perfil no reconoce a ese aparato' })
|
|
303
358
|
}
|
|
359
|
+
// BLOQUEADO por un aprobador: está en el acta y su papel vale, y aun así no entra. Se le
|
|
360
|
+
// dice con su código, que no es lo mismo que un papel viejo ni que una revocación.
|
|
361
|
+
if (blockedSet.has(chk.device)) {
|
|
362
|
+
audit('session-refused', { reason: 'blocked', device: (await pubkeyId(chk.device)).slice(0, 8).toUpperCase() })
|
|
363
|
+
return send(from, { type: ERROR, code: 'blocked', error: 'no autorizado: bloqueado — un aprobador bloqueó este aparato; se desbloquea en la bóveda' })
|
|
364
|
+
}
|
|
304
365
|
if (data.op !== HS || typeof data.eph !== 'string') return send(from, { type: ERROR, error: 'handshake inválido' })
|
|
305
366
|
|
|
306
367
|
const eph = await makeEphemeral()
|
|
@@ -366,6 +427,10 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
366
427
|
// tiene en el acta lo pone el dueño al emparejar y no sirve para saber qué atiende.
|
|
367
428
|
else if (payload.type === PING) send(from, { type: PONG, n: payload.n, kind: opts.label || null })
|
|
368
429
|
else if (payload.type === VMSG.REVOKED) handleRevoked(payload).catch(() => {})
|
|
430
|
+
// Un aviso de bloqueo o desbloqueo (`vault.admin.event`) no se cree: dispara traer la
|
|
431
|
+
// lista de la bóveda, que sí va firmada. Así el bloqueo cierra la sesión en el acto, en
|
|
432
|
+
// vez de esperar al tic de cinco minutos.
|
|
433
|
+
else if (payload.type === 'vault.admin.event' && (payload.body?.ev === 'blocked' || payload.body?.ev === 'unblocked')) refreshRevocations().catch(() => {})
|
|
369
434
|
})
|
|
370
435
|
|
|
371
436
|
if (!opts.quiet) {
|
|
@@ -373,7 +438,7 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
373
438
|
}
|
|
374
439
|
if (opts.onReady) { try { opts.onReady({ machine: myPub, machineId: myId, master }) } catch (_) {} }
|
|
375
440
|
|
|
376
|
-
return { machine: myPub, machineId: myId, master, client, close: stop }
|
|
441
|
+
return { machine: myPub, machineId: myId, master, client, reportIncident, isBlocked: (pub) => blockedSet.has(pub), close: stop }
|
|
377
442
|
}
|
|
378
443
|
|
|
379
444
|
export default { startRemoteAgent }
|
package/src/catch-up.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* catch-up.js — ponerse al día con la bóveda ANTES de rechazar a alguien.
|
|
3
|
+
*
|
|
4
|
+
* El agente trae el acta cada pocos minutos. Entre un tic y el siguiente no sabe de un aparato
|
|
5
|
+
* recién emparejado, y lo rechazaba. `makeCatchUp` da una función que trae el acta en el momento
|
|
6
|
+
* y contesta si avanzó — con un freno, porque un saludo lo puede mandar cualquiera y no por eso se
|
|
7
|
+
* le pregunta a la bóveda cada vez.
|
|
8
|
+
*
|
|
9
|
+
* @param {{ refresh: () => Promise<void>, seq: () => (number|null|undefined), minGapMs?: number, now?: () => number }} o
|
|
10
|
+
* @returns {() => Promise<boolean>} true si el acta avanzó (y vale la pena juzgar otra vez).
|
|
11
|
+
*/
|
|
12
|
+
export const CATCH_UP_GAP_MS = 10_000
|
|
13
|
+
|
|
14
|
+
export function makeCatchUp ({ refresh, seq, minGapMs = CATCH_UP_GAP_MS, now = Date.now }) {
|
|
15
|
+
let last = -Infinity
|
|
16
|
+
let running = null
|
|
17
|
+
return async () => {
|
|
18
|
+
// Uno a la vez: los saludos que lleguen mientras tanto esperan a ese mismo y usan su resultado.
|
|
19
|
+
if (running) return running
|
|
20
|
+
if (now() - last < minGapMs) return false
|
|
21
|
+
last = now()
|
|
22
|
+
const before = seq()
|
|
23
|
+
running = (async () => {
|
|
24
|
+
try { await refresh() } catch (_) { return false }
|
|
25
|
+
return seq() !== before
|
|
26
|
+
})().finally(() => { running = null })
|
|
27
|
+
return running
|
|
28
|
+
}
|
|
29
|
+
}
|