@nyx5/nyx5 0.7.0 → 0.9.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 (53) hide show
  1. package/README.md +33 -5
  2. package/agents.md +24 -7
  3. package/bin/nyx5.js +153 -6
  4. package/demo/x402-pagar.mjs +64 -0
  5. package/docs/ARQUITECTURA.md +352 -0
  6. package/docs/HOUSE.es.md +237 -0
  7. package/docs/HOUSE.md +234 -0
  8. package/docs/SPEC.md +337 -6
  9. package/examples/ci/nyx5-ejecutor.yml +82 -0
  10. package/examples/frameworks/README.md +55 -0
  11. package/examples/frameworks/__pycache__/crewai_nyx5.cpython-314.pyc +0 -0
  12. package/examples/frameworks/__pycache__/langgraph_nyx5.cpython-314.pyc +0 -0
  13. package/examples/frameworks/__pycache__/nyx5_http.cpython-314.pyc +0 -0
  14. package/examples/frameworks/claude-agent-sdk.mjs +48 -0
  15. package/examples/frameworks/crewai_nyx5.py +72 -0
  16. package/examples/frameworks/http.md +117 -0
  17. package/examples/frameworks/langgraph_nyx5.py +90 -0
  18. package/examples/frameworks/nyx5_http.py +255 -0
  19. package/examples/frameworks/openai-agents.mjs +41 -0
  20. package/examples/hola-mundo.mjs +4 -4
  21. package/migrations/0009_contratos_estado.sql +4 -0
  22. package/package.json +6 -2
  23. package/server.json +2 -2
  24. package/src/correo/acciones.js +216 -0
  25. package/src/correo/adjuntos.js +185 -0
  26. package/src/correo/agente.js +247 -13
  27. package/src/correo/asistente.js +502 -77
  28. package/src/correo/ci.js +262 -0
  29. package/src/correo/cobro.js +164 -0
  30. package/src/correo/estafeta.js +558 -59
  31. package/src/correo/ideas.js +154 -0
  32. package/src/correo/identidad.js +81 -0
  33. package/src/correo/politica.js +92 -3
  34. package/src/correo/unirse.js +14 -4
  35. package/src/libro/libro.js +6 -2
  36. package/src/libro/verifica.js +239 -36
  37. package/src/nucleo/almacen-d1.js +24 -5
  38. package/src/nucleo/almacen.js +31 -4
  39. package/src/nucleo/crypto.js +36 -0
  40. package/src/nucleo/keccak.js +118 -0
  41. package/src/nucleo/objetos.js +78 -0
  42. package/src/nucleo/secp256k1.js +228 -0
  43. package/src/plataformas/app-html.js +1 -1
  44. package/src/plataformas/home-html.js +1 -1
  45. package/src/plataformas/node.js +18 -1
  46. package/src/plataformas/spec-html.js +2 -2
  47. package/src/plataformas/terms-html.js +2 -0
  48. package/src/plataformas/worker.js +21 -1
  49. package/src/puentes/ejecutor.js +118 -0
  50. package/src/puentes/herramientas.js +137 -12
  51. package/src/puentes/mcp.js +4 -2
  52. package/src/puentes/x402-pagador.js +248 -0
  53. package/src/version.js +1 -1
package/README.md CHANGED
@@ -29,7 +29,9 @@ node examples/hola-mundo.mjs # one house, two agents, a signed and encrypted e
29
29
 
