@dotrino/remote-agent 0.14.0 → 0.15.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 CHANGED
@@ -182,6 +182,19 @@ extraerlo luego a este paquete.
182
182
  - **Auditoría** local de cada sesión abierta.
183
183
  - El relay de transporte (`proxy.dotrino.com`) **solo ve bytes cifrados**.
184
184
 
185
+ ## Por dónde viaja: el camino más directo (0.15.0+)
186
+
187
+ El saludo sale por el proxio, dirigido a la pubkey del agente. En cuanto el agente contesta,
188
+ las dos puntas se hablan **por token**, y el pilar sube solo a un canal WebRTC: en la misma
189
+ red los datos de la sesión no salen a internet. Si el canal no sale, sigue por el proxio.
190
+
191
+ - **En Node hace falta una implementación de WebRTC.** El agente que quiera canal directo
192
+ declara `@dotrino/webrtc` en sus `optionalDependencies`; sin él, todo sigue por el proxio.
193
+ - **Quién puede negociar un canal directo:** en el agente, solo un token con sesión abierta;
194
+ en el cliente, solo el token que contestó su saludo.
195
+ - Si el agente se reinicia, su token muere: el mensaje sale por su pubkey, el agente
196
+ contesta `unknown-session` y el cliente vuelve a saludar.
197
+
185
198
  ## Dependencias
186
199
 
187
200
  - `@dotrino/identity` (capabilities: `verifyChain`, `signWithDevice`, `verifyDeviceSig`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/remote-agent",
3
- "version": "0.14.0",
3
+ "version": "0.15.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",
@@ -47,12 +47,12 @@
47
47
  ],
48
48
  "peerDependencies": {
49
49
  "@dotrino/identity": ">=0.92.0",
50
- "@dotrino/proxy-client": ">=0.18.2",
50
+ "@dotrino/proxy-client": ">=0.29.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.25.0",
55
+ "@dotrino/proxy-client": "0.29.0",
56
56
  "@dotrino/vault": "0.75.0"
57
57
  }
58
58
  }
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
 
