@dotrino/remote-agent 0.17.0 → 0.18.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/remote-agent",
3
- "version": "0.17.0",
3
+ "version": "0.18.1",
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
@@ -39,6 +39,11 @@ export const SIGN_SCOPE = 'vault:sign'
39
39
  /** Vigencia de una sesión inactiva y refresco de la lista de revocados. */
40
40
  export const SESSION_TTL_MS = 30 * 60 * 1000
41
41
  export const REVOKE_REFRESH_MS = 5 * 60 * 1000
42
+ /**
43
+ * Cuánto se espera a un cliente cuya conexión se fue antes de cerrarle la sesión. Un cliente
44
+ * que reconecta sigue con su sesión desde un token nuevo: si en este plazo manda algo, se queda.
45
+ */
46
+ export const SESSION_GONE_MS = 20 * 1000
42
47
 
43
48
  /**
44
49
  * RENOVACIÓN del cert de esta máquina. El cert dura 30 días; se pide uno fresco
package/src/agent.js CHANGED
@@ -27,17 +27,18 @@ import { adoptRecord } from './record.js'
27
27
  import { installNodeGlobals } from '../node-globals.js'
28
28
  import { makeEphemeral, deriveKey, seal, open } from '../e2e.js'
29
29
  import { makeCatchUp } from './catch-up.js'
30
- import { HS, ACK, DATA, PING, PONG, ERROR, VMSG, SIGN_SCOPE, SESSION_TTL_MS, REVOKE_REFRESH_MS } from '../protocol.js'
30
+ import { HS, ACK, DATA, PING, PONG, ERROR, VMSG, SIGN_SCOPE, SESSION_TTL_MS, SESSION_GONE_MS, REVOKE_REFRESH_MS } from '../protocol.js'
31
31
  import { loadLink, saveLink, dataDir } from './link.js'
32
32
 
33
33
  /** Sesión cifrada con un dispositivo cliente. La app recibe esto en `onSession`. */