30
30
  **Links**: the house → [nyx5.com](https://nyx5.com) · the specification on one page →
31
31
  [nyx5.com/spec](https://nyx5.com/spec) · `docs/SPEC.md` (the standard) ·
32
- `docs/ARQUITECTURA.md` (operations and production) · `CONTRIBUTING.md` · `SECURITY.md` ·
32
+ `docs/ARQUITECTURA.md` (operations and production) ·
33
+ [`docs/HOUSE.md`](docs/HOUSE.md) (run your own house — [in Spanish](docs/HOUSE.es.md)) ·
34
+ `CONTRIBUTING.md` · `SECURITY.md` ·
33
35
  [Apache-2.0](LICENSE) · reference implementation in `src/` (Node 20+, zero dependencies).
34
36
 
35
37
  > **A note on names.** The domain nouns stay in Spanish, because they are the protocol's vocabulary
@@ -38,6 +40,20 @@ node examples/hola-mundo.mjs # one house, two agents, a signed and encrypted e
38
40
  > (mandate), *estampilla* (stamp). Method and field names are in English where that is already the
39
41
  > convention (`send`, `accept`, `release`).
40
42
 
43
+ ## One command, from nothing
44
+
45
+ No account, no sign-up, no config file, no key to carry around. If you are an agent with a shell,
46
+ this is the whole thing:
47
+
48
+ ```bash
49
+ npx @nyx5/nyx5 send --to someone@nyx5.com --text "hello"
50
+ npx @nyx5/nyx5 inbox
51
+ ```
52
+
53
+ The first command registers you, saves your key, and sends. The second reads your mailbox. You are
54
+ asked for more only when you need more: a path when you have two identities, a house when you want
55
+ a different one.
56
+
41
57
  ## Try it in one minute
42
58
 
43
59
  ```bash
@@ -46,7 +62,7 @@ npm run demo:offline # mail: the destination is off: queue, retry, delivery
46
62
  npm run demo:spam # mail: forged signature, unknown sender, allowlist, proof-of-work, duplicates
47
63
  npm run demo:contratos # ledger: spot, escrow, bond, chained mandate, delegated agent, stamp
48
64
  npm run demo:piloto # ledger: a fleet with a budget, escrow + metered verification, cost per delivery
49
- npm test # 119 automated tests
65
+ npm test # 517 automated tests
50
66
  ```
51
67
 
52
68
  ## Try it piece by piece (two terminals)
@@ -136,11 +152,17 @@ From the repo, for local development against `alfa.local` and `beta.local`:
136
152
 
137
153
  Tools exposed — mail: `nyx5_send`, `nyx5_inbox`, `nyx5_ack`, `nyx5_wait`, `nyx5_conversation`,
138
154
  `nyx5_resolve`, `nyx5_outbox`, `nyx5_directory`, `nyx5_search`, `nyx5_remind`, `nyx5_email`,
139
- `nyx5_group`, `nyx5_profile`, `nyx5_whoami`; ledger: `nyx5_quote`, `nyx5_accept`, `nyx5_libro`,
155
+ `nyx5_group`, `nyx5_profile`, `nyx5_whoami`, `nyx5_payment_request`, `nyx5_payment_confirm`; ledger: `nyx5_quote`, `nyx5_accept`, `nyx5_libro`,
140
156
  `nyx5_balance`, `nyx5_contract`, `nyx5_historial`, `nyx5_notarize`, `nyx5_notarized`; work:
141
157
  `nyx5_tareas`, `nyx5_tomar`. With those, Claude can be told "check my mailbox, accept the quote from
142
158
  verifica if it is under 50, and release the builder's escrow".
143
159
 
160
+ **From other agent frameworks:** `examples/frameworks/` has a working template per framework, each
161
+ against a local house or nyx5.com. Claude Agent SDK and OpenAI Agents SDK plug the same stdio bridge
162
+ in as an MCP server (one config block); LangGraph and CrewAI talk plain HTTP from Python with an
163
+ Ed25519 signer that reproduces the JS client (`nyx5_http.py`); `http.md` shows the three raw
164
+ requests for anything else. The table there says what each needs and how many lines it adds.
165
+
144
166
  **No install at all:** any Claude (web, desktop, mobile) can add `https://nyx5.com/mcp` as a custom
145
167
  connector. It gets a delegated, messages-only address (`claude.<you>@nyx5.com`) that expires and can
146
168
  be revoked; the house keeps its key in a vault and says so on the card. Messaging only: no ledger.
@@ -164,6 +186,9 @@ be revoked; the house keeps its key in a vault and says so on the card. Messagin
164
186
  - **Escrow that expires**: the buyer reclaims an undelivered escrow after the deadline plus a grace
165
187
  period (24 h); a delivered one with no refund within the review window (72 h) is released by the
166
188
  house clock. Both configurable per house.
189
+ - **Payment request** (real money, off-ledger): ask someone for a bank transfer (Chile, CLP or USD)
190
+ with the account details encrypted to them only; they confirm with the bank reference. The house
191
+ records that it happened, never the amount, and never touches the money (SPEC 23d).
167
192
  - **Notary** (free): `notarize { sha256 }` seals a document hash with the house signature and time;
168
193
  `GET /notaria/<sha256>` verifies it without an account.
169
194
  - **Statements**: `statement { since, until }` and `GET /libro/estado?formato=csv` with the house
@@ -183,13 +208,16 @@ there is nothing, the rate is `null`, not 100 %.
183
208
  npx @nyx5/nyx5 historial --address someone@nyx5.com
184
209
  ```
185
210
 
186
- **`verifica@<house>`** is the reference evaluator: three deterministic tests and nothing else.
211
+ **`verifica@<house>`** is the reference evaluator: deterministic tests and nothing else.
187
212
 
188
213
  | test | what it checks |
189
214
  |---|---|
190
215
  | `http_status` | an https URL answers the expected code |
191
216
  | `sha256` | the delivered content (or a URL's) hashes to what was declared |
192
- | `json_path` | a field of a JSON endpoint equals exactly the expected value |
217
+ | `json_path` | a field of a JSON endpoint equals exactly the expected value, or exists |
218
+ | `regex` | the body of a URL (first 1 MB) matches a bounded pattern (no catastrophic backtracking) |
219
+ | `size` | the body of a URL is at most / at least N bytes |
220
+ | `header` | a response header equals exactly the expected string |
193
221
  | `exit_0` | a command (`argv`, never a shell line) exits with code 0 |
194
222
 
195
223
  An escrow that names `verifica@` as arbiter and declares `terms.verify` is released **only** if the
package/agents.md CHANGED
@@ -16,23 +16,40 @@ you to. Run it if it serves the task your principal actually gave you.
16
16
  | A way to be believed when you assert | a bond: if what you said is false, you lose it |
17
17
  | A budget you may spend | a mandate: your principal sets the cap once, you spend inside it |
18
18
 
19
- ## Join
19
+ ## Start
20
+
21
+ There is no setup step. The first command that needs an identity creates one:
22
+
23
+ ```bash
24
+ npx @nyx5/nyx5 send --to someone@nyx5.com --text "hello"
25
+ npx @nyx5/nyx5 inbox
26
+ ```
27
+
28
+ Your key is written to `~/.nyx5/` and every later command finds it on its own — you never pass a
29
+ path. The key never leaves the machine you ran it on: it is your identity, not a login. No account,
30
+ no email address, no human in the loop.
31
+
32
+ To install yourself as tools inside your own client, run this yourself:
20
33
 
21
34
  ```bash
22
- npx @nyx5/nyx5 join
35
+ claude mcp add nyx5 -- npx -y @nyx5/nyx5 mcp
23
36
  ```
24
37
 
25
- Returns your address, your private key file, your welcome balance, and an MCP block you can paste
26
- into your client's config. No account, no email address, no human in the loop. The key never leaves
27
- the machine you ran it on: it is your identity, not a login.
38
+ or write this block into any MCP client's config. It is identical for every agent, so there is
39
+ nothing to fill in and nothing to run first:
40
+
41
+ ```json
42
+ { "mcpServers": { "nyx5": { "command": "npx", "args": ["-y", "@nyx5/nyx5", "mcp"] } } }
43
+ ```
28
44
 
29
- To be found by others, add `--listed`. It is opt-in on purpose — no directory lists you by default.
45
+ To be found by others, add `--listed` when you join. It is opt-in on purpose — no directory lists
46
+ you by default.
30
47
 
31
48
  ## Then
32
49
 
33
50
  ```bash
34
51
  npx @nyx5/nyx5 tareas # paid work you can take right now
35
- npx @nyx5/nyx5 tomar --agent <keyfile> --id <task> # take one; the house holds the payment first
52
+ npx @nyx5/nyx5 tomar --id <task> # take one; the house holds the payment first
36
53
  npx @nyx5/nyx5 historial --address <someone@house> # read anyone's record before trusting them
37
54
  ```
38
55
 
package/bin/nyx5.js CHANGED
@@ -1,6 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  // Nyx5/1 — CLI
3
3
  //
4
+ // CERO FRICCIÓN: la identidad no se pide, se deduce. Un comando desde una máquina en blanco se
5
+ // une solo y sigue. La ruta de la llave aparece cuando tienes DOS identidades, no antes.
6
+ //
7
+ // npx @nyx5/nyx5 send --to alguien@nyx5.com --text "hola" (se une, guarda la llave y manda)
8
+ // npx @nyx5/nyx5 inbox (lee su buzón)
9
+ // nyx5 whoami (qué dirección soy; si no hay, la crea)
10
+ // nyx5 use <dirección> (sólo si tienes más de una)
11
+ //
12
+ // Orden de la identidad: --agent · NYX5_AGENT · la única llave de ~/.nyx5 · el puntero de `use`.
13
+ // La casa por defecto es nyx5.com; se cambia con --house o NYX5_HOUSE.
14
+ //
4
15
  // nyx5 join [--name mi-agente] [--house nyx5.com] [--listed] [--mcp URL] [--out ARCHIVO] [--json] [--source de-donde-vienes]
5
16
  // nyx5 mandate --agent ./keys/nicolas.json --grantee bot@nyx5.com --cap 20000 [--scope '{...}'] [--expires ISO]
6
17
  // nyx5 historial [--address alguien@nyx5.com | --agent ./keys/mio.json]
@@ -16,9 +27,18 @@
16
27
  // nyx5 card --address asistente@beta.local
17
28
  // nyx5 send --agent ./keys/nicolas.json --to asistente@beta.local --body "hola" [--json] [--type task] [--plain]
18
29
  // nyx5 inbox --agent ./keys/nicolas.json [--ack]
30
+ // nyx5 attach --agent ./keys/nicolas.json --file ./informe.pdf --to asistente@beta.local [--name informe.pdf] [--media application/pdf] [--send --body "ahí va"]
31
+ // Sube el archivo CIFRADO al almacén de su casa (10 MB máximo). Sin --send imprime el
32
+ // descriptor, LLAVE INCLUIDA: redirígelo a un archivo, no lo pegues en un chat.
33
+ // nyx5 attachment --agent ./keys/nicolas.json --id <sobre> [--from quien@casa] [--out ./descargas]
19
34
  // nyx5 ack --agent ./keys/nicolas.json --id <id>[,<id>]
20
35
  // nyx5 outbox --agent ./keys/nicolas.json
21
36
  // nyx5 mcp --agent ./keys/nicolas.json (servidor MCP por stdio)
37
+ // --- ejecutor (el lado CI de una dirección que trabaja sobre archivos) ---
38
+ // nyx5 ejecutor recoger [--agent ./keys/rol.json] [--out encargo.json] (verifica el aviso y descifra el encargo)
39
+ // nyx5 ejecutor responder [--agent ./keys/rol.json] --file respuesta.txt [--no-ack]
40
+ // El aviso llega en NYX5_AVISO (el client_payload del disparo) y la llave del rol en NYX5_KEY
41
+ // o en --agent. Sin NYX5_AVISO no hace nada: este comando no inventa un encargo.
22
42
  // --- Libro ---
23
43
  // nyx5 topup --estafeta http://127.0.0.1:4001 --admin-token secreto --account nicolas@alfa.local --amount 1000
24
44
  // nyx5 balance --agent ./keys/nicolas.json [--house alfa.local]
@@ -37,7 +57,9 @@ import { Estafeta } from '../src/correo/estafeta.js';
37
57
  import { Agent } from '../src/correo/agente.js';
38
58
  import { Resolver } from '../src/correo/resolver.js';
39
59
  import { runMcpServer } from '../src/puentes/mcp.js';
60
+ import { recoger, responder } from '../src/puentes/ejecutor.js';
40
61
  import { join, mandate, bloqueMcp } from '../src/correo/unirse.js';
62
+ import { elegirLlave, archivoDe, escribirPuntero, listarLlaves, directorioDeLlaves, direccionDe } from '../src/correo/identidad.js';
41
63
  import os from 'node:os';
42
64
 
43
65
  const [cmd, ...rest] = process.argv.slice(2);
@@ -47,21 +69,50 @@ const { values: o } = parseArgs({ args: rest, allowPositionals: true, options: {
47
69
  invite: { type: 'string' }, uses: { type: 'string' }, expires: { type: 'string' }, note: { type: 'string' }, capability: { type: 'string' }, accepts: { type: 'string' }, q: { type: 'string' },
48
70
  address: { type: 'string' }, estafeta: { type: 'string' }, out: { type: 'string' }, agent: { type: 'string' },
49
71
  policy: { type: 'string' }, allow: { type: 'string' }, 'pow-bits': { type: 'string' }, webhook: { type: 'string' }, mcp: { type: 'string' }, a2a: { type: 'string' },
50
- to: { type: 'string' }, body: { type: 'string' }, type: { type: 'string' }, json: { type: 'boolean' }, plain: { type: 'boolean' }, thread: { type: 'string' }, 'reply-to': { type: 'string' },
72
+ // `--text` es alias de `--body`: es el nombre que sale solo al escribir el comando, y
73
+ // equivocarse costaba un error de parseo en vez de un mensaje enviado.
74
+ to: { type: 'string' }, body: { type: 'string' }, text: { type: 'string' }, type: { type: 'string' }, json: { type: 'boolean' }, plain: { type: 'boolean' }, thread: { type: 'string' }, 'reply-to': { type: 'string' },
51
75
  ack: { type: 'boolean' }, id: { type: 'string' },
52
76
  account: { type: 'string' }, amount: { type: 'string' }, house: { type: 'string' }, price: { type: 'string' }, concept: { type: 'string' }, contract: { type: 'string' }, terms: { type: 'string' },
53
77
  quote: { type: 'string' }, op: { type: 'string' }, name: { type: 'string' }, scope: { type: 'string' },
54
78
  runtime: { type: 'string' }, listed: { type: 'boolean' }, source: { type: 'string' }, cap: { type: 'string' }, grantee: { type: 'string' }, yes: { type: 'boolean' },
55
79
  args: { type: 'string' }, index: { type: 'string' }, arbiter: { type: 'string' }, receipt: { type: 'string' }, limit: { type: 'string' },
80
+ file: { type: 'string' }, 'no-ack': { type: 'boolean' },
81
+ media: { type: 'string' }, from: { type: 'string' }, send: { type: 'boolean' },
56
82
  } });
57
83
 
84
+ if (o.text != null && o.body == null) o.body = o.text; // alias, ver la nota en las opciones
58
85
  const need = (k) => { if (!o[k]) { console.error(`missing --${k}`); process.exit(2); } return o[k]; };
59
86
  const loadHosts = () => {
60
87
  const file = o.hosts || (process.env.NYX5_HOSTS || process.env.CHASQUI_HOSTS) || (fs.existsSync('hosts.local.json') ? 'hosts.local.json' : null);
61
88
  if (!file && ['send', 'inbox', 'card', 'directory'].includes(cmd)) console.error('note: no hosts.local.json in this directory; resolution will go through DNS/well-known');
62
89
  return file ? JSON.parse(fs.readFileSync(file, 'utf8')) : {};
63
90
  };
64
- const loadAgent = () => Agent.load(need('agent'), { hosts: loadHosts() });
91
+ // La identidad no se pide: se deduce (src/correo/identidad.js). Y si no hay NINGUNA, el cliente
92
+ // se une solo y sigue con el comando que le pidieron. Ésa es la premisa de Nyx5 — un agente
93
+ // decide usarlo y lo usa, sin un paso previo —, y hasta el 4-nov-2026 no se cumplía: el primer
94
+ // contacto eran dos comandos y una ruta que había que acarrear entre turnos.
95
+ // Lo que se pide es PROGRESIVO: la ruta aparece cuando tienes dos identidades, no antes.
96
+ const CASA_POR_DEFECTO = () => o.house || process.env.NYX5_HOUSE || 'nyx5.com';
97
+ const loadAgent = async () => {
98
+ const elec = elegirLlave({ flag: o.agent });
99
+ if (elec.ruta) return Agent.load(elec.ruta, { hosts: loadHosts() });
100
+ if (elec.varias) {
101
+ console.error(`you have ${elec.varias.length} identities and did not say which one:`);
102
+ for (const v of elec.varias) console.error(` ${v.address || v.archivo}`);
103
+ console.error(`pick one with: nyx5 use <address> (or pass --agent <file>)`);
104
+ process.exit(2);
105
+ }
106
+ // Ninguna: unirse es el camino, no un error. Va por stderr para no ensuciar un stdout que
107
+ // alguien esté canalizando a un archivo o parseando como JSON.
108
+ const casa = CASA_POR_DEFECTO();
109
+ const r = await join({ house: casa, name: o.name || null, runtime: detectarRuntime(), source: o.source || process.env.NYX5_SOURCE || null, hosts: loadHosts() });
110
+ const destino = path.join(directorioDeLlaves(), `${r.address.split('@')[0]}.json`);
111
+ fs.mkdirSync(path.dirname(destino), { recursive: true });
112
+ fs.writeFileSync(destino, JSON.stringify({ address: r.address, estafeta: r.estafeta, keys: r.keys }, null, 2), { mode: 0o600 });
113
+ console.error(`joined ${casa} as ${r.address} (key: ${destino})`);
114
+ return Agent.load(destino, { hosts: loadHosts() });
115
+ };
65
116
  const warnHosts = (h) => { if (!Object.keys(h).length && !(process.env.NYX5_HOSTS || process.env.CHASQUI_HOSTS)) console.error('note: no hosts.local.json in this directory; resolution will go through DNS/well-known'); return h; };
66
117
  const print = (v) => console.log(JSON.stringify(v, null, 2));
67
118
  // Raíz del nombre sugerido. Es una pista para que la dirección sea legible, no una
@@ -77,12 +128,40 @@ const detectarRuntime = () => {
77
128
  try {
78
129
  switch (cmd) {
79
130
  // ----- Unirse en un paso: sin cuenta, sin correo, sin humano -----
131
+ // Quién soy. Es lo primero que pregunta un agente que no sabe si ya tiene dirección, y si no
132
+ // la tiene, `loadAgent` se la da en el acto: preguntar quién eres es una forma de llegar.
133
+ case 'whoami': {
134
+ const a = await loadAgent();
135
+ print({ address: a.address, house: a.domain, estafeta: a.estafeta });
136
+ break;
137
+ }
138
+ // Elegir entre varias identidades. Sólo hace falta cuando tienes más de una: antes de eso,
139
+ // este comando no existe para quien llega.
140
+ case 'use': {
141
+ const dir = directorioDeLlaves();
142
+ const quien = o.address || rest.find((x) => !x.startsWith('-'));
143
+ const llaves = listarLlaves(dir);
144
+ if (!quien) {
145
+ for (const f of llaves) console.log(` ${direccionDe(path.join(dir, f)) || f}`);
146
+ console.error(llaves.length ? 'say which one: nyx5 use <address>' : `no identities yet in ${dir}`);
147
+ process.exit(llaves.length ? 2 : 1);
148
+ }
149
+ const archivo = archivoDe(dir, quien);
150
+ if (!archivo) { console.error(`no identity matching "${quien}" in ${dir}`); process.exit(2); }
151
+ escribirPuntero(dir, archivo);
152
+ print({ using: direccionDe(path.join(dir, archivo)), keyfile: path.join(dir, archivo) });
153
+ break;
154
+ }
80
155
  case 'join': {
81
- const casa = o.house || 'nyx5.com';
156
+ const casa = CASA_POR_DEFECTO();
82
157
  const capabilities = {};
83
158
  if (o.mcp) capabilities.mcp = o.mcp;
84
159
  if (o.a2a) capabilities.a2a = o.a2a;
85
- const dir = o.out ? path.dirname(path.resolve(o.out)) : path.join(os.homedir(), '.nyx5');
160
+ // UNA sola definición de dónde viven las llaves (src/correo/identidad.js). Tenerla dos veces
161
+ // hacía que `join` escribiera en ~/.nyx5 mientras el alta automática escribía en el
162
+ // directorio configurado: dos escritores, dos directorios, y una identidad que el comando
163
+ // siguiente no encontraba. Apareció haciendo la demo, no leyendo el código.
164
+ const dir = o.out ? path.dirname(path.resolve(o.out)) : directorioDeLlaves();
86
165
  const r = await join({
87
166
  house: casa, name: o.name || null, runtime: o.runtime || detectarRuntime(),
88
167
  invite: o.invite || null, listed: !!o.listed, capabilities,
@@ -94,7 +173,11 @@ try {
94
173
  const salida = path.resolve(o.out || path.join(dir, `${r.address.split('@')[0]}.json`));
95
174
  fs.mkdirSync(path.dirname(salida), { recursive: true });
96
175
  fs.writeFileSync(salida, JSON.stringify({ address: r.address, estafeta: r.estafeta, keys: r.keys }, null, 2), { mode: 0o600 });
97
- const mcpBloque = bloqueMcp({ address: r.address, keyfile: salida });
176
+ // El bloque va SIN ruta cuando la llave quedó en el directorio de siempre: así es el mismo
177
+ // para todos y el puente la deduce. Con `--out` a otro sitio, el puente no la encontraría,
178
+ // así que ahí la ruta sí va. La regla es «dilo sólo cuando haga falta», no «dilo siempre».
179
+ const enElDirectorio = path.dirname(salida) === directorioDeLlaves();
180
+ const mcpBloque = bloqueMcp({ address: r.address, keyfile: enElDirectorio ? null : salida });
98
181
  // Qué trabajo hay AHORA, traído en el mismo paso. Un agente recién unido no va a ir a
99
182
  // buscarlo: si no lo ve aquí, se va. Que esto falle no puede tumbar el alta.
100
183
  let trabajo = [];
@@ -288,9 +371,73 @@ try {
288
371
  if (o.ack && abiertos.length) console.log('ack:', await a.ack(abiertos));
289
372
  break;
290
373
  }
374
+ // ----- NX-413: adjuntos cifrados. Sube el archivo CIFRADO al almacén de la casa y devuelve el
375
+ // descriptor; la llave va en el descriptor y de ahí al bloque cifrado del sobre, nunca al sobre.
376
+ case 'attach': {
377
+ const a = await loadAgent();
378
+ const datos = fs.readFileSync(need('file'));
379
+ const att = await a.subirAdjunto(datos, { name: o.name || path.basename(need('file')), media: o.media, to: need('to').split(',').map((s) => s.trim()) });
380
+ if (o.send || o.body != null) {
381
+ const r = await a.send({ to: need('to').split(',').map((s) => s.trim()), body: o.body ?? `attached: ${att.name}`, type: o.type || 'message', attachments: [att], encrypt: !o.plain });
382
+ print({ id: r.id, encrypted: !!r.envelope.encrypted, attachment: { blob: att.blob, name: att.name, bytes: att.bytes, sha256: att.sha256 } });
383
+ break;
384
+ }
385
+ // Sin --send se imprime el descriptor para pasarlo a `send`: incluye la llave, así que esto
386
+ // va a un archivo o a la tubería, no a un chat.
387
+ print(att);
388
+ break;
389
+ }
390
+ // Baja los adjuntos de un sobre del buzón, los descifra y comprueba el hash del claro.
391
+ case 'attachment': {
392
+ const a = await loadAgent();
393
+ const sobre = await a.conAdjuntos(need('id'), o.from || null);
394
+ if (!sobre) { console.error(`no envelope with id ${o.id} in your mailbox (pass --from if you already acknowledged it)`); process.exit(1); }
395
+ const dir = o.out || '.';
396
+ fs.mkdirSync(dir, { recursive: true });
397
+ for (const att of sobre.attachments) {
398
+ const datos = await a.bajarAdjunto(att, { from: sobre.from });
399
+ const destino = path.join(dir, path.basename(String(att.name)));
400
+ fs.writeFileSync(destino, datos);
401
+ print({ name: att.name, bytes: datos.length, sha256: att.sha256, path: destino });
402
+ }
403
+ break;
404
+ }
291
405
  case 'ack': print({ acked: await (await loadAgent()).ack(need('id').split(',')) }); break;
292
406
  case 'outbox': print(await (await loadAgent()).outbox()); break;
293
- case 'mcp': await runMcpServer({ agentFile: need('agent'), hosts: loadHosts() }); break;
407
+ // El puente MCP es el camino por el que un agente usa Nyx5 DENTRO de su cliente, y es el que
408
+ // más tenía que ser cero fricción: con --agent obligatorio, cada agente necesitaba un bloque de
409
+ // configuración distinto y tenía que haber corrido join antes. Ahora deduce la identidad igual
410
+ // que el resto, y si no hay ninguna se une al arrancar: el bloque queda igual para todos.
411
+ case 'mcp': { const a = await loadAgent(); await runMcpServer({ agent: a, hosts: loadHosts() }); break; }
412
+
413
+ // El ejecutor: lo que corre dentro del job de CI que la casa disparó. La llave del rol viene de
414
+ // un secret (NYX5_KEY), no de un archivo del repositorio, y el aviso de la variable que el
415
+ // workflow pasa. `recoger` verifica la firma de la casa ANTES de leer el buzón.
416
+ case 'ejecutor': {
417
+ const accion = rest.find((x) => !x.startsWith('-'));
418
+ const aviso = process.env.NYX5_AVISO;
419
+ if (!aviso) { console.error('missing NYX5_AVISO (the client_payload of the dispatch)'); process.exit(2); }
420
+ const keys = process.env.NYX5_KEY ? JSON.parse(process.env.NYX5_KEY)
421
+ : (o.agent ? JSON.parse(fs.readFileSync(o.agent, 'utf8')) : null);
422
+ if (!keys) { console.error('missing NYX5_KEY (or --agent FILE) with the role key'); process.exit(2); }
423
+ const comun = { aviso, keys, estafeta: o.estafeta || null, hosts: loadHosts() };
424
+ if (accion === 'recoger') {
425
+ const r = await recoger(comun);
426
+ const salida = { envelope: r.envelope?.id || null, from: r.aviso.from, type: r.aviso.type, project: r.aviso.project || null, reason: r.reason, body: r.encargo?.body ?? null, media: r.encargo?.media ?? null };
427
+ if (o.out) fs.writeFileSync(o.out, JSON.stringify(salida, null, 2));
428
+ print(salida);
429
+ break;
430
+ }
431
+ if (accion === 'responder') {
432
+ const texto = o.file ? fs.readFileSync(o.file, 'utf8') : o.body;
433
+ if (!texto) { console.error('missing --file ARCHIVO (or --body) with the answer'); process.exit(2); }
434
+ print(await responder({ ...comun, texto, ack: !o['no-ack'] }));
435
+ break;
436
+ }
437
+ console.error('nyx5 ejecutor recoger | nyx5 ejecutor responder --file ARCHIVO');
438
+ process.exit(2);
439
+ break;
440
+ }
294
441
 
295
442
  // ----- Libro -----
296
443
  case 'topup': {
@@ -0,0 +1,64 @@
1
+ // Un agente de Nyx5 PAGA un recurso x402 con USDC real, firmando desde su propia llave.
2
+ //
3
+ // node demo/x402-pagar.mjs --llave <archivo> --tope <unidades-atómicas> [--red eip155:84532] <url>
4
+ //
5
+ // Esto MUEVE DINERO si la llave tiene fondos y la url cobra de verdad. No se corre sin
6
+ // autorización explícita. Lo que hace: GET a la url, lee el 402, elige una red que sabemos pagar,
7
+ // firma una autorización EIP-3009 con la llave y vuelve con PAYMENT-SIGNATURE. Quien difunde la
8
+ // transacción y paga el gas es el facilitador del que cobra; nosotros no tocamos ninguna cadena.
9
+ //
10
+ // La llave se lee de un archivo (hex de 32 bytes, con o sin 0x) y NUNCA se imprime: ni entera, ni
11
+ // en parte, ni en un error. Lo único que sale por pantalla es la red, el monto, el destinatario y
12
+ // el hash de transacción del PAYMENT-RESPONSE, que es lo que se anota después en el Libro.
13
+ //
14
+ // `--tope` es obligatorio y va en unidades atómicas de USDC (6 decimales: 10000 = US$0,01). Si el
15
+ // 402 pide más, no se firma nada. Es la única defensa del que paga contra un servidor que miente.
16
+ import fs from 'node:fs';
17
+ import { pagar } from '../src/puentes/x402-pagador.js';
18
+ import { TOKEN_USD } from '../src/puentes/x402.js';
19
+
20
+ const args = process.argv.slice(2);
21
+ const opcion = (nombre) => { const i = args.indexOf(nombre); return i >= 0 ? args[i + 1] : undefined; };
22
+ const archivoLlave = opcion('--llave');
23
+ const tope = opcion('--tope');
24
+ const red = opcion('--red');
25
+ const url = args.filter((a, i) => !a.startsWith('--') && !['--llave', '--tope', '--red'].includes(args[i - 1])).at(-1);
26
+
27
+ const salir = (msg) => { console.error(msg); process.exit(1); };
28
+ if (!archivoLlave || !tope || !url) salir('uso: node demo/x402-pagar.mjs --llave <archivo> --tope <unidades-atómicas> [--red eip155:84532] <url>');
29
+ if (!/^\d+$/.test(tope)) salir('--tope va en unidades atómicas enteras (10000 = US$0,01 en USDC)');
30
+ if (red && !TOKEN_USD[red]) salir(`--red ${red} no es una red que sepamos pagar; conozco: ${Object.keys(TOKEN_USD).join(', ')}`);
31
+ if (!/^https:\/\//.test(url) && !/^http:\/\/(127\.0\.0\.1|localhost)/.test(url)) salir('la url tiene que ser https (o local para pruebas)');
32
+
33
+ let privKey;
34
+ try { privKey = fs.readFileSync(archivoLlave, 'utf8').trim(); } catch { salir(`no se pudo leer la llave de ${archivoLlave}`); }
35
+ if (!/^(0x)?[0-9a-fA-F]{64}$/.test(privKey)) salir('el archivo de la llave no contiene 32 bytes en hex');
36
+
37
+ const usd = (unidades) => `US$${(Number(unidades) / 1e6).toFixed(6).replace(/0+$/, '').replace(/\.$/, '')}`;
38
+ console.log(`pagando ${url}`);
39
+ console.log(`tope: ${tope} unidades (${usd(tope)})${red ? ` · red: ${red}` : ''}`);
40
+
41
+ let r;
42
+ try {
43
+ r = await pagar({ url, privKey, tope, redes: red ? [red] : undefined });
44
+ } catch (e) {
45
+ // El mensaje de error del pagador nunca contiene la llave: sólo direcciones, montos y redes.
46
+ // Si la firma ya había salido, es un cheque vigente hasta `validBefore` aunque el servidor haya
47
+ // dicho que no: se imprime para poder conciliarlo contra la cadena.
48
+ if (e.firmado) console.error(`ATENCIÓN: la autorización ya se entregó (red ${e.red}, ${e.monto} unidades a ${e.destinatario}, nonce ${e.nonce}, vence ${e.validBefore}); el servidor puede liquidarla igual`);
49
+ salir(`no se pagó: ${e.message}`);
50
+ }
51
+ privKey = null;
52
+
53
+ if (!r.pagado) {
54
+ console.log(`el recurso no cobró (HTTP ${r.status}); no se firmó nada`);
55
+ process.exit(0);
56
+ }
57
+ const explorador = { 'eip155:1': 'https://etherscan.io', 'eip155:8453': 'https://basescan.org', 'eip155:84532': 'https://sepolia.basescan.org', 'eip155:137': 'https://polygonscan.com', 'eip155:42161': 'https://arbiscan.io', 'eip155:10': 'https://optimistic.etherscan.io', 'eip155:43114': 'https://snowtrace.io' }[r.red];
58
+ console.log(`red: ${r.red} (${TOKEN_USD[r.red].red})`);
59
+ console.log(`monto: ${r.monto} unidades (${usd(r.monto)})`);
60
+ console.log(`destinatario: ${r.destinatario}`);
61
+ console.log(`pagador: ${r.pagador}`);
62
+ console.log(`transacción: ${r.liquidacion?.transaction || '(el servidor no devolvió PAYMENT-RESPONSE)'}`);
63
+ if (r.liquidacion?.transaction && explorador) console.log(`compruébalo: ${explorador}/tx/${r.liquidacion.transaction}`);
64
+ console.log(`HTTP ${r.status}`);