@@ -97,14 +98,23 @@ export async function startRemoteAgent (opts = {}) {
97
98
 
98
99
  installNodeGlobals(dir)
99
100
 
101
+ // sid -> AgentSession
102
+ const sessions = new Map()
103
+ const hasSessionFrom = (token) => { for (const s of sessions.values()) if (s.from === token) return true; return false }
104
+
100
105
  const proxyUrl = opts.proxyUrl || process.env.PROXY_URL || link.proxy || 'wss://proxy.dotrino.com'
101
106
  // `client` inyectado: solo para las pruebas (transporte de mentira). En producción se
102
107
  // levanta el del ecosistema — no hay otro transporte.
103
108
  const client = opts.client || await (async () => {
104
109
  const { getWebSocketProxyClient } = await import('@dotrino/proxy-client')
110
+ // SIEMPRE EL CAMINO MÁS DIRECTO: WebRTC encendido (si la máquina tiene con qué: el
111
+ // pilar carga `@dotrino/webrtc` si está instalado, y si no sigue por el proxio).
112
+ // Solo negocia canal directo quien YA tiene una sesión abierta: negociar arranca
113
+ // DTLS/ICE/SCTP, y eso no lo dispara cualquiera que conozca el token.
105
114
  const c = getWebSocketProxyClient({
106
- url: proxyUrl, enableWebRTC: false, autoReconnect: true,
107
- maxReconnectAttempts: 100000, reconnectDelay: 4000
115
+ url: proxyUrl, autoReconnect: true,
116
+ maxReconnectAttempts: 100000, reconnectDelay: 4000,
117
+ acceptDirectFrom: (token) => hasSessionFrom(token)
108
118
  })
109
119
  await c.connect()
110
120
  return c
@@ -250,11 +260,11 @@ export async function startRemoteAgent (opts = {}) {
250
260
  }
251
261
 
252
262
  const vaultTick = async () => { await refreshRevocations(); await renewCertIfNeeded() }
263
+ /** Trae el acta ahora (con freno) y dice si AVANZÓ: solo entonces merece juzgar otra vez. */
264
+ const catchUp = makeCatchUp({ refresh: vaultTick, seq: () => link.actaSeq })
253
265
  vaultTick()
254
266
  const revTimer = setInterval(vaultTick, REVOKE_REFRESH_MS); revTimer.unref?.()
255
267
 
256
- // sid -> AgentSession
257
- const sessions = new Map()
258
268
  const sweeper = setInterval(() => {
259
269
  const now = Date.now()
260
270
  for (const [sid, s] of sessions) {
@@ -273,8 +283,25 @@ export async function startRemoteAgent (opts = {}) {
273
283
  // Manda el acta, no una llave fija: con varias selladoras el papel de un peer puede
274
284
  // venir firmado por otra bóveda del mismo perfil. Sin acta no se atiende — no hay con
275
285
  // qué decidir, así que no se decide que sí.
276
- const chk = await verifyChain({ data, signature, cert, expectedScope: SIGN_SCOPE, ...contextoActa(), revoked: revokedSet })
277
- if (!chk.ok) return send(from, { type: ERROR, error: 'no autorizado: ' + chk.reason })
286
+ // SE JUZGA, Y SI NO PASA SE PREGUNTA A LA BÓVEDA ANTES DE DECIR QUE NO. El acta se trae cada
287
+ // `REVOKE_REFRESH_MS` (5 min), así que un aparato recién emparejado era un desconocido hasta
288
+ // el siguiente tic: «este perfil no reconoce a ese aparato» nada más emparejar (un iPhone, el
289
+ // 2026-10-07). Ahora, ante un rechazo, se trae el acta EN EL MOMENTO y se juzga otra vez, una
290
+ // sola — y solo si el acta avanzó. Con freno (`catchUp`): cualquiera puede mandar saludos, y
291
+ // no por eso se le pregunta a la bóveda cada vez.
292
+ const judge = async () => {
293
+ const chk = await verifyChain({ data, signature, cert, expectedScope: SIGN_SCOPE, ...contextoActa(), revoked: revokedSet })
294
+ if (!chk.ok) return { error: 'no autorizado: ' + chk.reason }
295
+ return { chk }
296
+ }
297
+ let verdict = await judge()
298
+ const known = (v) => {
299
+ const acta = actaDeConfianza()
300
+ return !!acta && (acta.members || []).some((m) => m?.pub === v.chk.device) && memberCan(acta, v.chk.device, 'sign')
301
+ }
302
+ if ((verdict.error || !known(verdict)) && await catchUp()) verdict = await judge()
303
+ if (verdict.error) return send(from, { type: ERROR, error: verdict.error })
304
+ const chk = verdict.chk
278
305
  // CERT ∩ ACTA. `verifyChain` comprueba QUIÉN EMITIÓ el papel; que el APARATO siga siendo
279
306
  // isMember y pueda firmar lo dice el acta, y hay que preguntárselo aparte. Sin esto, un
280
307
  // papel bien firmado por una selladora abría sesión aunque el acta no nombrara a ese
@@ -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
+ }
package/src/client.js CHANGED
@@ -43,6 +43,10 @@ export class RemoteAgentClient {
43
43
  this.sid = null
44
44
  this._h = { message: [], error: [], resumed: [] }
45
45
  this._resuming = null
46
+ // El token del agente: una vez que contesta el saludo se le habla POR TOKEN, que es lo
47
+ // único que sube a WebRTC. `null` es «todavía no lo sé» y se va por su pubkey.
48
+ this.agentToken = null
49
+ this._directPeers = new Set()
46
50
  }
47
51
 
48
52
  /** Suscribe a eventos. Devuelve un `off()` para desuscribir. Eventos: message, error, resumed. */
@@ -90,13 +94,27 @@ export class RemoteAgentClient {
90
94
  async connect () {
91
95
  if (!this.agentPubkey) throw new Error('falta la dirección del agente destino')
92
96
  const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
93
- this.client = new WebSocketProxyClient({ url: this.proxyUrl, enableWebRTC: false, autoReconnect: true })
97
+ // SIEMPRE EL CAMINO MÁS DIRECTO: WebRTC encendido. Estuvo en `false` desde el 0.1.0
98
+ // porque entonces Node no tenía con qué; se quedó así cuando ya lo tuvo, y dos equipos
99
+ // en la misma red se hablaban dando la vuelta por el proxio.
100
+ // Solo negocia canal directo quien contestó NUESTRO saludo.
101
+ this.client = new WebSocketProxyClient({
102
+ url: this.proxyUrl, autoReconnect: true,
103
+ acceptDirectFrom: (token) => this._directPeers.has(token)
104
+ })
94
105
  await this.client.connect()
95
106
  await this._identify()
96
107
  if (this.link.mode !== 'self') {
97
108
  this.client.on('token', () => { this._identify().catch(() => {}) })
98
109
  }
99
110
 
111
+ // El agente se reinició: su token ya no existe. El pilar ya mandó ese mensaje por su
112
+ // pubkey; aquí solo se deja de usar el token hasta el próximo saludo.
113
+ this.client.on('token_gone', (token) => {
114
+ this._directPeers.delete(token)
115
+ if (token === this.agentToken) this.agentToken = null
116
+ })
117
+
100
118
  this.client.on('message', async (_from, p) => {
101
119
  if (!p || typeof p !== 'object') return
102
120
  if (p.type === DATA && p.sid === this.sid) {
@@ -151,15 +169,23 @@ export class RemoteAgentClient {
151
169
  // varias sesiones simultáneas (varios RemoteAgentClient sobre la misma pubkey)
152
170
  // no se roban el ACK de la otra.
153
171
  const acked = new Promise((resolve, reject) => {
154
- const off = this.client.on('message', (_from, p) => {
172
+ const off = this.client.on('message', (from, p) => {
155
173
  if (!p || typeof p !== 'object') return
156
- if (p.type === ACK && p.ack && p.ack.ceph === eph.pub) { off(); resolve(p) }
174
+ // El token se apunta AQUÍ, en el mismo tic: si el agente es quien abre el canal
175
+ // directo, su oferta llega justo detrás del ack y tiene que encontrarlo ya.
176
+ if (p.type === ACK && p.ack && p.ack.ceph === eph.pub) { off(); if (from) this._directPeers.add(from); resolve({ ...p, from }) }
157
177
  else if (p.type === ERROR) { off(); reject(new Error(p.error)) }
158
178
  })
159
179
  setTimeout(() => { off(); reject(new Error('el agente no respondió (¿está corriendo allí?)')) }, 20000)
160
180
  })
161
181
  this.client.sendByPubkey(this.agentPubkey, { type: HS, data, signature, cert })
162
182
  const res = await acked
183
+ try { await this._checkAck(res, eph) } catch (e) { this._directPeers.delete(res.from); throw e }
184
+ this.agentToken = res.from || null
185
+ }
186
+
187
+ /** Comprueba el ack y deja `sid` y `key`. */
188
+ async _checkAck (res, eph) {
163
189
 
164
190
  // El ack debe: (1) encadenar a NUESTRA maestra, (2) estar firmado por el agente
165
191
  // que apuntamos, (3) atar nuestra pub efímera y el sid.
@@ -189,7 +215,11 @@ export class RemoteAgentClient {
189
215
  if (this._resuming) await this._resuming
190
216
  if (!this.key) throw Object.assign(new Error('no session with the agent'), { code: 'no-session' })
191
217
  const env = await seal(this.key, payload)
192
- this.client.sendByPubkey(this.agentPubkey, { type: DATA, sid: this.sid, env })
218
+ const msg = { type: DATA, sid: this.sid, env }
219
+ // Por token en cuanto se sabe: sube al canal directo, y si el token murió el pilar lo
220
+ // saca por la pubkey. Por pubkey solo mientras no hay token.
221
+ if (this.agentToken) this.client.sendToOrQueue(this.agentToken, msg, { peerPubkey: this.agentPubkey })
222
+ else this.client.sendByPubkey(this.agentPubkey, msg)
193
223
  }
194
224
 
195
225
  /** Sonda de presencia (liveness) sin abrir sesión. Devuelve true si responde. */