@dotrino/remote-agent 0.1.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 ADDED
@@ -0,0 +1,112 @@
1
+ # @dotrino/remote-agent
2
+
3
+ Middleware compartido del ecosistema Dotrino para **apps de agente remoto cifrado
4
+ punto a punto**. Lo consumen [`dotrino-terminal`](https://github.com/imdotrino/dotrino-terminal)
5
+ (PTY remoto) y [`dotrino-ia`](https://github.com/imdotrino/dotrino-ia) (chat con
6
+ agentes de IA), y cualquier app futura que necesite hablar con un proceso que corre
7
+ en la máquina del usuario.
8
+
9
+ Provee lo común — y **solo** eso:
10
+
11
+ - **Canal cifrado E2E por sesión** (ECDH P-256 → HKDF → AES-256-GCM, isomórfico WebCrypto).
12
+ - **Handshake anti-MITM** con `verifyChain` + anti-replay (firmado por el vault).
13
+ - **Emparejamiento SAS** del agente contra el vault (código que no viaja).
14
+ - **Revocación** con auto-borrado del enlace al ser revocada la máquina.
15
+ - **Wiring de proxy + identity** (`identify` firmado, transporte por `proxy.dotrino.com`).
16
+ - **Autodescubrimiento** de agentes por label (`listVaultDevices` + filtro).
17
+ - **Auditoría** (`sessions.log` JSONL).
18
+
19
+ **No sabe nada de PTY ni de IA.** Cada app define sus payloads de dominio
20
+ (terminal pasa `{type:'cmd',...}`, ia pasa `{type:'msg',...}`) y su renderer.
21
+
22
+ ## Entry points (subpath exports)
23
+
24
+ | Import | Entorno | Qué da |
25
+ |---|---|---|
26
+ | `@dotrino/remote-agent` | isomórfico | `e2e` (`makeEphemeral`/`deriveKey`/`seal`/`open`) + constantes de protocolo |
27
+ | `@dotrino/remote-agent/agent` | Node | `startRemoteAgent(opts)` |
28
+ | `@dotrino/remote-agent/client` | navegador | `RemoteAgentClient` |
29
+ | `@dotrino/remote-agent/link` | Node | `enroll`, `loadLink`, `dataDir`, `parseQr` |
30
+ | `@dotrino/remote-agent/discover` | navegador | `listAgentsByLabel` |
31
+
32
+ ## Uso
33
+
34
+ ### Agente (Node, en tu PC)
35
+
36
+ ```js
37
+ import { startRemoteAgent } from '@dotrino/remote-agent/agent'
38
+
39
+ const ra = await startRemoteAgent({
40
+ label: 'ia-agent',
41
+ proxyUrl: 'wss://proxy.dotrino.com', // opcional
42
+ dir: '/home/yo/.local/share/dotrino-ia-agent', // opcional
43
+ quiet: false,
44
+ onSession (session) {
45
+ // sesión cifrada con un dispositivo cliente. session.device = su pubkey.
46
+ session.on('message', async (msg) => {
47
+ // msg es un payload de dominio: la app define la forma
48
+ if (msg.type === 'msg') {
49
+ const reply = await runYourDriver(msg.text) // Claude / OpenCode / ...
50
+ session.send({ type: 'done', text: reply })
51
+ }
52
+ })
53
+ session.on('close', () => {})
54
+ },
55
+ onRevoked () { console.log('me revocaron') }
56
+ })
57
+ // ra.machine, ra.machineId, ra.master, ra.close()
58
+ ```
59
+
60
+ Enrolar la máquina (una vez, con el QR del vault):
61
+
62
+ ```js
63
+ import { enroll } from '@dotrino/remote-agent/link'
64
+ const link = await enroll({
65
+ qr, label: 'ia-agent',
66
+ onChallenge: ({ deviceId, code }) => console.log(`Tipeá en el vault: ${code}`)
67
+ })
68
+ ```
69
+
70
+ ### Cliente (navegador, la PWA)
71
+
72
+ ```js
73
+ import { RemoteAgentClient } from '@dotrino/remote-agent/client'
74
+ import { listAgentsByLabel } from '@dotrino/remote-agent/discover'
75
+
76
+ const agentes = await listAgentsByLabel(id, 'ia-agent') // tarjetas en la UI
77
+ const chat = new RemoteAgentClient(link, { agentPubkey: agentes[0].sub })
78
+ await chat.connect() // handshake E2E
79
+ chat.on('message', (payload) => render(payload))
80
+ chat.on('error', (e) => console.warn(e))
81
+ chat.send({ type: 'msg', text: 'arreglá el bug en auth.js' })
82
+
83
+ const online = await chat.ping() // liveness sin abrir sesión
84
+ ```
85
+
86
+ ### `link` (la PWA lo obtiene del vault)
87
+
88
+ Ver `dotrino-terminal/src/vault.js` (`getLink` / `getSelfLink`) como referencia: es
89
+ el enlace del dispositivo (cert `P ← maestra`, `iss`, `proxy`), que se pasa al
90
+ `RemoteAgentClient`. La terminal ya lo resuelve; cada app puede copiar ese patrón o
91
+ extraerlo luego a este paquete.
92
+
93
+ ## Seguridad
94
+
95
+ - **Autorización = estar vinculado al vault.** El agente solo atiende dispositivos
96
+ cuya cadena certifica la maestra pineada (`verifyChain` con `trustedIssuer`).
97
+ - **Emparejamiento con código SAS** que no viaja (anti aprobación a ciegas / vault impostor).
98
+ - **Anti-replay** en el handshake (`ts` ±5 min).
99
+ - **Auto-borrado** del `link.json` al recibir un `revoke` firmado por la maestra.
100
+ - **Auditoría** local de cada sesión abierta.
101
+ - El relay de transporte (`proxy.dotrino.com`) **solo ve bytes cifrados**.
102
+
103
+ ## Dependencias
104
+
105
+ - `@dotrino/identity` (capabilities: `verifyChain`, `signWithDevice`, `verifyDeviceSig`,
106
+ `pubkeyId`, `makeDeviceKey`, `verifyDelegation`, `makePairingCode`)
107
+ - `@dotrino/proxy-client` (`WebSocketProxyClient`)
108
+ - `ws` (shim de `WebSocket` en Node, lado agente)
109
+
110
+ ## Licencia
111
+
112
+ MIT.
package/e2e.js ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * e2e.js — canal cifrado punto a punto entre el dispositivo enlazado y un agente
3
+ * remoto del ecosistema Dotrino. Isomórfico: usa WebCrypto
4
+ * (`globalThis.crypto.subtle`), disponible tanto en el navegador (PWA) como en
5
+ * Node ≥ 18.
6
+ *
7
+ * NO reimplementa la identidad ni la firma del ecosistema: la AUTORIZACIÓN la da
8
+ * el vault (cert de dispositivo verificado con `@dotrino/identity/capabilities`
9
+ * → cadena hasta la maestra). Este módulo solo levanta un secreto de sesión
10
+ * efímero (ECDH P-256 → HKDF → AES-256-GCM) para que el relay de transporte
11
+ * (`proxy.dotrino.com`) vea únicamente texto cifrado. La identidad de las dos
12
+ * puntas ya la garantizan las firmas del vault que envuelven las claves efímeras.
13
+ *
14
+ * Fuente única para `@dotrino/remote-agent` (consumido por terminal, ia y futuras
15
+ * apps de agente remoto).
16
+ */
17
+
18
+ const subtle = () => globalThis.crypto.subtle
19
+
20
+ const b64 = {
21
+ enc (buf) {
22
+ const b = new Uint8Array(buf)
23
+ let s = ''
24
+ for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i])
25
+ return btoa(s)
26
+ },
27
+ dec (str) {
28
+ const bin = atob(str)
29
+ const out = new Uint8Array(bin.length)
30
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
31
+ return out
32
+ }
33
+ }
34
+
35
+ /** Genera un par ECDH P-256 efímero. Devuelve la pub en raw (base64) + privada. */
36
+ export async function makeEphemeral () {
37
+ const kp = await subtle().generateKey({ name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits'])
38
+ const raw = await subtle().exportKey('raw', kp.publicKey)
39
+ return { privateKey: kp.privateKey, pub: b64.enc(raw) }
40
+ }
41
+
42
+ /**
43
+ * Deriva la clave AES-256-GCM de la sesión a partir de la privada propia y la
44
+ * pub efímera (base64 raw) del otro extremo. `salt` liga la clave a la sesión.
45
+ */
46
+ export async function deriveKey (privateKey, otherPubB64, salt) {
47
+ const otherPub = await subtle().importKey(
48
+ 'raw', b64.dec(otherPubB64), { name: 'ECDH', namedCurve: 'P-256' }, false, []
49
+ )
50
+ const bits = await subtle().deriveBits({ name: 'ECDH', public: otherPub }, privateKey, 256)
51
+ const hkdfKey = await subtle().importKey('raw', bits, 'HKDF', false, ['deriveKey'])
52
+ return subtle().deriveKey(
53
+ { name: 'HKDF', hash: 'SHA-256', salt: new TextEncoder().encode(salt), info: new TextEncoder().encode('dotrino-remote-agent-e2e') },
54
+ hkdfKey,
55
+ { name: 'AES-GCM', length: 256 },
56
+ false,
57
+ ['encrypt', 'decrypt']
58
+ )
59
+ }
60
+
61
+ /** Cifra un objeto JSON → sobre { iv, ct } (ambos base64). */
62
+ export async function seal (key, obj) {
63
+ const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
64
+ const pt = new TextEncoder().encode(JSON.stringify(obj))
65
+ const ct = await subtle().encrypt({ name: 'AES-GCM', iv }, key, pt)
66
+ return { iv: b64.enc(iv), ct: b64.enc(ct) }
67
+ }
68
+
69
+ /** Descifra un sobre { iv, ct } → objeto JSON. Lanza si el tag GCM no valida. */
70
+ export async function open (key, env) {
71
+ const pt = await subtle().decrypt({ name: 'AES-GCM', iv: b64.dec(env.iv) }, key, b64.dec(env.ct))
72
+ return JSON.parse(new TextDecoder().decode(pt))
73
+ }
74
+
75
+ export { b64 }
package/index.js ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @dotrino/remote-agent — middleware compartido para apps de agente remoto
3
+ * cifrado punto a punto del ecosistema Dotrino.
4
+ *
5
+ * Entry points (subpath exports en package.json):
6
+ * '@dotrino/remote-agent' → e2e + constantes de protocolo (isomórfico)
7
+ * '@dotrino/remote-agent/agent' → startRemoteAgent() (Node, lado del agente)
8
+ * '@dotrino/remote-agent/client' → RemoteAgentClient (navegador, lado PWA)
9
+ * '@dotrino/remote-agent/link' → enroll() emparejamiento SAS (Node)
10
+ * '@dotrino/remote-agent/discover' → listAgentsByLabel() (navegador)
11
+ *
12
+ * El paquete NO sabe nada de PTY ni de IA: provee canal cifrado por sesión,
13
+ * despacho de payloads de dominio, emparejamiento con vault y revocación.
14
+ */
15
+ export * from './e2e.js'
16
+ export * from './protocol.js'
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Instala los globals del navegador que los paquetes del ecosistema esperan
3
+ * cuando corren headless en Node: `WebSocket` (paquete `ws`) y un `localStorage`
4
+ * respaldado en archivo (lo usa `@dotrino/proxy-client` para su keypair de
5
+ * transporte estable). Mismo patrón que `dotrino-bots/src/core/node-globals.js`
6
+ * — NO se reimplementa nada, solo se inyecta el entorno.
7
+ *
8
+ * Solo para uso del lado del AGENTE (Node). No importar desde entrypoints de
9
+ * navegador.
10
+ */
11
+ import fs from 'node:fs'
12
+ import path from 'node:path'
13
+ import WebSocket from 'ws'
14
+
15
+ /** localStorage síncrono respaldado por un archivo JSON (solo lo que usan los paquetes). */
16
+ export function fileLocalStorage (filePath) {
17
+ let data = {}
18
+ try { if (fs.existsSync(filePath)) data = JSON.parse(fs.readFileSync(filePath, 'utf8')) || {} } catch (_) {}
19
+ const flush = () => {
20
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
21
+ fs.writeFileSync(filePath, JSON.stringify(data), { mode: 0o600 })
22
+ }
23
+ return {
24
+ getItem: (k) => (k in data ? data[k] : null),
25
+ setItem: (k, v) => { data[k] = String(v); flush() },
26
+ removeItem: (k) => { delete data[k]; flush() },
27
+ clear: () => { data = {}; flush() },
28
+ key: (i) => Object.keys(data)[i] ?? null,
29
+ get length () { return Object.keys(data).length }
30
+ }
31
+ }
32
+
33
+ let _installed = false
34
+ export function installNodeGlobals (dir) {
35
+ if (_installed) return
36
+ if (typeof globalThis.WebSocket === 'undefined') globalThis.WebSocket = WebSocket
37
+ // Node ≥22 expone un `localStorage` no funcional sin flag → forzamos el shim.
38
+ globalThis.localStorage = fileLocalStorage(path.join(dir, 'transport.json'))
39
+ _installed = true
40
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@dotrino/remote-agent",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "main": "./index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./agent": "./src/agent.js",
10
+ "./client": "./src/client.js",
11
+ "./link": "./src/link.js",
12
+ "./discover": "./src/discover.js"
13
+ },
14
+ "files": [
15
+ "index.js",
16
+ "e2e.js",
17
+ "protocol.js",
18
+ "node-globals.js",
19
+ "src"
20
+ ],
21
+ "scripts": {
22
+ "test": "node -e \"import('@dotrino/remote-agent').then(()=>console.log('ok'))\""
23
+ },
24
+ "dependencies": {
25
+ "@dotrino/identity": "0.17.0",
26
+ "@dotrino/proxy-client": "0.6.4",
27
+ "ws": "8.21.0"
28
+ },
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+ssh://git@github.com/imdotrino/dotrino-remote-agent.git"
36
+ },
37
+ "homepage": "https://github.com/imdotrino/dotrino-remote-agent",
38
+ "keywords": ["dotrino", "remote", "agent", "e2e", "vault", "proxy", "p2p"]
39
+ }
package/protocol.js ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * protocol.js — constantes del protocolo de transporte de `@dotrino/remote-agent`.
3
+ *
4
+ * El protocolo tiene dos capas:
5
+ * 1. Envelope de transporte (visible al relay/proxy): `{ type, sid, env?, ... }`.
6
+ * Usa los tipos `ra.*` de aquí abajo. El proxy solo ve el `type` + ciphertext.
7
+ * 2. Payload de dominio (dentro de `env`, cifrado AES-256-GCM): un objeto
8
+ * arbitrario definido por CADA app (terminal envía `{type:'cmd',...}`,
9
+ * ia envía `{type:'msg',...}`). El paquete no impone su forma.
10
+ *
11
+ * El handshake (HS/ACK) y la liveness (PING/PONG) son comunes; el despacho de
12
+ * payloads de dominio viaja por el tipo `DATA` genérico.
13
+ */
14
+
15
+ export const HS = 'ra.hs'
16
+ export const ACK = 'ra.hs.ack'
17
+ export const DATA = 'ra.data' // payload de dominio cifrado (cualquier dirección)
18
+ export const PING = 'ra.ping'
19
+ export const PONG = 'ra.pong'
20
+ export const ERROR = 'ra.error'
21
+
22
+ /** Mensajes del vault (refresco de dispositivos / revocación). */
23
+ export const VMSG = {
24
+ DEVICES: 'vault.devices',
25
+ DEVICES_RESULT: 'vault.devices.result',
26
+ REVOKED: 'vault.revoked'
27
+ }
28
+
29
+ /** Scope del cert de dispositivo que autoriza a hablar con el agente. */
30
+ export const SIGN_SCOPE = 'vault:sign'
31
+
32
+ /** Vigencia de una sesión inactiva y refresco de la lista de revocados. */
33
+ export const SESSION_TTL_MS = 30 * 60 * 1000
34
+ export const REVOKE_REFRESH_MS = 5 * 60 * 1000
package/src/agent.js ADDED
@@ -0,0 +1,227 @@
1
+ /**
2
+ * agent.js — lado del AGENTE (Node) de `@dotrino/remote-agent`.
3
+ *
4
+ * Abre sesiones cifradas punto a punto SOLO para dispositivos enlazados al mismo
5
+ * vault. Puede correr en CUALQUIER máquina: se enrola con el vault como un
6
+ * dispositivo más (ver link.js), así que NO necesita la maestra — solo su propia
7
+ * sub-clave `D` + `cert` y la pública maestra pineada (`iss`).
8
+ *
9
+ * Autorización = el vault. Cada `ra.hs` llega firmado por la `D` del cliente con su
10
+ * `cert`; el agente verifica la cadena `D_cliente ← maestra` (misma maestra que la
11
+ * suya) con `verifyChain`. Ambas puntas son peers certificados por el mismo vault;
12
+ * ninguna tiene la clave maestra. El ack lo firma el agente con SU `D` y adjunta su
13
+ * `cert`, para que el cliente compruebe que habla con un agente que el vault certificó
14
+ * (anti-MITM del relay).
15
+ *
16
+ * Transporte = el proxy (`@dotrino/proxy-client`): el agente se identifica bajo SU
17
+ * pubkey y el cliente lo direcciona por ella. Revocación: refresca la lista del vault
18
+ * por el proxy; si está offline, usa la última cacheada + el TTL del cert acota el
19
+ * riesgo. Los payloads de dominio viajan cifrados E2E (../e2e.js) dentro del tipo
20
+ * `DATA` genérico — el agente NO sabe nada de PTY ni de IA.
21
+ */
22
+ import fs from 'node:fs'
23
+ import path from 'node:path'
24
+ import { verifyChain, signWithDevice, verifyDeviceSig, pubkeyId } from '@dotrino/identity/capabilities'
25
+ import { installNodeGlobals } from '../node-globals.js'
26
+ import { makeEphemeral, deriveKey, seal, open } from '../e2e.js'
27
+ import { HS, ACK, DATA, PING, PONG, ERROR, VMSG, SIGN_SCOPE, SESSION_TTL_MS, REVOKE_REFRESH_MS } from '../protocol.js'
28
+ import { loadLink, dataDir } from './link.js'
29
+
30
+ /** Sesión cifrada con un dispositivo cliente. La app recibe esto en `onSession`. */
31
+ class AgentSession {
32
+ constructor ({ sid, key, from, device, send }) {
33
+ this.sid = sid
34
+ this.key = key
35
+ this.from = from // token del cliente en el proxy
36
+ this.device = device // pubkey del dispositivo cliente
37
+ this._send = send // (to, obj) => void
38
+ this._h = { message: [], close: [] }
39
+ this._closed = false
40
+ }
41
+
42
+ /** Envía un payload de dominio cifrado al cliente. */
43
+ async send (payload) {
44
+ if (this._closed) return
45
+ const env = await seal(this.key, payload).catch(() => null)
46
+ if (env) this._send(this.from, { type: DATA, sid: this.sid, env })
47
+ }
48
+
49
+ on (ev, cb) { this._h[ev]?.push(cb); return this }
50
+
51
+ async _ingest (env) {
52
+ let msg
53
+ try { msg = await open(this.key, env) } catch { return }
54
+ for (const h of this._h.message) { try { h(msg) } catch (_) {} }
55
+ }
56
+
57
+ close () {
58
+ if (this._closed) return
59
+ this._closed = true
60
+ for (const h of this._h.close) { try { h() } catch (_) {} }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Arranca el agente. Devuelve `{ machine, machineId, master, close }`.
66
+ *
67
+ * @param {object} opts
68
+ * @param {string} [opts.label] label del dispositivo (para logging; el real vive en link.json).
69
+ * @param {string} [opts.proxyUrl] default wss://proxy.dotrino.com.
70
+ * @param {string} [opts.dir] dir del enlace (default dataDir()).
71
+ * @param {object} [opts.link] enlace ya cargado (override de dir).
72
+ * @param {(session:AgentSession)=>void} [opts.onSession] cada handshake exitoso abre una sesión.
73
+ * @param {()=>void} [opts.onRevoked] se llamó al auto-borrarse por revocación.
74
+ * @param {()=>void} [opts.onReady] agente listo y escuchando.
75
+ * @param {boolean} [opts.quiet] sin logs.
76
+ */
77
+ export async function startRemoteAgent (opts = {}) {
78
+ const dir = opts.dir || dataDir()
79
+ const link = opts.link || loadLink(dir)
80
+ if (!link?.device?.privateJwk || !link?.cert || !link?.iss) {
81
+ throw new Error('esta máquina no está enlazada. Ejecuta primero el enrolamiento (enroll) de tu agente.')
82
+ }
83
+ const master = link.iss
84
+ const myPub = link.device.publickey
85
+ const myId = (await pubkeyId(myPub)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
86
+
87
+ installNodeGlobals(dir)
88
+
89
+ const { getWebSocketProxyClient } = await import('@dotrino/proxy-client')
90
+ const proxyUrl = opts.proxyUrl || process.env.PROXY_URL || link.proxy || 'wss://proxy.dotrino.com'
91
+ const client = getWebSocketProxyClient({
92
+ url: proxyUrl, enableWebRTC: false, autoReconnect: true,
93
+ maxReconnectAttempts: 100000, reconnectDelay: 4000
94
+ })
95
+ await client.connect()
96
+
97
+ // Identificarse bajo la pubkey de ESTA máquina (firmado con su D).
98
+ const identify = async () => {
99
+ if (!client.token) return
100
+ const data = { op: 'identify', publickey: myPub, token: client.token, ts: Date.now() }
101
+ const { signature } = await signWithDevice({ privateJwk: link.device.privateJwk, data })
102
+ await client.identify({ data, signature })
103
+ }
104
+ await identify()
105
+ client.on('token', () => { identify().catch(() => {}) })
106
+
107
+ // Bitácora persistente (sessions.log, JSONL): qué dispositivo abrió sesión y cuándo
108
+ // — auditoría local si un dispositivo tuyo cae en malas manos.
109
+ const sessionsLog = path.join(dir, 'sessions.log')
110
+ const audit = (op, info = {}) => {
111
+ try { fs.appendFileSync(sessionsLog, JSON.stringify({ ts: Date.now(), op, ...info }) + '\n') } catch (_) {}
112
+ }
113
+
114
+ const send = (to, obj) => { try { client.send(to, obj) } catch (e) { if (!opts.quiet) console.error('[remote-agent] send:', e.message) } }
115
+
116
+ // --- Revocación: refrescar la lista del vault por el proxy (best-effort) ---
117
+ let revokedSet = new Set()
118
+ async function refreshRevocations () {
119
+ try {
120
+ const data = { op: 'devices', publickey: myPub, ts: Date.now() }
121
+ const { signature } = await signWithDevice({ privateJwk: link.device.privateJwk, data })
122
+ const res = await new Promise((resolve, reject) => {
123
+ const off = client.on('message', (_f, p) => {
124
+ if (p?.type === VMSG.DEVICES_RESULT) { off(); resolve(p) }
125
+ else if (p?.type === 'vault.error') { off(); reject(new Error(p.error)) }
126
+ })
127
+ setTimeout(() => { off(); reject(new Error('timeout')) }, 15000)
128
+ client.sendByPubkey(master, { type: VMSG.DEVICES, data, signature, cert: link.cert })
129
+ })
130
+ revokedSet = new Set((res.revoked || []).map((r) => r.nonce || r))
131
+ } catch (e) {
132
+ if (!opts.quiet) console.error('[remote-agent] no pude refrescar revocaciones (uso la cache):', e.message)
133
+ }
134
+ }
135
+ refreshRevocations()
136
+ const revTimer = setInterval(refreshRevocations, REVOKE_REFRESH_MS); revTimer.unref?.()
137
+
138
+ // sid -> AgentSession
139
+ const sessions = new Map()
140
+ const sweeper = setInterval(() => {
141
+ const now = Date.now()
142
+ for (const [sid, s] of sessions) {
143
+ if (now > s._exp) { s.close(); sessions.delete(sid); audit('session-expire', { sid: sid.slice(0, 8) }) }
144
+ }
145
+ }, 60 * 1000); sweeper.unref?.()
146
+
147
+ async function handleHandshake (from, p) {
148
+ const { data, signature, cert } = p
149
+ if (!data || !signature || !cert) return send(from, { type: ERROR, error: 'handshake incompleto' })
150
+ // FRESCURA anti-replay: el HS debe ser reciente (±5 min); sin esto un relay
151
+ // malicioso podía reproducir handshakes viejos (sesiones huérfanas / DoS).
152
+ if (typeof data.ts !== 'number' || Math.abs(Date.now() - data.ts) > 5 * 60 * 1000) {
153
+ return send(from, { type: ERROR, error: 'handshake vencido (posible replay, o el reloj del dispositivo está desfasado)' })
154
+ }
155
+ const chk = await verifyChain({ data, signature, cert, expectedScope: SIGN_SCOPE, trustedIssuer: master, revoked: revokedSet })
156
+ if (!chk.ok) return send(from, { type: ERROR, error: 'no autorizado: ' + chk.reason })
157
+ if (data.op !== HS || typeof data.eph !== 'string') return send(from, { type: ERROR, error: 'handshake inválido' })
158
+
159
+ const eph = await makeEphemeral()
160
+ const sid = [...crypto.getRandomValues(new Uint8Array(16))].map((x) => x.toString(16).padStart(2, '0')).join('')
161
+ const key = await deriveKey(eph.privateKey, data.eph, sid)
162
+ // Ack firmado con la D de ESTA máquina + su cert: el cliente verifica la cadena
163
+ // a la maestra y que la pub efímera viene de un agente certificado. `publickey`
164
+ // va DENTRO del dato firmado (verifyChain lo exige: firma contra data.publickey
165
+ // y cert.sub === data.publickey).
166
+ const ack = { op: ACK, sid, seph: eph.pub, ceph: data.eph, machine: myPub, publickey: myPub, ts: Date.now() }
167
+ const { signature: ackSig } = await signWithDevice({ privateJwk: link.device.privateJwk, data: ack })
168
+
169
+ const session = new AgentSession({ sid, key, from, device: chk.device, send })
170
+ session._exp = Date.now() + SESSION_TTL_MS
171
+ sessions.set(sid, session)
172
+ audit('session-open', { sid: sid.slice(0, 8), device: (await pubkeyId(chk.device)).slice(0, 8).toUpperCase() })
173
+ send(from, { type: ACK, sid, ack, signature: ackSig, cert: link.cert })
174
+ if (!opts.quiet) console.log(`[remote-agent] sesión ${sid.slice(0, 8)} autorizada (device ${chk.device?.slice?.(0, 8) || '?'})`)
175
+ if (opts.onSession) { try { opts.onSession(session) } catch (e) { if (!opts.quiet) console.error('[remote-agent] onSession:', e.message) } }
176
+ }
177
+
178
+ function handleData (from, p) {
179
+ const s = sessions.get(p.sid)
180
+ if (!s) return send(from, { type: ERROR, error: 'sesión desconocida o expirada' })
181
+ s._exp = Date.now() + SESSION_TTL_MS
182
+ s.from = from
183
+ s._ingest(p.env).catch(() => send(from, { type: ERROR, error: 'sobre inválido' }))
184
+ }
185
+
186
+ const stop = () => {
187
+ clearInterval(revTimer); clearInterval(sweeper)
188
+ for (const s of sessions.values()) s.close()
189
+ try { client.close() } catch {}
190
+ }
191
+
192
+ // AUTO-BORRADO al ser REVOCADA: la bóveda envía un REVOKED FIRMADO por la maestra
193
+ // dirigido a ESTA máquina (al revocar, o cuando reaparece). Solo nos borramos si la
194
+ // firma valida y el body es para nuestra pubkey → un proxy/peer malicioso NO puede
195
+ // borrarnos con un mensaje falso (cierra el wipe-DoS). Borra el enlace (link.json) y
196
+ // detiene el agente.
197
+ async function handleRevoked (p) {
198
+ const b = p?.body
199
+ if (!b || b.op !== 'revoke' || b.sub !== myPub) return
200
+ if (typeof b.exp === 'number' && Date.now() > b.exp) return
201
+ if (!await verifyDeviceSig({ publickey: master, data: b, signature: p.signature })) return
202
+ audit('revoked', { nonce: b.nonce })
203
+ if (!opts.quiet) console.log('\n[remote-agent] tu bóveda REVOCÓ esta máquina → borro el enlace y salgo.')
204
+ try { fs.rmSync(path.join(dir, 'link.json'), { force: true }) } catch (_) {}
205
+ stop()
206
+ if (opts.onRevoked) { try { opts.onRevoked() } catch (_) {} }
207
+ }
208
+
209
+ client.on('message', (from, payload) => {
210
+ if (!payload || typeof payload !== 'object') return
211
+ if (payload.type === HS) handleHandshake(from, payload).catch((e) => send(from, { type: ERROR, error: e.message }))
212
+ else if (payload.type === DATA) handleData(from, payload)
213
+ // Sonda de presencia (liveness): el cliente hace ping y respondemos pong con el
214
+ // mismo nonce → así la app sabe que el agente está online (sin abrir sesión).
215
+ else if (payload.type === PING) send(from, { type: PONG, n: payload.n })
216
+ else if (payload.type === VMSG.REVOKED) handleRevoked(payload).catch(() => {})
217
+ })
218
+
219
+ if (!opts.quiet) {
220
+ console.log(`[remote-agent] agente activo · máquina ${myId} · vault ${(await pubkeyId(master)).slice(0, 16)} · proxy ${proxyUrl}`)
221
+ }
222
+ if (opts.onReady) { try { opts.onReady({ machine: myPub, machineId: myId, master }) } catch (_) {} }
223
+
224
+ return { machine: myPub, machineId: myId, master, close: stop }
225
+ }
226
+
227
+ export default { startRemoteAgent }
package/src/client.js ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * client.js — lado del CLIENTE (navegador/PWA) de `@dotrino/remote-agent`.
3
+ *
4
+ * Habla con un agente remoto por el proxy del ecosistema (`@dotrino/proxy-client`).
5
+ * El agente es otro dispositivo enrolado en el MISMO vault: lo direccionamos por SU
6
+ * pubkey (`agentPubkey`) y verificamos que su `cert` encadena a la maestra que este
7
+ * dispositivo vio al enlazar. Ambas puntas son peers certificados por el vault;
8
+ * ninguna tiene la clave maestra.
9
+ *
10
+ * La firma de este lado la hace el PILAR de identidad (`id.signData`, dentro del
11
+ * iframe id.dotrino.com): la clave del dispositivo es la identidad del navegador y
12
+ * su cert (`P ← maestra`) viene del emparejamiento estándar del ecosistema
13
+ * (profile.dotrino.com/#vault). Nada de claves privadas en la app.
14
+ *
15
+ * Handshake: firmamos la autorización → el agente responde un ack firmado con SU
16
+ * `D` + su `cert` → verificamos la cadena a la maestra pineada (anti-MITM del
17
+ * relay) → levantamos el canal cifrado (ECDH → AES-GCM).
18
+ *
19
+ * Una vez conectado, `send(payload)` envía un objeto de dominio arbitrario (la app
20
+ * define la forma: terminal pasa `{type:'cmd',...}`, ia pasa `{type:'msg',...}`) y
21
+ * `on('message', cb)` recibe los payloads del agente, ya descifrados.
22
+ */
23
+ import { verifyChain } from '@dotrino/identity/capabilities'
24
+ import { makeEphemeral, deriveKey, seal, open } from '../e2e.js'
25
+ import { HS, ACK, DATA, PING, PONG, ERROR } from '../protocol.js'
26
+
27
+ export class RemoteAgentClient {
28
+ /**
29
+ * @param {{ id:object, cert:object, iss:string, proxy?:string, mode?:string }} link
30
+ * enlace del vault (modo vault) o de @dotrino/vault (modo self: el dispositivo
31
+ * es su propio vault; `cert` es el self-cert `P ← P` e `iss` es la propia P).
32
+ * @param {object} opts
33
+ * @param {string} opts.agentPubkey dirección (pubkey) del agente destino.
34
+ * @param {string} [opts.proxyUrl] override del proxy del enlace.
35
+ */
36
+ constructor (link, { agentPubkey, proxyUrl } = {}) {
37
+ this.link = link // { id, cert, iss, proxy, mode? }
38
+ this.agentPubkey = agentPubkey // pubkey del agente destino
39
+ this.proxyUrl = proxyUrl || link.proxy || 'wss://proxy.dotrino.com'
40
+ this.client = null
41
+ this.key = null
42
+ this.sid = null
43
+ this._h = { message: [], error: [] }
44
+ }
45
+
46
+ /** Suscribe a eventos. Devuelve un `off()` para desuscribir. Eventos: message, error. */
47
+ on (ev, cb) {
48
+ if (!this._h[ev]) this._h[ev] = []
49
+ this._h[ev].push(cb)
50
+ return () => { this._h[ev] = this._h[ev].filter((f) => f !== cb) }
51
+ }
52
+
53
+ _emit (ev, ...args) { for (const h of (this._h[ev] || [])) { try { h(...args) } catch (_) {} } }
54
+
55
+ async _identify () {
56
+ // En modo self NO identificamos esta conexión como P: el proxy enruta por token
57
+ // las respuestas del agente (no por pubkey), y así no recibimos el fan-out de
58
+ // mensajes dirigidos a P (que atiende el listener de enrolamiento). La
59
+ // autorización la da el vault/self-cert que va firmado en el handshake.
60
+ if (this.link.mode === 'self') return
61
+ if (!this.client.token) return
62
+ // Patrón estándar del ecosistema (messenger): identify firmado por id.signData
63
+ // + cert del vault → el proxy enruta también lo dirigido a la maestra.
64
+ const { id, cert } = this.link
65
+ const publickey = id.me?.publickey
66
+ if (!publickey) return
67
+ const data = { op: 'identify', publickey, token: this.client.token, ts: Date.now() }
68
+ const { signature } = await id.signData(data)
69
+ await this.client.identify({ data, signature, cert })
70
+ }
71
+
72
+ async connect () {
73
+ if (!this.agentPubkey) throw new Error('falta la dirección del agente destino')
74
+ const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
75
+ this.client = new WebSocketProxyClient({ url: this.proxyUrl, enableWebRTC: false, autoReconnect: true })
76
+ await this.client.connect()
77
+ await this._identify()
78
+ if (this.link.mode !== 'self') {
79
+ this.client.on('token', () => { this._identify().catch(() => {}) })
80
+ }
81
+
82
+ this.client.on('message', async (_from, p) => {
83
+ if (!p || typeof p !== 'object') return
84
+ if (p.type === DATA && p.sid === this.sid) {
85
+ try { const m = await open(this.key, p.env); this._emit('message', m) } catch {}
86
+ } else if (p.type === ERROR) {
87
+ this._emit('error', new Error(p.error))
88
+ }
89
+ })
90
+
91
+ const eph = await makeEphemeral()
92
+ // El self-cert P←P del modo self puede vencerse (24 h): refrescarlo si hace falta.
93
+ let cert = this.link.cert
94
+ if (this.link.mode === 'self' && this.link.getSelfCert) {
95
+ cert = await this.link.getSelfCert()
96
+ }
97
+ // `publickey` va DENTRO del dato firmado: verifyChain verifica la firma contra
98
+ // data.publickey y exige cert.sub === data.publickey.
99
+ const data = { op: HS, eph: eph.pub, publickey: this.link.id.me?.publickey, ts: Date.now() }
100
+ const { signature } = await this.link.id.signData(data)
101
+
102
+ // El ACK se correlaciona por la clave efímera PROPIA (ceph === eph.pub): así
103
+ // varias sesiones simultáneas (varios RemoteAgentClient sobre la misma pubkey)
104
+ // no se roban el ACK de la otra.
105
+ const acked = new Promise((resolve, reject) => {
106
+ const off = this.client.on('message', (_from, p) => {
107
+ if (!p || typeof p !== 'object') return
108
+ if (p.type === ACK && p.ack && p.ack.ceph === eph.pub) { off(); resolve(p) }
109
+ else if (p.type === ERROR) { off(); reject(new Error(p.error)) }
110
+ })
111
+ setTimeout(() => { off(); reject(new Error('el agente no respondió (¿está corriendo allí?)')) }, 20000)
112
+ })
113
+ this.client.sendByPubkey(this.agentPubkey, { type: HS, data, signature, cert })
114
+ const res = await acked
115
+
116
+ // El ack debe: (1) encadenar a NUESTRA maestra, (2) estar firmado por el agente
117
+ // que apuntamos, (3) atar nuestra pub efímera y el sid.
118
+ const chk = await verifyChain({ data: res.ack, signature: res.signature, cert: res.cert, trustedIssuer: this.link.iss })
119
+ if (!chk.ok) throw new Error('el agente no está certificado por tu vault: ' + chk.reason)
120
+ if (res.ack.machine !== this.agentPubkey) throw new Error('el ack vino de otro agente')
121
+ if (res.ack.ceph !== eph.pub || res.ack.sid !== res.sid) throw new Error('ack no corresponde a este handshake')
122
+
123
+ this.sid = res.sid
124
+ this.key = await deriveKey(eph.privateKey, res.ack.seph, res.sid)
125
+ return this
126
+ }
127
+
128
+ /** Envía un payload de dominio cifrado al agente. */
129
+ async send (payload) {
130
+ const env = await seal(this.key, payload)
131
+ this.client.sendByPubkey(this.agentPubkey, { type: DATA, sid: this.sid, env })
132
+ }
133
+
134
+ /** Sonda de presencia (liveness) sin abrir sesión. Devuelve true si responde. */
135
+ async ping ({ timeoutMs = 4000 } = {}) {
136
+ const n = crypto.getRandomValues(new Uint8Array(8)).join('')
137
+ const got = new Promise((resolve) => {
138
+ const off = this.client.on('message', (_f, p) => {
139
+ if (p?.type === PONG && p.n === n) { off(); resolve(true) }
140
+ })
141
+ setTimeout(() => { off(); resolve(false) }, timeoutMs)
142
+ })
143
+ this.client.sendByPubkey(this.agentPubkey, { type: PING, n })
144
+ return got
145
+ }
146
+
147
+ async close () {
148
+ try { this.client?.close() } catch {}
149
+ }
150
+ }
151
+
152
+ export default { RemoteAgentClient }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * discover.js — autodescubrimiento de agentes vinculados al vault (lado cliente).
3
+ *
4
+ * Tus agentes = los dispositivos enrolados en TU vault (`vault.devices` trae la
5
+ * pubkey `sub` de cada uno = su dirección en el proxy). Sin pegar nada: filtrás por
6
+ * label y abrís una sesión con el que quieras.
7
+ *
8
+ * Si pasás `label` (p. ej. `'ia-agent'`), lista solo los dispositivos con ESE label
9
+ * exacto. Si NO pasás `label`, mantiene el comportamiento legacy de terminal: lista
10
+ * cualquier dispositivo que tenga un label y no sea `'cli'` (los navegadores
11
+ * enrolados quedan con label `'cli'` y no atienden agentes).
12
+ */
13
+
14
+ /**
15
+ * @param {object} id instancia de Identity (del vault) ya conectada.
16
+ * @param {string} [label] filtrar por label exacto (p. ej. 'ia-agent'). Omitir = todos los no-cli.
17
+ * @returns {Promise<Array<{sub:string,label:string,deviceId:string,exp?:number}>>}
18
+ * dedup por pubkey (renovaciones/re-emparejes → el cert más nuevo).
19
+ */
20
+ export async function listAgentsByLabel (id, label) {
21
+ const { devices } = await id.listVaultDevices()
22
+ const mine = id.me?.publickey
23
+ const now = Date.now()
24
+ const bySub = new Map()
25
+ for (const d of devices || []) {
26
+ if (!d.sub || d.sub === mine || (d.exp && d.exp <= now)) continue
27
+ if (label ? d.label !== label : (!d.label || d.label === 'cli')) continue
28
+ if (!bySub.has(d.sub) || (d.exp || 0) > (bySub.get(d.sub).exp || 0)) bySub.set(d.sub, d)
29
+ }
30
+ return [...bySub.values()]
31
+ }
32
+
33
+ export default { listAgentsByLabel }
package/src/link.js ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * link.js — enrola ESTA máquina como un dispositivo del vault (mismo flujo
3
+ * endurecido que usa el navegador / `dotrino-vault/src/client.js#enroll`). Es lo
4
+ * que vuelve al agente INDEPENDIENTE de la máquina del vault: guarda su propia
5
+ * sub-clave `D` + `cert` (cadena `D ← maestra`); la maestra NUNCA vive aquí, solo
6
+ * su pública pineada (`iss`).
7
+ *
8
+ * Persistencia en un directorio propio (por defecto
9
+ * `~/.local/share/<name>`, override `DOTRINO_REMOTE_AGENT_DIR` o `dir` explícito):
10
+ * NO comparte carpeta con el vault, así el agente puede correr en otro host.
11
+ *
12
+ * El `label` (p. ej. `terminal-agent`, `ia-agent`) viaja en el cert y lo usa el
13
+ * cliente para FILTRAR qué dispositivos son agentes del tipo que busca
14
+ * (listAgentsByLabel).
15
+ */
16
+ import fs from 'node:fs'
17
+ import path from 'node:path'
18
+ import os from 'node:os'
19
+ import {
20
+ makeDeviceKey, signWithDevice, verifyDelegation, makePairingCode, pubkeyId
21
+ } from '@dotrino/identity/capabilities'
22
+ import { installNodeGlobals } from '../node-globals.js'
23
+
24
+ const MSG = {
25
+ ENROLL: 'vault.enroll',
26
+ ENROLL_CHALLENGE: 'vault.enroll.challenge',
27
+ ENROLLED: 'vault.enrolled',
28
+ ERROR: 'vault.error'
29
+ }
30
+
31
+ /** Directorio de datos del agente. `name` define el subdir (p. ej. 'dotrino-ia-agent'). */
32
+ export function dataDir (name = 'dotrino-remote-agent') {
33
+ if (process.env.DOTRINO_REMOTE_AGENT_DIR) return process.env.DOTRINO_REMOTE_AGENT_DIR
34
+ const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share')
35
+ return path.join(base, name)
36
+ }
37
+
38
+ const linkPath = (dir) => path.join(dir, 'link.json')
39
+
40
+ export function loadLink (dir = dataDir()) {
41
+ try { return JSON.parse(fs.readFileSync(linkPath(dir), 'utf8')) } catch { return null }
42
+ }
43
+
44
+ function saveLink (dir, link) {
45
+ fs.mkdirSync(dir, { recursive: true })
46
+ fs.writeFileSync(linkPath(dir), JSON.stringify(link, null, 2), { mode: 0o600 })
47
+ }
48
+
49
+ export function parseQr (text) {
50
+ const qr = JSON.parse(String(text).trim())
51
+ if (!qr?.iss || !qr?.proxy || !qr?.token || !qr?.sn) throw new Error('QR inválido (v2): faltan iss/proxy/token/sn')
52
+ return qr
53
+ }
54
+
55
+ async function freshClient (proxyUrl, dir) {
56
+ installNodeGlobals(dir)
57
+ const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
58
+ const client = new WebSocketProxyClient({ url: proxyUrl, enableWebRTC: false, autoReconnect: false })
59
+ await client.connect()
60
+ return client
61
+ }
62
+
63
+ /**
64
+ * Enrola esta máquina contra el vault del QR. Devuelve y persiste
65
+ * `{ device, cert, iss, proxy, label, at }`.
66
+ *
67
+ * @param {object} args
68
+ * @param {object} args.qr QR parseado del vault ({ iss, proxy, token, sn }).
69
+ * @param {string} [args.label='remote-agent'] label del dispositivo (lo filtra el cliente).
70
+ * @param {string} [args.dir=dataDir()] dónde persistir el enlace.
71
+ * @param {(c:{deviceId:string,code:string})=>void} [args.onChallenge] mostrar el CÓDIGO para
72
+ * que un humano lo TIPEE en la bóveda al aprobar (el código NO viaja de aquí).
73
+ * @param {number} [args.timeoutMs=180000]
74
+ */
75
+ export async function enroll ({ qr, label = 'remote-agent', dir = dataDir(), onChallenge, timeoutMs = 180000 } = {}) {
76
+ const client = await freshClient(qr.proxy, dir)
77
+ try {
78
+ const device = await makeDeviceKey({ label })
79
+ const deviceId = (await pubkeyId(device.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
80
+ // Código de emparejamiento ALEATORIO (un secreto, NO derivable de valores públicos):
81
+ // lo mostramos y NO lo enviamos. La bóveda lo aprende solo cuando un humano lo tipea
82
+ // y nos lo ECHA de vuelta → así aprobar exige TENER esta máquina (de aquí sale el código).
83
+ const code = makePairingCode()
84
+ const data = { op: 'enroll', dpub: device.publickey, token: qr.token, sn: qr.sn, label, ts: Date.now() }
85
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
86
+
87
+ const done = new Promise((resolve, reject) => {
88
+ const off = client.on('message', (_from, p) => {
89
+ if (!p || typeof p !== 'object') return
90
+ if (p.type === MSG.ENROLL_CHALLENGE) onChallenge?.({ deviceId, code })
91
+ // Aceptamos SOLO si el código echado coincide con el que generamos (anti
92
+ // aprobación-a-ciegas / bóveda impostora). Un eco distinto se IGNORA y seguimos
93
+ // esperando la aprobación correcta (o el timeout).
94
+ else if (p.type === MSG.ENROLLED) { if (String(p.code || '').trim() === code) { cleanup(); resolve(p) } }
95
+ else if (p.type === MSG.ERROR) { cleanup(); reject(new Error(p.error)) }
96
+ })
97
+ const t = setTimeout(() => { cleanup(); reject(new Error('timeout esperando la aprobación en el vault')) }, timeoutMs)
98
+ const cleanup = () => { off(); clearTimeout(t) }
99
+ })
100
+ client.sendByPubkey(qr.iss, { type: MSG.ENROLL, data, signature })
101
+ const res = await done
102
+
103
+ const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey })
104
+ if (!v.ok) throw new Error('cert inválido: ' + v.reason)
105
+ if (res.cert.iss !== qr.iss) throw new Error('cert firmado por otra maestra (posible proxy malicioso)')
106
+
107
+ const link = { device, cert: res.cert, iss: qr.iss, proxy: qr.proxy, label, at: Date.now() }
108
+ saveLink(dir, link)
109
+ return link
110
+ } finally { client.close() }
111
+ }
112
+
113
+ export default { dataDir, loadLink, parseQr, enroll }