@dotrino/remote-agent 0.14.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
@@ -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.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) {
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. */