@dotrino/remote-agent 0.12.1 → 0.14.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 +10 -0
- package/package.json +1 -1
- package/src/agent.js +36 -11
- package/src/client.js +37 -5
- package/src/link.js +7 -1
- package/src/record.js +37 -0
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`):
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/remote-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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",
|
package/src/agent.js
CHANGED
|
@@ -23,6 +23,7 @@ import fs from 'node:fs'
|
|
|
23
23
|
import path from 'node:path'
|
|
24
24
|
import { verifyChain, signWithDevice, verifyDeviceSig, pubkeyId } from '@dotrino/identity/capabilities'
|
|
25
25
|
import { sealersOf, memberCan, checkVaultReply } from '@dotrino/identity/acta'
|
|
26
|
+
import { adoptRecord } from './record.js'
|
|
26
27
|
import { installNodeGlobals } from '../node-globals.js'
|
|
27
28
|
import { makeEphemeral, deriveKey, seal, open } from '../e2e.js'
|
|
28
29
|
import { HS, ACK, DATA, PING, PONG, ERROR, VMSG, SIGN_SCOPE, SESSION_TTL_MS, REVOKE_REFRESH_MS } from '../protocol.js'
|
|
@@ -157,7 +158,7 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
157
158
|
revokedSet = new Set((res.revoked || []).map((r) => r.nonce || r))
|
|
158
159
|
// El acta viaja con la lista. Apuntar su `seq` es lo que dispara la renovación en el
|
|
159
160
|
// mismo tic si el dueño nos cambió los permisos.
|
|
160
|
-
anotarActa(res.acta)
|
|
161
|
+
await anotarActa(res.acta)
|
|
161
162
|
} catch (e) {
|
|
162
163
|
if (!opts.quiet) console.error('[remote-agent] could not refresh revocations (using cache):', e.message)
|
|
163
164
|
}
|
|
@@ -210,23 +211,43 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
210
211
|
}
|
|
211
212
|
|
|
212
213
|
/**
|
|
213
|
-
* Guarda el acta que manda la bóveda
|
|
214
|
+
* Guarda el acta que manda la bóveda, SOLO SI SE PUEDE COMPROBAR (`record.js`). Hace dos
|
|
215
|
+
* cosas, y las dos hacen falta:
|
|
214
216
|
* · dispara la renovación cuando su `seq` pasa al del papel que tenemos;
|
|
215
217
|
* · es CON LO QUE SE JUZGA a quien nos habla — quien firma tiene que ser selladora de
|
|
216
218
|
* este perfil, y eso solo lo dice el acta. Por eso se PERSISTE: si viviera en memoria,
|
|
217
219
|
* al reiniciar el agente no podría atender a nadie hasta el primer tic.
|
|
220
|
+
*
|
|
221
|
+
* Antes se guardaba lo que llegara, de quien llegara: la respuesta de la bóveda no trae
|
|
222
|
+
* remitente comprobable, así que cualquiera que conociera la llave de esta máquina podía
|
|
223
|
+
* mandar un acta inventada y nombrarse miembro. Ahora el acta tiene que encadenar desde la
|
|
224
|
+
* que ya se dio por buena, o venir firmada por la bóveda con la que se enroló.
|
|
218
225
|
*/
|
|
219
|
-
function anotarActa (acta) {
|
|
220
|
-
if (typeof acta?.seq !== 'number'
|
|
226
|
+
async function anotarActa (acta) {
|
|
227
|
+
if (typeof acta?.seq !== 'number') return
|
|
228
|
+
const r = await adoptRecord({ link, candidate: acta, me: myPub })
|
|
229
|
+
if (!r.adopt) {
|
|
230
|
+
if (r.reason !== 'misma-acta') {
|
|
231
|
+
audit('record-rejected', { seq: acta.seq, reason: r.reason })
|
|
232
|
+
if (!opts.quiet) console.error(`[remote-agent] record #${acta.seq} rejected (${r.reason}); keeping the one already trusted`)
|
|
233
|
+
}
|
|
234
|
+
return
|
|
235
|
+
}
|
|
221
236
|
link.acta = acta
|
|
222
237
|
link.actaSeq = acta.seq
|
|
238
|
+
link.actaTrusted = true
|
|
223
239
|
try { saveLink(dir, link) } catch (_) {}
|
|
224
240
|
}
|
|
225
241
|
|
|
226
|
-
/**
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
242
|
+
/**
|
|
243
|
+
* El acta con la que se juzga un papel. Sin ella no se atiende a nadie — y un acta que
|
|
244
|
+
* nunca se comprobó (la que guardó una versión anterior) cuenta como no tenerla.
|
|
245
|
+
*/
|
|
246
|
+
const actaDeConfianza = () => (link.actaTrusted === true && link.acta ? link.acta : null)
|
|
247
|
+
const contextoActa = () => {
|
|
248
|
+
const acta = actaDeConfianza()
|
|
249
|
+
return acta ? { actaSeq: acta.seq, sealers: sealersOf(acta) } : { actaSeq: null, sealers: null }
|
|
250
|
+
}
|
|
230
251
|
|
|
231
252
|
const vaultTick = async () => { await refreshRevocations(); await renewCertIfNeeded() }
|
|
232
253
|
vaultTick()
|
|
@@ -268,8 +289,9 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
268
289
|
// abrir sesión con otro agente. El bot social se pasó así el 1 de septiembre, con un
|
|
269
290
|
// node de contenido encendido a su lado que le contestaba «ese aparato no firma por este
|
|
270
291
|
// perfil». Se le pide lo que corresponde: que el acta lo nombre y le reconozca `sign`.
|
|
271
|
-
const
|
|
272
|
-
|
|
292
|
+
const acta = actaDeConfianza()
|
|
293
|
+
const isMember = (acta?.members || []).some((m) => m?.pub === chk.device)
|
|
294
|
+
if (!acta || !isMember || !memberCan(acta, chk.device, 'sign')) {
|
|
273
295
|
return send(from, { type: ERROR, error: 'no autorizado: acta — este perfil no reconoce a ese aparato' })
|
|
274
296
|
}
|
|
275
297
|
if (data.op !== HS || typeof data.eph !== 'string') return send(from, { type: ERROR, error: 'handshake inválido' })
|
|
@@ -295,7 +317,10 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
295
317
|
|
|
296
318
|
function handleData (from, p) {
|
|
297
319
|
const s = sessions.get(p.sid)
|
|
298
|
-
|
|
320
|
+
// Con `code` y el `sid`: así el cliente sabe que es SU sesión la que se perdió (el agente
|
|
321
|
+
// se reinició, o venció) y vuelve a saludar solo, en vez de quedarse con un error. Se
|
|
322
|
+
// comprueba por el código, no por la frase.
|
|
323
|
+
if (!s) return send(from, { type: ERROR, code: 'unknown-session', sid: p.sid, error: 'sesión desconocida o expirada' })
|
|
299
324
|
s._exp = Date.now() + SESSION_TTL_MS
|
|
300
325
|
s.from = from
|
|
301
326
|
s._ingest(p.env).catch(() => send(from, { type: ERROR, error: 'sobre inválido' }))
|
package/src/client.js
CHANGED
|
@@ -41,10 +41,11 @@ 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
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
/** Suscribe a eventos. Devuelve un `off()` para desuscribir. Eventos: message, error. */
|
|
48
|
+
/** Suscribe a eventos. Devuelve un `off()` para desuscribir. Eventos: message, error, resumed. */
|
|
48
49
|
on (ev, cb) {
|
|
49
50
|
if (!this._h[ev]) this._h[ev] = []
|
|
50
51
|
this._h[ev].push(cb)
|
|
@@ -101,10 +102,40 @@ export class RemoteAgentClient {
|
|
|
101
102
|
if (p.type === DATA && p.sid === this.sid) {
|
|
102
103
|
try { const m = await open(this.key, p.env); this._emit('message', m) } catch {}
|
|
103
104
|
} else if (p.type === ERROR) {
|
|
104
|
-
this.
|
|
105
|
+
this._onAgentError(p)
|
|
105
106
|
}
|
|
106
107
|
})
|
|
107
108
|
|
|
109
|
+
await this._handshake()
|
|
110
|
+
return this
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Un error del agente. Si es que ya no conoce NUESTRA sesión (se reinició: las sesiones
|
|
115
|
+
* viven en su memoria), se vuelve a saludar solo y se avisa con `resumed`; lo que la app
|
|
116
|
+
* tenía abierto allí lo decide la app. Cualquier otro error, o no poder volver a saludar,
|
|
117
|
+
* sale por `error`. Un `unknown-session` de un `sid` que ya no es el nuestro (llegó tarde,
|
|
118
|
+
* de antes de volver a saludar) no dice nada nuevo y se ignora.
|
|
119
|
+
*/
|
|
120
|
+
_onAgentError (p) {
|
|
121
|
+
if (p.code !== 'unknown-session') { this._emit('error', Object.assign(new Error(p.error), { code: p.code })); return }
|
|
122
|
+
if (p.sid !== this.sid) return
|
|
123
|
+
this._resume()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Vuelve a saludar, una sola vez aunque lleguen varios errores seguidos. */
|
|
127
|
+
_resume () {
|
|
128
|
+
if (this._resuming) return this._resuming
|
|
129
|
+
this.key = null
|
|
130
|
+
this._resuming = this._handshake()
|
|
131
|
+
.then(() => { this._emit('resumed', { sid: this.sid }) })
|
|
132
|
+
.catch((e) => { this._emit('error', e) })
|
|
133
|
+
.finally(() => { this._resuming = null })
|
|
134
|
+
return this._resuming
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** El saludo: abre una sesión con el agente y deja `sid` y `key`. */
|
|
138
|
+
async _handshake () {
|
|
108
139
|
const eph = await makeEphemeral()
|
|
109
140
|
// El self-cert P←P del modo self puede vencerse (24 h): refrescarlo si hace falta.
|
|
110
141
|
let cert = this.link.cert
|
|
@@ -151,11 +182,12 @@ export class RemoteAgentClient {
|
|
|
151
182
|
|
|
152
183
|
this.sid = res.sid
|
|
153
184
|
this.key = await deriveKey(eph.privateKey, res.ack.seph, res.sid)
|
|
154
|
-
return this
|
|
155
185
|
}
|
|
156
186
|
|
|
157
|
-
/** Envía un payload de dominio cifrado al agente. */
|
|
187
|
+
/** Envía un payload de dominio cifrado al agente (si se está volviendo a saludar, después). */
|
|
158
188
|
async send (payload) {
|
|
189
|
+
if (this._resuming) await this._resuming
|
|
190
|
+
if (!this.key) throw Object.assign(new Error('no session with the agent'), { code: 'no-session' })
|
|
159
191
|
const env = await seal(this.key, payload)
|
|
160
192
|
this.client.sendByPubkey(this.agentPubkey, { type: DATA, sid: this.sid, env })
|
|
161
193
|
}
|
package/src/link.js
CHANGED
|
@@ -19,6 +19,7 @@ import os from 'node:os'
|
|
|
19
19
|
import { signWithDevice, pubkeyId } from '@dotrino/identity/capabilities'
|
|
20
20
|
import { requestDevices, requestRenew } from '@dotrino/identity/vault/remote.js'
|
|
21
21
|
import { installNodeGlobals } from '../node-globals.js'
|
|
22
|
+
import { adoptRecord } from './record.js'
|
|
22
23
|
|
|
23
24
|
export function dataDir (name = 'dotrino-remote-agent') {
|
|
24
25
|
if (process.env.DOTRINO_REMOTE_AGENT_DIR) return process.env.DOTRINO_REMOTE_AGENT_DIR
|
|
@@ -177,8 +178,13 @@ export async function renewLink (link, { dir = dataDir(), force = false } = {})
|
|
|
177
178
|
// esto no se persiste la máquina se queda usando uno revocado y fuera para siempre. Le
|
|
178
179
|
// pasó al registro de selladores en la migración, y por eso está dicho aquí.
|
|
179
180
|
link.cert = cert
|
|
180
|
-
link.acta = acta
|
|
181
181
|
link.actaSeq = acta.seq
|
|
182
|
+
// El acta que viaja con el papel se guarda SOLO si se puede comprobar (`record.js`): es
|
|
183
|
+
// la que decide quién abre sesión con este agente. El papel sí se guarda siempre.
|
|
184
|
+
if ((await adoptRecord({ link, candidate: acta, me: link.device.publickey })).adopt) {
|
|
185
|
+
link.acta = acta
|
|
186
|
+
link.actaTrusted = true
|
|
187
|
+
}
|
|
182
188
|
saveLink(dir, link)
|
|
183
189
|
return { renewed: true, seq: cert.seq }
|
|
184
190
|
} catch (e) {
|
package/src/record.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* record.js — ¿se puede dar por buena el acta que acaba de llegar?
|
|
3
|
+
*
|
|
4
|
+
* El acta decide quién abre sesión con el agente, así que no se guarda ninguna que no se
|
|
5
|
+
* pueda comprobar. Hay dos casos y ninguno más:
|
|
6
|
+
*
|
|
7
|
+
* · YA HAY una de confianza → la nueva tiene que encadenar desde ella (`canAdopt` del
|
|
8
|
+
* pilar: misma cuenta, bien firmada, sellada por quien la anterior nombra selladora, y
|
|
9
|
+
* nunca hacia atrás).
|
|
10
|
+
* · TODAVÍA NO (recién enrolado, o el acta la guardó una versión que no comprobaba nada)
|
|
11
|
+
* → solo vale una firmada por LA BÓVEDA CON LA QUE SE ENROLÓ (`link.iss`, la única llave
|
|
12
|
+
* que esta máquina tiene fijada), que nombre a esta máquina y no sea anterior a su papel.
|
|
13
|
+
* Si la última acta de la cuenta la selló otra selladora, no hay con qué comprobarla
|
|
14
|
+
* desde aquí: no se adopta, y toca enrolar de nuevo.
|
|
15
|
+
*
|
|
16
|
+
* Sin dependencias de Node: lo usa el agente y se prueba solo.
|
|
17
|
+
*/
|
|
18
|
+
import { canAdopt, verifyActa, samePubkey } from '@dotrino/identity/acta'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {{ link: any, candidate: any, me: string }} args `me` = la llave pública de esta máquina.
|
|
22
|
+
* @returns {Promise<{ adopt: boolean, reason: string }>}
|
|
23
|
+
*/
|
|
24
|
+
export async function adoptRecord ({ link, candidate, me }) {
|
|
25
|
+
if (!candidate || typeof candidate.seq !== 'number') return { adopt: false, reason: 'shape' }
|
|
26
|
+
if (link?.actaTrusted === true && link.acta) return canAdopt({ candidate, current: link.acta })
|
|
27
|
+
|
|
28
|
+
if (typeof link?.iss !== 'string') return { adopt: false, reason: 'no-pinned-vault' }
|
|
29
|
+
const v = await verifyActa({ acta: candidate })
|
|
30
|
+
if (!v.ok) return { adopt: false, reason: v.reason }
|
|
31
|
+
if (!samePubkey(candidate.sealedBy, link.iss)) return { adopt: false, reason: 'not-sealed-by-pinned-vault' }
|
|
32
|
+
if (!(candidate.members || []).some((m) => samePubkey(m?.pub, me))) return { adopt: false, reason: 'not-a-member' }
|
|
33
|
+
if (typeof link.cert?.seq === 'number' && candidate.seq < link.cert.seq) return { adopt: false, reason: 'older-than-cert' }
|
|
34
|
+
return { adopt: true, reason: 'sealed-by-pinned-vault' }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default { adoptRecord }
|