@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 +23 -0
- package/package.json +3 -3
- package/src/agent.js +15 -5
- package/src/client.js +71 -9
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.
|
|
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) {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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', (
|
|
172
|
+
const off = this.client.on('message', (from, p) => {
|
|
124
173
|
if (!p || typeof p !== 'object') return
|
|
125
|
-
|
|
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
|
-
|
|
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. */
|