34
34
  class AgentSession {
35
- constructor ({ sid, key, from, device, send }) {
35
+ constructor ({ sid, key, from, device, send, isDirect = null }) {
36
36
  this.sid = sid
37
37
  this.key = key
38
38
  this.from = from // token del cliente en el proxy
39
39
  this.device = device // pubkey del dispositivo cliente
40
40
  this._send = send // (to, obj) => void
41
+ this._isDirect = isDirect // (token) => boolean, o null si el transporte no lo sabe
41
42
  this._h = { message: [], close: [] }
42
43
  this._closed = false
43
44
  }
@@ -49,6 +50,33 @@ class AgentSession {
49
50
  if (env) this._send(this.from, { type: DATA, sid: this.sid, env })
50
51
  }
51
52
 
53
+ /**
54
+ * ¿VA YA POR EL CANAL DIRECTO (WebRTC) lo que se le mande a este cliente? Mientras sea
55
+ * `false`, cada `send` da la vuelta por el proxio. Sirve para que la app decida qué manda
56
+ * YA y qué puede esperar: nunca para dejar de mandar (subir de escalón no bloquea).
57
+ */
58
+ direct () {
59
+ if (this._closed || !this._isDirect) return false
60
+ try { return !!this._isDirect(this.from) } catch (_) { return false }
61
+ }
62
+
63
+ /**
64
+ * Espera a que abra el canal directo, como mucho `ms`. Resuelve `true` si abrió y `false`
65
+ * si se acabó el plazo o la sesión se cerró — en los dos casos se sigue, por donde haya.
66
+ */
67
+ whenDirect (ms = 8000) {
68
+ return new Promise((resolve) => {
69
+ const until = Date.now() + ms
70
+ const look = () => {
71
+ if (this.direct()) return resolve(true)
72
+ if (this._closed || Date.now() >= until) return resolve(false)
73
+ const t = setTimeout(look, 100)
74
+ t.unref?.()
75
+ }
76
+ look()
77
+ })
78
+ }
79
+
52
80
  on (ev, cb) { this._h[ev]?.push(cb); return this }
53
81
 
54
82
  async _ingest (env) {
@@ -60,6 +88,7 @@ class AgentSession {
60
88
  close () {
61
89
  if (this._closed) return
62
90
  this._closed = true
91
+ clearTimeout(this._gone)
63
92
  for (const h of this._h.close) { try { h() } catch (_) {} }
64
93
  }
65
94
  }
@@ -77,6 +106,7 @@ class AgentSession {
77
106
  * @param {()=>void} [opts.onRevoked] se llamó al auto-borrarse por revocación.
78
107
  * @param {()=>void} [opts.onReady] agente listo y escuchando.
79
108
  * @param {boolean} [opts.quiet] sin logs.
109
+ * @param {number} [opts.sessionGoneMs] cuánto se espera a un cliente cuya conexión se fue (`SESSION_GONE_MS`).
80
110
  * @param {number} [opts.netLogMs] cada cuánto van al log las estadísticas de red (5 min).
81
111
  * @param {object} [opts.client] transporte ya conectado (SOLO pruebas).
82
112
  *
@@ -375,7 +405,7 @@ export async function startRemoteAgent (opts = {}) {
375
405
  const ack = { op: ACK, sid, seph: eph.pub, ceph: data.eph, machine: myPub, publickey: myPub, ts: Date.now() }
376
406
  const { signature: ackSig } = await signWithDevice({ privateJwk: link.device.privateJwk, data: ack })
377
407
 
378
- const session = new AgentSession({ sid, key, from, device: chk.device, send })
408
+ const session = new AgentSession({ sid, key, from, device: chk.device, send, isDirect: typeof client.isWebRTCOpen === 'function' ? (t) => client.isWebRTCOpen(t) : null })
379
409
  session._exp = Date.now() + SESSION_TTL_MS
380
410
  sessions.set(sid, session)
381
411
  audit('session-open', { sid: sid.slice(0, 8), device: (await pubkeyId(chk.device)).slice(0, 8).toUpperCase() })
@@ -392,9 +422,27 @@ export async function startRemoteAgent (opts = {}) {
392
422
  if (!s) return send(from, { type: ERROR, code: 'unknown-session', sid: p.sid, error: 'sesión desconocida o expirada' })
393
423
  s._exp = Date.now() + SESSION_TTL_MS
394
424
  s.from = from
425
+ if (s._gone) { clearTimeout(s._gone); s._gone = null }
395
426
  s._ingest(p.env).catch(() => send(from, { type: ERROR, error: 'sobre inválido' }))
396
427
  }
397
428
 
429
+ // LA CONEXIÓN DEL CLIENTE SE FUE: su sesión no se queda 30 minutos. El proxio avisa de que ese
430
+ // token murió (`peer_disconnected`); un token no vuelve, así que quien sigue vivo manda desde
431
+ // otro y la sesión se queda (`handleData`). Si en `SESSION_GONE_MS` no llega nada, se cierra.
432
+ // Antes solo vencía por `SESSION_TTL_MS`: un teléfono que volvía del fondo abría sesión nueva
433
+ // y la vieja seguía enganchada a su consola (seis del mismo aparato en una, el 2026-10-09).
434
+ const goneMs = opts.sessionGoneMs ?? SESSION_GONE_MS
435
+ client.on('peer_disconnected', (token) => {
436
+ for (const [sid, s] of sessions) {
437
+ if (s.from !== token || s._gone) continue
438
+ s._gone = setTimeout(() => {
439
+ s.close(); sessions.delete(sid)
440
+ audit('session-gone', { sid: sid.slice(0, 8) })
441
+ }, goneMs)
442
+ s._gone.unref?.()
443
+ }
444
+ })
445
+
398
446
  // POR DÓNDE VA EL TRÁFICO, AL LOG. Un agente no tiene topbar: sin esto, saber si una
399
447
  // sesión subió a WebRTC o sigue dando la vuelta por el proxio pedía una prueba aparte.
400
448
  // Lo escribe el pilar (`logStats`), que lee los clientes del proceso; aquí solo se enciende.