@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 +13 -0
- package/package.json +3 -3
- package/src/agent.js +33 -6
- package/src/catch-up.js +29 -0
- package/src/client.js +34 -4
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
-
|
|
277
|
-
|
|
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
|
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
|
+
}
|
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
|
-
|
|
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', (
|
|
172
|
+
const off = this.client.on('message', (from, p) => {
|
|
155
173
|
if (!p || typeof p !== 'object') return
|
|
156
|
-
|
|
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
|
-
|
|
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. */
|