@dotrino/remote-agent 0.4.1 → 0.5.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 +30 -3
- package/package.json +3 -2
- package/src/link.js +76 -78
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ Provee lo común — y **solo** eso:
|
|
|
33
33
|
| `@dotrino/remote-agent` | isomórfico | `e2e` (`makeEphemeral`/`deriveKey`/`seal`/`open`) + constantes de protocolo |
|
|
34
34
|
| `@dotrino/remote-agent/agent` | Node | `startRemoteAgent(opts)` |
|
|
35
35
|
| `@dotrino/remote-agent/client` | navegador | `RemoteAgentClient` |
|
|
36
|
-
| `@dotrino/remote-agent/link` | Node | `enroll`, `loadLink`, `dataDir`, `parseQr` |
|
|
36
|
+
| `@dotrino/remote-agent/link` | Node | `enroll`, `loadLink`, `saveLink`, `dataDir`, `parseQr`, `identityFromLink`, `clientLink` |
|
|
37
37
|
| `@dotrino/remote-agent/discover` | navegador | `listAgentsByLabel` |
|
|
38
38
|
|
|
39
39
|
## Uso
|
|
@@ -68,14 +68,40 @@ const ra = await startRemoteAgent({
|
|
|
68
68
|
// `identify`, una segunda identidad de transporte y una segunda cola.
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
Enrolar la máquina (una vez, con
|
|
71
|
+
Enrolar la máquina (una vez, con la invitación del vault — URL del QR, código compacto
|
|
72
|
+
o JSON, da igual: la entiende `enrollWithVault` de `@dotrino/vault`, que es el único
|
|
73
|
+
enrolamiento headless del ecosistema y el que esto reusa):
|
|
72
74
|
|
|
73
75
|
```js
|
|
74
76
|
import { enroll } from '@dotrino/remote-agent/link'
|
|
75
77
|
const link = await enroll({
|
|
76
78
|
qr, label: 'ia-agent',
|
|
77
|
-
onChallenge: ({ deviceId, code }) => console.log(`
|
|
79
|
+
onChallenge: ({ deviceId, code }) => console.log(`Escribe en el vault: dotrino-vault approve ${code}`)
|
|
78
80
|
})
|
|
81
|
+
// link = { device, enc, cert, iss, proxy, label, ns?, at } — `enc` es la llave de
|
|
82
|
+
// CIFRADO: con ella la bóveda puede sellarle secretos a este agente.
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**Lo que puede hacer el agente lo dice la invitación, no este paquete**: no hay tipos
|
|
86
|
+
de agente, hay permisos (`dotrino-vault pair --scope sign,read,store,secrets:<ns>`). Un
|
|
87
|
+
bot que publica en las apps y lee solo su cajón de secretos se empareja con
|
|
88
|
+
`pair --service <ns> --scope sign`, y se enrola con `enroll({ qr, ns: '<ns>' })` (el `ns`
|
|
89
|
+
hace que se exija ese permiso en el cert y queda en el enlace). Sus secretos los pide
|
|
90
|
+
con `fetchSecrets`/`waitForSecrets` de `@dotrino/vault/service` pasando la identidad por
|
|
91
|
+
parámetros (`{ ns, proxyUrl: link.proxy, masterPubkey: link.iss, device, cert, enc }`).
|
|
92
|
+
|
|
93
|
+
### Un agente como CLIENTE de otro agente (Node)
|
|
94
|
+
|
|
95
|
+
Un agente Node también puede hablar con otro agente —un bot que guarda en el node de
|
|
96
|
+
contenido, por ejemplo— con el mismo código que usa una app: `clientLink(link)` arma el
|
|
97
|
+
`{ id, cert, iss, proxy }` que esperan `RemoteAgentClient` y `ContentClient`, con un `id`
|
|
98
|
+
que firma con la llave del aparato (`identityFromLink`).
|
|
99
|
+
|
|
100
|
+
```js
|
|
101
|
+
import { loadLink, clientLink } from '@dotrino/remote-agent/link'
|
|
102
|
+
import { ContentClient } from '@dotrino/content-client'
|
|
103
|
+
const cc = await ContentClient.connect({ link: clientLink(loadLink()) })
|
|
104
|
+
const ref = await cc.put(bytes, { encrypt: false, acl: 'public', mime: 'application/json' })
|
|
79
105
|
```
|
|
80
106
|
|
|
81
107
|
### Cliente (navegador, la PWA)
|
|
@@ -116,6 +142,7 @@ extraerlo luego a este paquete.
|
|
|
116
142
|
- `@dotrino/identity` (capabilities: `verifyChain`, `signWithDevice`, `verifyDeviceSig`,
|
|
117
143
|
`pubkeyId`, `makeDeviceKey`, `verifyDelegation`, `makePairingCode`)
|
|
118
144
|
- `@dotrino/proxy-client` (`WebSocketProxyClient`)
|
|
145
|
+
- `@dotrino/vault` (`enrollWithVault`: el enrolamiento headless del ecosistema; `parseInvite`)
|
|
119
146
|
- `ws` (shim de `WebSocket` en Node, lado agente)
|
|
120
147
|
|
|
121
148
|
## Licencia
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/remote-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
"type-check": "tsc --noEmit"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@dotrino/identity": "0.
|
|
26
|
+
"@dotrino/identity": "0.56.0",
|
|
27
27
|
"@dotrino/proxy-client": "0.10.1",
|
|
28
|
+
"@dotrino/vault": "0.30.0",
|
|
28
29
|
"ws": "8.21.0"
|
|
29
30
|
},
|
|
30
31
|
"engines": {
|
package/src/link.js
CHANGED
|
@@ -16,19 +16,10 @@
|
|
|
16
16
|
import fs from 'node:fs'
|
|
17
17
|
import path from 'node:path'
|
|
18
18
|
import os from 'node:os'
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
} from '@dotrino/identity/capabilities'
|
|
19
|
+
import { signWithDevice, pubkeyId } from '@dotrino/identity/capabilities'
|
|
20
|
+
import { requestDevices } from '@dotrino/identity/vault/remote.js'
|
|
22
21
|
import { installNodeGlobals } from '../node-globals.js'
|
|
23
22
|
|
|
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
23
|
export function dataDir (name = 'dotrino-remote-agent') {
|
|
33
24
|
if (process.env.DOTRINO_REMOTE_AGENT_DIR) return process.env.DOTRINO_REMOTE_AGENT_DIR
|
|
34
25
|
const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share')
|
|
@@ -37,6 +28,7 @@ export function dataDir (name = 'dotrino-remote-agent') {
|
|
|
37
28
|
|
|
38
29
|
const linkPath = (dir) => path.join(dir, 'link.json')
|
|
39
30
|
|
|
31
|
+
/** Lee el enlace persistido (o null si no está enrolado). */
|
|
40
32
|
export function loadLink (dir = dataDir()) {
|
|
41
33
|
try { return JSON.parse(fs.readFileSync(linkPath(dir), 'utf8')) } catch { return null }
|
|
42
34
|
}
|
|
@@ -47,91 +39,97 @@ export function saveLink (dir, link) {
|
|
|
47
39
|
fs.writeFileSync(linkPath(dir), JSON.stringify(link, null, 2), { mode: 0o600 })
|
|
48
40
|
}
|
|
49
41
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Parsea una invitación de la bóveda a objeto. Acepta TODAS las formas que imprime
|
|
44
|
+
* `dotrino-vault pair` (URL del QR, código compacto, JSON, base64url): es el mismo
|
|
45
|
+
* `parseInvite` del ecosistema. `enroll` ya lo hace solo; esto es para quien quiera
|
|
46
|
+
* mirar la invitación antes (qué bóveda, qué proxy) sin enrolar.
|
|
47
|
+
*/
|
|
48
|
+
export async function parseQr (text) {
|
|
49
|
+
const { parseInvite } = await import('@dotrino/vault/invite')
|
|
50
|
+
const qr = parseInvite(String(text ?? '').trim())
|
|
51
|
+
if (!qr?.sn || !(qr.iss || qr.conn)) throw new Error('invalid invitation: paste the output of `dotrino-vault pair`')
|
|
53
52
|
return qr
|
|
54
53
|
}
|
|
55
54
|
|
|
56
|
-
|
|
57
|
-
// URL "profile.dotrino.com/#vault=<base64url>". El base64url es el formato canónico
|
|
58
|
-
// del ecosistema (no expone la estructura en texto plano).
|
|
55
|
+
/** base64url → utf8 (lo usan los tests y algún QR viejo). */
|
|
59
56
|
export function b64urlDecode (s) {
|
|
60
57
|
const b64 = String(s).replace(/-/g, '+').replace(/_/g, '/')
|
|
61
58
|
return Buffer.from(b64, 'base64').toString('utf8')
|
|
62
59
|
}
|
|
63
60
|
|
|
64
|
-
function normalizeQr (text) {
|
|
65
|
-
let s = String(text ?? '').trim()
|
|
66
|
-
const i = s.indexOf('#vault=')
|
|
67
|
-
if (i >= 0) s = s.slice(i + 7).trim() // profile.dotrino.com/#vault=…
|
|
68
|
-
if (!s.startsWith('{')) { // no es JSON → probar base64url
|
|
69
|
-
const json = b64urlDecode(s)
|
|
70
|
-
if (json && json.trim().startsWith('{')) s = json
|
|
71
|
-
else throw new Error('código de emparejamiento inválido (se espera JSON o base64url)')
|
|
72
|
-
}
|
|
73
|
-
return JSON.parse(s)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
async function freshClient (proxyUrl, dir) {
|
|
77
|
-
installNodeGlobals(dir)
|
|
78
|
-
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
79
|
-
const client = new WebSocketProxyClient({ url: proxyUrl, enableWebRTC: false, autoReconnect: false })
|
|
80
|
-
await client.connect()
|
|
81
|
-
return client
|
|
82
|
-
}
|
|
83
|
-
|
|
84
61
|
/**
|
|
85
|
-
* Enrola
|
|
86
|
-
* `{ device, cert, iss, proxy, label, at }`.
|
|
62
|
+
* Enrola ESTA máquina contra la bóveda y persiste el enlace
|
|
63
|
+
* `{ device, enc, cert, iss, proxy, label, ns?, at }`.
|
|
64
|
+
*
|
|
65
|
+
* El enrolamiento en sí es el del ecosistema —`enrollWithVault` de `@dotrino/vault`—
|
|
66
|
+
* y no se duplica aquí: entiende cualquier invitación, crea las DOS llaves del aparato
|
|
67
|
+
* (firma y **cifrado**: sin la segunda la bóveda no puede sellarle secretos) y
|
|
68
|
+
* verifica el cert. Este módulo solo decide dónde vive.
|
|
69
|
+
*
|
|
70
|
+
* Lo que puede hacer el aparato lo decide la invitación (`dotrino-vault pair --scope …`):
|
|
71
|
+
* no hay tipos de agente, hay permisos. Un bot que publica y lee su cajón se empareja
|
|
72
|
+
* con `pair --service <ns> --scope sign`; pásale `ns` para que el enlace lo recuerde y
|
|
73
|
+
* exija ese permiso en el cert.
|
|
87
74
|
*
|
|
88
75
|
* @param {object} args
|
|
89
|
-
* @param {object} args.qr
|
|
76
|
+
* @param {object|string} args.qr La invitación, en cualquiera de sus formas.
|
|
90
77
|
* @param {string} [args.label='remote-agent'] label del dispositivo (lo filtra el cliente).
|
|
91
78
|
* @param {string} [args.dir=dataDir()] dónde persistir el enlace.
|
|
79
|
+
* @param {string} [args.ns] cajón de secretos que lee este agente (si lo hay).
|
|
92
80
|
* @param {(c:{deviceId:string,code:string})=>void} [args.onChallenge] mostrar el CÓDIGO para
|
|
93
81
|
* que un humano lo TIPEE en la bóveda al aprobar (el código NO viaja de aquí).
|
|
94
82
|
* @param {number} [args.timeoutMs=180000]
|
|
95
83
|
*/
|
|
96
|
-
export async function enroll ({ qr, label = 'remote-agent', dir = dataDir(), onChallenge, timeoutMs = 180000 } = {}) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const done = new Promise((resolve, reject) => {
|
|
112
|
-
const off = client.on('message', (_from, p) => {
|
|
113
|
-
if (!p || typeof p !== 'object') return
|
|
114
|
-
if (p.type === MSG.ENROLL_CHALLENGE) onChallenge?.({ deviceId, code })
|
|
115
|
-
// Aceptamos SOLO si el código echado coincide con el que generamos (anti
|
|
116
|
-
// aprobación-a-ciegas / bóveda impostora). Un eco distinto se IGNORA y seguimos
|
|
117
|
-
// esperando la aprobación correcta (o el timeout).
|
|
118
|
-
else if (p.type === MSG.ENROLLED) { if (String(p.code || '').trim() === code) { cleanup(); resolve(p) } }
|
|
119
|
-
else if (p.type === MSG.ERROR) { cleanup(); reject(new Error(p.error)) }
|
|
120
|
-
})
|
|
121
|
-
const t = setTimeout(() => { cleanup(); reject(new Error('timeout esperando la aprobación en el vault')) }, timeoutMs)
|
|
122
|
-
const cleanup = () => { off(); clearTimeout(t) }
|
|
123
|
-
})
|
|
124
|
-
client.sendByPubkey(qr.iss, { type: MSG.ENROLL, data, signature })
|
|
125
|
-
const res = await done
|
|
84
|
+
export async function enroll ({ qr, label = 'remote-agent', dir = dataDir(), ns = null, onChallenge, timeoutMs = 180000 } = {}) {
|
|
85
|
+
installNodeGlobals(dir)
|
|
86
|
+
const { enrollWithVault } = await import('@dotrino/vault/service')
|
|
87
|
+
const res = await enrollWithVault({
|
|
88
|
+
qr,
|
|
89
|
+
label,
|
|
90
|
+
expectedScope: ns ? 'vault:secrets:' + ns : null,
|
|
91
|
+
onCode: onChallenge,
|
|
92
|
+
approveTimeoutMs: timeoutMs
|
|
93
|
+
})
|
|
94
|
+
const link = { device: res.device, enc: res.enc, cert: res.cert, iss: res.iss, proxy: res.proxy, label, ...(ns ? { ns } : {}), at: Date.now() }
|
|
95
|
+
saveLink(dir, link)
|
|
96
|
+
return link
|
|
97
|
+
}
|
|
126
98
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
99
|
+
/**
|
|
100
|
+
* La IDENTIDAD de un agente a partir de su enlace: lo que un cliente del ecosistema
|
|
101
|
+
* espera como `link.id` (`RemoteAgentClient`, `ContentClient`, `listAgentsByLabel`) —
|
|
102
|
+
* `me.publickey`, `signData`, `listVaultDevices`, `getVaultCert`, `vaultStatus`— pero
|
|
103
|
+
* firmando con la llave del aparato en vez de con el pilar del navegador. Así un agente
|
|
104
|
+
* Node puede ser CLIENTE de otro agente (un bot que guarda en el node de contenido)
|
|
105
|
+
* con el mismo código que usa una app.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} link el enlace (`loadLink(dir)`).
|
|
108
|
+
* @param {{ dir?: string }} [opts] dir para los globals de Node (localStorage del proxy-client).
|
|
109
|
+
*/
|
|
110
|
+
export function identityFromLink (link, { dir = dataDir() } = {}) {
|
|
111
|
+
if (!link?.device?.privateJwk || !link?.cert || !link?.iss) throw new Error('invalid link: device/cert/iss required')
|
|
112
|
+
installNodeGlobals(dir)
|
|
113
|
+
const device = link.device
|
|
114
|
+
const proxy = link.proxy || 'wss://proxy.dotrino.com'
|
|
115
|
+
return {
|
|
116
|
+
me: { publickey: device.publickey },
|
|
117
|
+
async signData (data) {
|
|
118
|
+
return signWithDevice({ privateJwk: device.privateJwk, publickey: device.publickey, data })
|
|
119
|
+
},
|
|
120
|
+
async getVaultCert () { return { cert: link.cert } },
|
|
121
|
+
async vaultStatus () {
|
|
122
|
+
return { paired: true, master: link.iss, proxy, exp: link.cert.exp, deviceId: await pubkeyId(device.publickey) }
|
|
123
|
+
},
|
|
124
|
+
async listVaultDevices () {
|
|
125
|
+
return requestDevices({ master: link.iss, proxy, device, cert: link.cert, sinceSeq: 0 })
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
130
129
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
} finally { client.close() }
|
|
130
|
+
/** El enlace como lo esperan `ContentClient.connect({ link })` y `RemoteAgentClient`. */
|
|
131
|
+
export function clientLink (link, opts) {
|
|
132
|
+
return { id: identityFromLink(link, opts), cert: link.cert, iss: link.iss, proxy: link.proxy || 'wss://proxy.dotrino.com' }
|
|
135
133
|
}
|
|
136
134
|
|
|
137
|
-
export default { dataDir, loadLink, parseQr, enroll }
|
|
135
|
+
export default { dataDir, loadLink, saveLink, parseQr, enroll, identityFromLink, clientLink }
|