@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.
Files changed (3) hide show
  1. package/README.md +30 -3
  2. package/package.json +3 -2
  3. 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 el QR del vault):
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(`Tipeá en el vault: ${code}`)
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.4.1",
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.53.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
- makeDeviceKey, signWithDevice, verifyDelegation, makePairingCode, commitCode, pubkeyId
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
- export function parseQr (text) {
51
- const qr = normalizeQr(text)
52
- if (!qr?.iss || !qr?.proxy || !qr?.token || !qr?.sn) throw new Error('QR inválido (v2): faltan iss/proxy/token/sn')
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
- // Acepta el código de emparejamiento en 3 formas: JSON crudo, base64url del JSON, o
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 esta máquina contra el vault del QR. Devuelve y persiste
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 QR parseado del vault ({ iss, proxy, token, sn }).
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
- const client = await freshClient(qr.proxy, dir)
98
- try {
99
- const device = await makeDeviceKey({ label })
100
- const deviceId = (await pubkeyId(device.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
101
- // Código de emparejamiento ALEATORIO (un secreto, NO derivable de valores públicos):
102
- // lo mostramos y NO lo enviamos. La bóveda lo aprende solo cuando un humano lo tipea
103
- // y nos lo ECHA de vuelta → así aprobar exige TENER esta máquina (de aquí sale el código).
104
- const code = makePairingCode()
105
- // El COMPROMISO del código (nunca el código): la bóveda lo recompone con lo que
106
- // tipeas y solo entonces firma el cert → aprobar exige haber leído esta pantalla.
107
- const commit = await commitCode({ code, dpub: device.publickey, sn: qr.sn })
108
- const data = { op: 'enroll', dpub: device.publickey, token: qr.token, sn: qr.sn, commit, label, ts: Date.now() }
109
- const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
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
- const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey })
128
- if (!v.ok) throw new Error('cert inválido: ' + v.reason)
129
- if (res.cert.iss !== qr.iss) throw new Error('cert firmado por otra maestra (posible proxy malicioso)')
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
- const link = { device, cert: res.cert, iss: qr.iss, proxy: qr.proxy, label, at: Date.now() }
132
- saveLink(dir, link)
133
- return link
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 }