@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 +13 -0
- package/package.json +3 -3
- package/src/agent.js +11 -4
- 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.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.
|
|
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
|
@@ -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,
|
|
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
|
-
|
|
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. */
|