@dotrino/remote-agent 0.13.0 → 0.15.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 CHANGED
@@ -141,6 +141,16 @@ chat.send({ type: 'msg', text: 'arreglá el bug en auth.js' })
141
141
  const online = await chat.ping() // liveness sin abrir sesión
142
142
  ```
143
143
 
144
+ **Si el agente se reinicia, el cliente vuelve a saludar solo** (≥ 0.14.0). Las sesiones viven
145
+ en la memoria del agente: tras reiniciarse contesta `unknown-session` a lo que le llegue con un
146
+ `sid` viejo, el cliente abre otra sesión y avisa con `resumed`. Lo que la app tenía abierto allí
147
+ (una consola, una conversación) puede no existir ya: eso lo decide la app en ese evento. Lo que
148
+ se mande mientras tanto sale con la sesión nueva.
149
+
150
+ ```js
151
+ chat.on('resumed', () => volverAEngancharme()) // p. ej. la terminal: attach, u open si ya no está
152
+ ```
153
+
144
154
  **Encontrar los agentes de UN tipo: pregúntaselo a ellos, no al nombre del acta.** El label
145
155
  del acta lo pone el dueño al emparejar («TerminalLocal»), así que filtrar por él no encuentra
146
156
  nada. El pong de cada agente dice qué es (el `label` con que arrancó `startRemoteAgent`):
@@ -172,6 +182,19 @@ extraerlo luego a este paquete.
172
182
  - **Auditoría** local de cada sesión abierta.
173
183
  - El relay de transporte (`proxy.dotrino.com`) **solo ve bytes cifrados**.
174
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
+
175
198
  ## Dependencias
176
199
 
177
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.13.0",
3
+ "version": "0.15.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.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
@@ -97,14 +97,23 @@ export async function startRemoteAgent (opts = {}) {
97
97
 
98
98
  installNodeGlobals(dir)
99
99
 
100
+ // sid -> AgentSession
101
+ const sessions = new Map()
102
+ const hasSessionFrom = (token) => { for (const s of sessions.values()) if (s.from === token) return true; return false }
103
+
100
104
  const proxyUrl = opts.proxyUrl || process.env.PROXY_URL || link.proxy || 'wss://proxy.dotrino.com'
101
105
  // `client` inyectado: solo para las pruebas (transporte de mentira). En producción se
102
106
  // levanta el del ecosistema — no hay otro transporte.
103
107
  const client = opts.client || await (async () => {
104
108
  const { getWebSocketProxyClient } = await import('@dotrino/proxy-client')
109
+ // SIEMPRE EL CAMINO MÁS DIRECTO: WebRTC encendido (si la máquina tiene con qué: el
110
+ // pilar carga `@dotrino/webrtc` si está instalado, y si no sigue por el proxio).
111
+ // Solo negocia canal directo quien YA tiene una sesión abierta: negociar arranca
112
+ // DTLS/ICE/SCTP, y eso no lo dispara cualquiera que conozca el token.
105
113
  const c = getWebSocketProxyClient({
106
- url: proxyUrl, enableWebRTC: false, autoReconnect: true,
107
- maxReconnectAttempts: 100000, reconnectDelay: 4000
114
+ url: proxyUrl, autoReconnect: true,
115
+ maxReconnectAttempts: 100000, reconnectDelay: 4000,
116
+ acceptDirectFrom: (token) => hasSessionFrom(token)
108
117
  })
109
118
  await c.connect()
110
119
  return c
@@ -253,8 +262,6 @@ export async function startRemoteAgent (opts = {}) {
253
262
  vaultTick()
254
263
  const revTimer = setInterval(vaultTick, REVOKE_REFRESH_MS); revTimer.unref?.()
255
264
 
256
- // sid -> AgentSession
257
- const sessions = new Map()
258
265
  const sweeper = setInterval(() => {
259
266
  const now = Date.now()
260
267
  for (const [sid, s] of sessions) {
@@ -317,7 +324,10 @@ export async function startRemoteAgent (opts = {}) {
317
324
 
318
325
  function handleData (from, p) {
319
326
  const s = sessions.get(p.sid)
320
- if (!s) return send(from, { type: ERROR, error: 'sesión desconocida o expirada' })
327
+ // Con `code` y el `sid`: así el cliente sabe que es SU sesión la que se perdió (el agente
328
+ // se reinició, o venció) y vuelve a saludar solo, en vez de quedarse con un error. Se
329
+ // comprueba por el código, no por la frase.
330
+ if (!s) return send(from, { type: ERROR, code: 'unknown-session', sid: p.sid, error: 'sesión desconocida o expirada' })
321
331
  s._exp = Date.now() + SESSION_TTL_MS
322
332
  s.from = from
323
333
  s._ingest(p.env).catch(() => send(from, { type: ERROR, error: 'sobre inválido' }))
package/src/client.js CHANGED
@@ -41,10 +41,15 @@ export class RemoteAgentClient {
41
41
  this.client = null
42
42
  this.key = null
43
43
  this.sid = null
44
- this._h = { message: [], error: [] }
44
+ this._h = { message: [], error: [], resumed: [] }
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()
45
50
  }
46
51
 
47
- /** Suscribe a eventos. Devuelve un `off()` para desuscribir. Eventos: message, error. */
52
+ /** Suscribe a eventos. Devuelve un `off()` para desuscribir. Eventos: message, error, resumed. */
48
53
  on (ev, cb) {
49
54
  if (!this._h[ev]) this._h[ev] = []
50
55
  this._h[ev].push(cb)
@@ -89,22 +94,66 @@ export class RemoteAgentClient {
89
94
  async connect () {
90
95
  if (!this.agentPubkey) throw new Error('falta la dirección del agente destino')
91
96
  const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
92
- 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
+ })
93
105
  await this.client.connect()
94
106
  await this._identify()
95
107
  if (this.link.mode !== 'self') {
96
108
  this.client.on('token', () => { this._identify().catch(() => {}) })
97
109
  }
98
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
+
99
118
  this.client.on('message', async (_from, p) => {
100
119
  if (!p || typeof p !== 'object') return
101
120
  if (p.type === DATA && p.sid === this.sid) {
102
121
  try { const m = await open(this.key, p.env); this._emit('message', m) } catch {}
103
122
  } else if (p.type === ERROR) {
104
- this._emit('error', new Error(p.error))
123
+ this._onAgentError(p)
105
124
  }
106
125
  })
107
126
 
127
+ await this._handshake()
128
+ return this
129
+ }
130
+
131
+ /**
132
+ * Un error del agente. Si es que ya no conoce NUESTRA sesión (se reinició: las sesiones
133
+ * viven en su memoria), se vuelve a saludar solo y se avisa con `resumed`; lo que la app
134
+ * tenía abierto allí lo decide la app. Cualquier otro error, o no poder volver a saludar,
135
+ * sale por `error`. Un `unknown-session` de un `sid` que ya no es el nuestro (llegó tarde,
136
+ * de antes de volver a saludar) no dice nada nuevo y se ignora.
137
+ */
138
+ _onAgentError (p) {
139
+ if (p.code !== 'unknown-session') { this._emit('error', Object.assign(new Error(p.error), { code: p.code })); return }
140
+ if (p.sid !== this.sid) return
141
+ this._resume()
142
+ }
143
+
144
+ /** Vuelve a saludar, una sola vez aunque lleguen varios errores seguidos. */
145
+ _resume () {
146
+ if (this._resuming) return this._resuming
147
+ this.key = null
148
+ this._resuming = this._handshake()
149
+ .then(() => { this._emit('resumed', { sid: this.sid }) })
150
+ .catch((e) => { this._emit('error', e) })
151
+ .finally(() => { this._resuming = null })
152
+ return this._resuming
153
+ }
154
+
155
+ /** El saludo: abre una sesión con el agente y deja `sid` y `key`. */
156
+ async _handshake () {
108
157
  const eph = await makeEphemeral()
109
158
  // El self-cert P←P del modo self puede vencerse (24 h): refrescarlo si hace falta.
110
159
  let cert = this.link.cert
@@ -120,15 +169,23 @@ export class RemoteAgentClient {
120
169
  // varias sesiones simultáneas (varios RemoteAgentClient sobre la misma pubkey)
121
170
  // no se roban el ACK de la otra.
122
171
  const acked = new Promise((resolve, reject) => {
123
- const off = this.client.on('message', (_from, p) => {
172
+ const off = this.client.on('message', (from, p) => {
124
173
  if (!p || typeof p !== 'object') return
125
- 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 }) }
126
177
  else if (p.type === ERROR) { off(); reject(new Error(p.error)) }
127
178
  })
128
179
  setTimeout(() => { off(); reject(new Error('el agente no respondió (¿está corriendo allí?)')) }, 20000)
129
180
  })
130
181
  this.client.sendByPubkey(this.agentPubkey, { type: HS, data, signature, cert })
131
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) {
132
189
 
133
190
  // El ack debe: (1) encadenar a NUESTRA maestra, (2) estar firmado por el agente
134
191
  // que apuntamos, (3) atar nuestra pub efímera y el sid.
@@ -151,13 +208,18 @@ export class RemoteAgentClient {
151
208
 
152
209
  this.sid = res.sid
153
210
  this.key = await deriveKey(eph.privateKey, res.ack.seph, res.sid)
154
- return this
155
211
  }
156
212
 
157
- /** Envía un payload de dominio cifrado al agente. */
213
+ /** Envía un payload de dominio cifrado al agente (si se está volviendo a saludar, después). */
158
214
  async send (payload) {
215
+ if (this._resuming) await this._resuming
216
+ if (!this.key) throw Object.assign(new Error('no session with the agent'), { code: 'no-session' })
159
217
  const env = await seal(this.key, payload)
160
- 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)
161
223
  }
162
224
 
163
225
  /** Sonda de presencia (liveness) sin abrir sesión. Devuelve true si responde. */