@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
@@ -0,0 +1,154 @@
1
+ // Nyx5/1 — ideas@: el buzón automático de vacaciones. RECIBE, GUARDA y CONFIRMA. NUNCA EJECUTA.
2
+ //
3
+ // Nació del pedido de Nicholas del 14-sep-2026 (se va un mes): un lugar donde dejar una idea desde
4
+ // el teléfono y saber que quedó anotada, sin que nadie la ejecute ni la interprete. Corre desde el
5
+ // reloj programado de la casa, sin el Mac y sin ningún modelo de lenguaje.
6
+ //
7
+ // Límites, y son DE CÓDIGO, no de configuración:
8
+ // - Este módulo no importa nada que hable con la API de Anthropic ni con el Libro, y no tiene
9
+ // `fetch`. Lo único que sabe mandar es la confirmación a quien escribió (`agente.send` una vez,
10
+ // al `from` del sobre recibido). `test/ideas.test.js` lo comprueba por inspección de la fuente
11
+ // (lista CERRADA de lo que puede tocar de la estafeta, del almacén y del agente) y espiando las
12
+ // salidas de la casa durante el tick.
13
+ // - Sólo registra sobres FIRMADOS de quien está en la lista del buzón. La puerta (`puertaIdeas`,
14
+ // que `inbound` consulta para este buzón) rechaza al resto ANTES de guardar nada: ni el `intro`
15
+ // corto ni el aval con fianza que la política general deja pasar (§9) sirven aquí, porque este
16
+ // buzón no tiene a nadie que los lea. El tick vuelve a mirar la lista por si algo entró por
17
+ // otra puerta (un correo, un sobre anterior al alta): se cierra sin registrar y sin contestar.
18
+ // - Cupo diario por remitente en la puerta (revisión adversarial del 14-sep-2026: una dirección
19
+ // de la lista podía meter 112 MB por minuto en el buzón, que no se borra nunca): 200 ideas y
20
+ // 5 MB por día UTC; lo que sobra rebota con motivo desde la puerta, no desde este módulo.
21
+ // - El contenido no se guarda aparte: sigue cifrado en el buzón de ideas@. El registro lleva
22
+ // sólo el número, quién, cuándo, el hash del sobre, el hilo y el proyecto.
23
+ // - El texto de la confirmación es FIJO (aprobado con el pedido: «registra y confirma, nunca
24
+ // ejecuta») y vive en un solo lugar: `CONFIRMACION`.
25
+ import { parseAddress } from './resolver.js';
26
+ import { sha256hex, canonical } from '../nucleo/crypto.js';
27
+ import { proyectoDe, rolDe } from './politica.js';
28
+
29
+ export const LOCAL = 'ideas';
30
+ export const NS = 'ideas'; // nyx5_kv: `_n` (contador), `n:<000001>` (registro), `sobre:<id>` (idempotencia)
31
+ export const NS_CUPO = 'ideas-cupo'; // nyx5_kv: `<from>:<día>:n` y `<from>:<día>:bytes`, vencen al día siguiente
32
+ export const VIA = 'ideas'; // custody.via de la tarjeta: la casa firma y descifra en su nombre
33
+ export const CUPO_DIARIO = { ideas: 200, bytes: 5 * 1024 * 1024 };
34
+ export const PAGINA = 1000; // GET /ideas devuelve hasta esto por página, con `next`
35
+ const SISTEMA = new Set(['postmaster', 'libro', 'verifica', 'tareas']);
36
+ const TURNO_MS = 15 * 60_000;
37
+ const DIA_MS = 24 * 3600 * 1000;
38
+
39
+ export const idDeIdea = (n) => `IDEA-${String(n).padStart(3, '0')}`;
40
+ const claveDe = (n) => `n:${String(n).padStart(6, '0')}`;
41
+ // «2026-09-15 10:22» a partir de una fecha ISO (siempre UTC).
42
+ export const fechaCorta = (isoStr) => String(isoStr).slice(0, 16).replace('T', ' ');
43
+ export const CONFIRMACION = (id, at) => `Saved as ${id} on ${fechaCorta(at)} UTC. Nothing was executed: this mailbox only records and confirms. Nicholas reads it when he is back.`;
44
+
45
+ const minus = (x) => String(x || '').toLowerCase();
46
+ function enLista(lista, from) {
47
+ const f = minus(from);
48
+ let dominio = ''; try { dominio = parseAddress(f).domain; } catch { dominio = ''; }
49
+ return lista.some((x) => x === f || (dominio && x === dominio));
50
+ }
51
+
52
+ // La puerta de ideas@, consultada por `inbound` DESPUÉS de la política general y ANTES de guardar.
53
+ // Devuelve null si el sobre puede entrar, o `{ code, reason }` para rechazarlo (el rebote lo hace la
54
+ // estafeta emisora, como con cualquier rechazo de la puerta: no es una salida de este módulo).
55
+ // Rechaza (403, permanente) a quien no está en la lista y a quien agotó su cupo del día: un
56
+ // rechazo transitorio (429) dejaría los sobres reintentando en la cola de la otra casa un día entero.
57
+ // Lo que NO cubre: el cupo se cuenta al aceptar en la puerta, así que dos casas emisoras que
58
+ // entreguen a la vez pueden pasarse por uno; y sólo protege el buzón de ideas@, no el resto de la casa.
59
+ export async function puertaIdeas(est, env, rec, { cupo = CUPO_DIARIO, nowMs = Date.now() } = {}) {
60
+ const lista = (rec?.inbox?.allowlist || []).map(minus);
61
+ if (!enLista(lista, env.from)) return { code: 403, reason: 'the ideas mailbox records only signed envelopes from its list: not recorded' };
62
+ if (!est.store.kvIncrement) return null;
63
+ const dia = new Date(nowMs).toISOString().slice(0, 10);
64
+ const vence = (Math.floor(nowMs / DIA_MS) + 2) * DIA_MS;
65
+ const clave = `${minus(env.from)}:${dia}`;
66
+ const bytes = Buffer.byteLength(JSON.stringify(env));
67
+ // Se suma ANTES de comparar: con dos relojes contando a la vez, el que se pasa cierra el día.
68
+ const n = await est.store.kvIncrement(NS_CUPO, `${clave}:n`, vence, nowMs);
69
+ const total = await est.store.kvIncrement(NS_CUPO, `${clave}:bytes`, vence, nowMs, bytes);
70
+ if (n > cupo.ideas || total > cupo.bytes) return { code: 403, reason: `daily limit of the ideas mailbox reached for this sender (${cupo.ideas} ideas or ${Math.round(cupo.bytes / 1024 / 1024)} MB per UTC day): not recorded` };
71
+ return null;
72
+ }
73
+
74
+ // El registro ordenado por número, para GET /ideas: hasta `limit` registros con número > `after`.
75
+ export async function listarIdeas(est, { after = 0, limit = PAGINA } = {}) {
76
+ const filas = await est.store.kvList(NS, { prefix: 'n:', after: after > 0 ? claveDe(after) : undefined, limit });
77
+ return filas.map((f) => f.doc).sort((a, b) => a.n - b.n);
78
+ }
79
+ // Lo que contesta GET /ideas: `total` es el último número asignado (puede superar a los registros
80
+ // si un reloj cayó entre asignar el número y escribir el registro), `next` es el número desde el
81
+ // que sigue la página siguiente, o null si ésta es la última.
82
+ export async function paginaDeIdeas(est, { after = 0 } = {}) {
83
+ const ideas = await listarIdeas(est, { after, limit: PAGINA });
84
+ const total = Number(await est.store.kvGet(NS, '_n')) || 0;
85
+ return { total, count: ideas.length, ideas, next: ideas.length === PAGINA ? ideas[ideas.length - 1].n : null };
86
+ }
87
+
88
+ // Un tick: cada sobre pendiente del buzón se registra (número correlativo durable) y se confirma.
89
+ // Devuelve cuántas ideas registró. Dos relojes sobre el mismo almacén no repiten número ni
90
+ // confirmación: un turno por sobre (`kvPutIfAbsent`) y un número por sobre (`sobre:<id>`).
91
+ // `maxPorTick` acota las ideas registradas por pasada; `maxDescartesPorTick`, lo que se cierra sin
92
+ // registrar (correo sin firma, rebotes del postmaster, algo anterior al alta): un buzón lleno de
93
+ // eso no puede comerse el minuto del reloj, y tampoco puede dejar sin turno a las ideas de verdad
94
+ // (son cupos separados).
95
+ export async function atenderIdeas(est, { maxPorTick = 20, maxDescartesPorTick = 200 } = {}) {
96
+ if (!est.ideas?.enabled || !est.store.kvIncrement) return 0;
97
+ const rec = await est.store.getAgent(LOCAL);
98
+ if (!rec || rec.revoked || rec.custody?.via !== VIA) return 0;
99
+ const lista = (rec.inbox?.allowlist || []).map(minus);
100
+ const propia = `${LOCAL}@${est.domain}`;
101
+ let registradas = 0;
102
+ let vistos = 0, descartes = 0;
103
+ for (const m of await est.store.listMail(LOCAL)) {
104
+ if (vistos >= maxPorTick && descartes >= maxDescartesPorTick) break;
105
+ const e = m.envelope || {};
106
+ let de; try { de = parseAddress(e.from); } catch { continue; }
107
+ // Lo que manda la casa (un rebote, un recibo) o el propio buzón no es una idea: se cierra y sigue.
108
+ const deSistema = (de.domain === est.domain && SISTEMA.has(de.local)) || e.from === propia || e.type === 'receipt';
109
+ // Sin firma (un correo) o fuera de la lista (algo que entró antes del alta o por otra puerta): se
110
+ // cierra sin registrar y sin contestar. Contestarle a un desconocido sería la salida que este
111
+ // buzón no tiene.
112
+ const ajeno = !e.signature || m.from_verified === false || !enLista(lista, e.from);
113
+ if (deSistema || ajeno) {
114
+ if (descartes >= maxDescartesPorTick) continue;
115
+ descartes++;
116
+ if (!deSistema) est.log(`ideas@: sobre ${e.id} de ${e.from} descartado (${!e.signature || m.from_verified === false ? 'sin firma' : 'fuera de la lista'})`);
117
+ await est.store.ackMail(LOCAL, e.id);
118
+ continue;
119
+ }
120
+ if (vistos >= maxPorTick) continue;
121
+ vistos++;
122
+ if (!(await est.store.kvPutIfAbsent('ideas-turno', e.id, { at: new Date().toISOString() }, Date.now() + TURNO_MS))) continue;
123
+ try {
124
+ const agente = await est.agenteDeBoveda(LOCAL);
125
+ if (!agente) throw new Error('la bóveda no tiene la llave de ideas@');
126
+ // Un número por sobre: si un tick anterior asignó número y no llegó a confirmar, se reutiliza.
127
+ let n = Number((await est.store.kvGet(NS, `sobre:${e.id}`))?.n) || 0;
128
+ if (!n) {
129
+ n = await est.store.kvIncrement(NS, '_n');
130
+ await est.store.kvPut(NS, `sobre:${e.id}`, { n });
131
+ }
132
+ // El registro se escribe si no está, aunque el número ya estuviera asignado: un reloj que cayó
133
+ // entre asignar el número y escribir el registro no puede dejar una idea confirmada y sin
134
+ // registro (revisión adversarial del 14-sep-2026). No depende del contenido: si el sobre no se
135
+ // puede abrir, se registra igual por su hash.
136
+ let registro = await est.store.kvGet(NS, claveDe(n));
137
+ if (!registro) {
138
+ let abierto = false;
139
+ try { await agente.open(e); abierto = true; } catch { abierto = false; }
140
+ registro = { n, id: idDeIdea(n), id_sobre: e.id, from: e.from, at: new Date().toISOString(), sha256_sobre: sha256hex(canonical(e)), thread: e.thread || e.id, project: proyectoDe(e), role: rolDe(e), opened: abierto };
141
+ await est.store.kvPut(NS, claveDe(n), registro);
142
+ }
143
+ // La ÚNICA salida de este módulo: la confirmación a quien escribió, en su hilo y su proyecto.
144
+ await agente.send({ to: e.from, body: CONFIRMACION(registro.id, registro.at), thread: e.thread || e.id, inReplyTo: e.id, project: registro.project || undefined, role: registro.role || undefined });
145
+ await est.store.ackMail(LOCAL, e.id);
146
+ registradas++;
147
+ await est._evento('idea_saved', e.from, { idea: registro.id, sobre: e.id });
148
+ } catch (err) {
149
+ // El turno queda tomado 15 minutos y se reintenta; el número, si ya salió, se conserva.
150
+ est.log(`ideas@: ${e.id} no se pudo confirmar: ${err.message}`);
151
+ }
152
+ }
153
+ return registradas;
154
+ }
@@ -0,0 +1,81 @@
1
+ // Nyx5/1 — qué identidad usa un comando cuando nadie la nombró.
2
+ //
3
+ // La premisa de Nyx5 es cero fricción: un agente decide usarlo y lo usa, sin pasos previos. Hasta
4
+ // el 4-nov-2026 los 16 comandos del cliente exigían `--agent /ruta/larga/a/la/llave.json`, así que
5
+ // el primer contacto eran DOS comandos y una ruta que el agente tenía que acarrear entre turnos.
6
+ // Una ruta que hay que recordar es estado, y el estado entre turnos es justo donde un modelo
7
+ // falla. Aquí no hay estado: la identidad se deduce.
8
+ //
9
+ // El orden, de lo más explícito a lo más implícito:
10
+ // 1. `--agent <ruta>` quien lo dice, manda. No cambia nada de lo que ya funcionaba.
11
+ // 2. `NYX5_AGENT` para CI y contenedores, donde no hay home del usuario.
12
+ // 3. una sola llave en el directorio el caso normal: tienes una identidad, es ésa.
13
+ // 4. el puntero `default` cuando tienes varias y elegiste una con `nyx5 use`.
14
+ // 5. ninguna quien llama decide: la CLI se une sola y sigue.
15
+ //
16
+ // Y lo PROGRESIVO: nada de esto se le pide al que llega. La ruta aparece cuando tienes dos
17
+ // identidades, no antes; la casa, cuando quieres otra; la llave explícita, cuando automatizas.
18
+ // Tener dos nunca se adivina: dos identidades es ambigüedad, y adivinar ahí firma con la que no
19
+ // era. Se devuelve `varias` y el que llama lo dice con los nombres.
20
+
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import os from 'node:os';
24
+
25
+ // `NYX5_HOME` existe para las pruebas y para un contenedor sin home: un test que escribiera en
26
+ // el ~/.nyx5 de quien lo corre le pisaría sus llaves de verdad.
27
+ export const directorioDeLlaves = (env = process.env) => env.NYX5_HOME || path.join(os.homedir(), '.nyx5');
28
+
29
+ // El puntero a la identidad elegida cuando hay varias. Es un archivo de texto con el nombre del
30
+ // archivo de llave, no un enlace: un enlace roto es invisible y esto se lee de un vistazo.
31
+ export const PUNTERO = 'default';
32
+
33
+ // Una llave es un archivo que TIENE dirección, no cualquier `.json`. Nació de un borde que
34
+ // encontró su propia prueba: un `hosts.json` en el mismo directorio se contaba como identidad, y
35
+ // con eso una máquina con una sola llave parecía tener dos y quedaba preguntando.
36
+ export function listarLlaves(dir) {
37
+ let entradas;
38
+ try { entradas = fs.readdirSync(dir); } catch { return []; }
39
+ return entradas.filter((f) => f.endsWith('.json') && direccionDe(path.join(dir, f))).sort();
40
+ }
41
+
42
+ export function leerPuntero(dir) {
43
+ try { return fs.readFileSync(path.join(dir, PUNTERO), 'utf8').trim() || null; } catch { return null; }
44
+ }
45
+
46
+ export function escribirPuntero(dir, archivo) {
47
+ fs.mkdirSync(dir, { recursive: true });
48
+ fs.writeFileSync(path.join(dir, PUNTERO), `${archivo}\n`, { mode: 0o600 });
49
+ }
50
+
51
+ // La dirección que hay dentro de un archivo de llave, para poder elegir por dirección y no por
52
+ // nombre de archivo. Un archivo ilegible no tumba la elección: devuelve null y se ignora.
53
+ export function direccionDe(archivo) {
54
+ try { return JSON.parse(fs.readFileSync(archivo, 'utf8')).address || null; } catch { return null; }
55
+ }
56
+
57
+ // -> { ruta } | { ninguna: true, dir } | { varias: [{ archivo, address }], dir }
58
+ export function elegirLlave({ flag = null, env = process.env, dir = null } = {}) {
59
+ if (flag) return { ruta: flag };
60
+ if (env.NYX5_AGENT) return { ruta: env.NYX5_AGENT };
61
+ const d = dir || directorioDeLlaves(env);
62
+ const llaves = listarLlaves(d);
63
+ if (llaves.length === 0) return { ninguna: true, dir: d };
64
+ if (llaves.length === 1) return { ruta: path.join(d, llaves[0]) };
65
+ const elegido = leerPuntero(d);
66
+ if (elegido && llaves.includes(elegido)) return { ruta: path.join(d, elegido) };
67
+ return { varias: llaves.map((archivo) => ({ archivo, address: direccionDe(path.join(d, archivo)) })), dir: d };
68
+ }
69
+
70
+ // El archivo que corresponde a una dirección (o a un nombre de archivo), para `nyx5 use`.
71
+ export function archivoDe(dir, quien) {
72
+ const llaves = listarLlaves(dir);
73
+ const q = String(quien || '').toLowerCase();
74
+ if (llaves.includes(q)) return q;
75
+ if (llaves.includes(`${q}.json`)) return `${q}.json`;
76
+ for (const f of llaves) {
77
+ const a = direccionDe(path.join(dir, f));
78
+ if (a && (a.toLowerCase() === q || a.toLowerCase().split('@')[0] === q)) return f;
79
+ }
80
+ return null;
81
+ }
@@ -21,9 +21,33 @@ export const EXT_PROYECTO = 'urn:nyx5:ext:proyecto';
21
21
  // llegaban tal cual a la herramienta del Claude remoto. Se limpian y acotan como la ficha, y el
22
22
  // proyecto se normaliza (NFKC, sin invisibles) para que un homógrafo no esconda un mensaje del filtro.
23
23
  export const proyectoDe = (env) => { const p = env?.extensions?.[EXT_PROYECTO]?.project; return typeof p === 'string' ? nombreDeProyecto(p) : null; };
24
- export const nombreDeProyecto = (p) => limpio(String(p).normalize('NFKC'), 40).toLowerCase() || null;
24
+ // Devuelve null cuando lo que queda no es un nombre: vacío tras limpiar, o un nombre que es
25
+ // propiedad propia de Object.prototype (`__proto__`, `constructor`). Revisión del 14-sep-2026:
26
+ // `pending_by_project` viaja como objeto JSON y un lector que haga `mapa[p]` con ese nombre ausente
27
+ // recibe una función en vez de un número (o, al armar el objeto, pisa el prototipo). Es UNA función
28
+ // para las dos puertas: el cliente rechaza al enviar y la casa lee «sin proyecto» si llega de fuera.
29
+ export const nombreDeProyecto = (p) => { const n = limpio(String(p).normalize('NFKC'), 40).toLowerCase(); return n && !Object.hasOwn(Object.prototype, n) ? n : null; };
25
30
  export const rolDe = (env) => { const r = env?.extensions?.[EXT_PROYECTO]?.role; return typeof r === 'string' ? (limpio(r, 40) || null) : null; };
26
31
 
32
+ // El RESUMEN EN CLARO (29-oct-2026, decisión de Nicholas). El aviso por correo sólo podía decir
33
+ // QUIÉN le escribió, nunca QUÉ: el contenido va cifrado y la casa no lo lee (invariante 8). Un
34
+ // timbre que no dice quién está en la puerta es ruido, y él tiene que abrir la app para saber si
35
+ // algo le toca. Ahora el REMITENTE puede adjuntar una línea que viaja SIN CIFRAR, y la casa la
36
+ // pone en el asunto del aviso.
37
+ //
38
+ // Quién decide qué se expone: el que escribe, no la casa. Por eso la línea es opcional y por eso
39
+ // la descripción de la herramienta dice, con todas sus letras, que la leen la casa y el correo.
40
+ // Nunca va ahí lo que el sobre protege: va de qué se trata, no lo que dice.
41
+ //
42
+ // Se limpia como el proyecto y se acota a UNA línea: `limpio` cambia los controles por espacios y
43
+ // colapsa, así que un salto de línea no puede partir el asunto de un correo en dos.
44
+ export const EXT_RESUMEN = 'urn:nyx5:ext:resumen';
45
+ export const RESUMEN_MAX = 140;
46
+ export const resumenDe = (env) => {
47
+ const r = env?.extensions?.[EXT_RESUMEN]?.text;
48
+ return typeof r === 'string' ? (limpio(r, RESUMEN_MAX) || null) : null;
49
+ };
50
+
27
51
  // Ficha pública del agente (13-sep-2026): qué hace, en qué idiomas, de quién es, con qué etiquetas.
28
52
  // Va DENTRO de la tarjeta certificada, la firma el dueño al declararla y la casa al certificarla.
29
53
  // Vocabulario CERRADO, como el alcance de un mandato: una clave desconocida se rechaza nombrándola,
@@ -137,6 +161,59 @@ export function usdSinBilletera(perfil, wallets) {
137
161
  return `service "${con[0].id}" declares a usd price but the card has no wallet; declare wallets first`;
138
162
  }
139
163
 
164
+ // ---------- adjuntos (NX-413) ----------
165
+ // El archivo NO viaja en el sobre: viaja cifrado al almacén de objetos de la casa del remitente y
166
+ // el sobre lleva la referencia. Esta validación es la que decide qué puede verse desde afuera.
167
+ //
168
+ // En un sobre CIFRADO el array de afuera lleva SÓLO `blob` y `bytes`, y el vocabulario es CERRADO:
169
+ // una clave de más se RECHAZA con nombre, no se ignora. Contradice el invariante 7 a propósito, por
170
+ // la misma razón que `ALCANCE_MANDATO` (9-sep-2026): ese invariante es para extender mensajes sin
171
+ // romper nada, y aquí ignorar un campo de más NO es neutro — `name: "balance-despido-javier.pdf"`
172
+ // afuera del cifrado le cuenta a la casa y a cualquier estafeta del camino casi todo el contenido.
173
+ // Si alguien "arregla" esta inconsistencia para que sea permisiva, reabre la fuga.
174
+ //
175
+ // En un sobre EN CLARO el array lleva la ficha completa, `key` incluida: el adjunto sigue cifrado
176
+ // en el almacén, pero la llave viaja a la vista y la casa SÍ puede abrirlo. Se permite (hay usos
177
+ // legítimos: un adjunto público) y la herramienta lo advierte antes de que un agente lo elija.
178
+ export const ADJUNTO_CIFRADO = ['blob', 'bytes'];
179
+ export const ADJUNTO_CLARO = ['blob', 'bytes', 'name', 'media', 'sha256', 'key'];
180
+ export const ADJUNTOS = { max: 10, bytesPorSobre: 25 * 1024 * 1024, bytesPorAdjunto: 10 * 1024 * 1024, nombreMax: 120, mediaMax: 100 };
181
+ const UUID_BLOB = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
182
+ const MEDIA_TIPO = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/i;
183
+ const ENTERO = (v, max) => Number.isSafeInteger(v) && v > 0 && v <= max;
184
+
185
+ // Devuelve el motivo del rechazo (string) o null si los adjuntos están bien.
186
+ export function validarAdjuntos(attachments, { cifrado = true } = {}) {
187
+ if (!Array.isArray(attachments)) return 'attachments must be a list';
188
+ if (attachments.length > ADJUNTOS.max) return `an envelope carries at most ${ADJUNTOS.max} attachments, not ${attachments.length}`;
189
+ const permitidas = cifrado ? ADJUNTO_CIFRADO : ADJUNTO_CLARO;
190
+ const vistos = new Set();
191
+ let total = 0;
192
+ for (const [i, a] of attachments.entries()) {
193
+ const donde = `attachments[${i}]`;
194
+ if (!a || typeof a !== 'object' || Array.isArray(a)) return `${donde} must be an object`;
195
+ for (const k of Object.keys(a)) {
196
+ if (!permitidas.includes(k)) {
197
+ return cifrado
198
+ ? `${donde}.${k} must not travel outside the encrypted block (an attachment of an encrypted envelope carries only ${ADJUNTO_CIFRADO.join(' and ')}; name, media, sha256 and key go inside)`
199
+ : `${donde}.${k} is not an attachment field (${ADJUNTO_CLARO.join(', ')})`;
200
+ }
201
+ }
202
+ if (!UUID_BLOB.test(String(a.blob || ''))) return `${donde}.blob must be the uuid of an uploaded object`;
203
+ if (vistos.has(String(a.blob).toLowerCase())) return `${donde}.blob repeats an attachment of this same envelope`;
204
+ vistos.add(String(a.blob).toLowerCase());
205
+ if (!ENTERO(a.bytes, ADJUNTOS.bytesPorAdjunto)) return `${donde}.bytes must be an integer between 1 and ${ADJUNTOS.bytesPorAdjunto} (${Math.round(ADJUNTOS.bytesPorAdjunto / 1024 / 1024)} MB per attachment)`;
206
+ total += a.bytes;
207
+ if (cifrado) continue;
208
+ if (typeof a.name !== 'string' || !limpio(a.name, ADJUNTOS.nombreMax)) return `${donde}.name must be a non-empty string of at most ${ADJUNTOS.nombreMax} characters`;
209
+ if (typeof a.media !== 'string' || a.media.length > ADJUNTOS.mediaMax || !MEDIA_TIPO.test(a.media)) return `${donde}.media must be a media type like application/pdf`;
210
+ if (typeof a.sha256 !== 'string' || !/^[0-9a-f]{64}$/i.test(a.sha256)) return `${donde}.sha256 must be the hex sha256 of the plaintext (64 characters)`;
211
+ if (a.key != null && (typeof a.key !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(a.key))) return `${donde}.key must be the base64url of a 32-byte key`;
212
+ }
213
+ if (total > ADJUNTOS.bytesPorSobre) return `the attachments of one envelope add up to ${total} bytes and the maximum is ${ADJUNTOS.bytesPorSobre} (${Math.round(ADJUNTOS.bytesPorSobre / 1024 / 1024)} MB)`;
214
+ return null;
215
+ }
216
+
140
217
  export function validateEnvelope(env, { maxBytes = 1_048_576 } = {}) {
141
218
  const fail = (reason) => ({ ok: false, code: 400, reason });
142
219
  if (!env || typeof env !== 'object') return fail('sobre no es un objeto');
@@ -160,8 +237,13 @@ export function validateEnvelope(env, { maxBytes = 1_048_576 } = {}) {
160
237
  if (!hasPlain && !hasEnc) return fail('el sobre necesita content o encrypted');
161
238
  if (hasPlain && hasEnc) return fail('content y encrypted son excluyentes');
162
239
  if (env.attachments != null) {
163
- if (!Array.isArray(env.attachments)) return fail('attachments debe ser lista');
164
- for (const a of env.attachments) if (!a?.name || !a?.media || !a?.sha256 || !a?.url) return fail('adjunto incompleto (name, media, sha256, url)');
240
+ // `!!hasEnc` y no `hasEnc`: para un sobre en claro `hasEnc` vale undefined, no false, y un
241
+ // undefined ACTIVA el valor por defecto del parámetro (`cifrado = true`), así que los adjuntos
242
+ // de un sobre EN CLARO se validaban con las reglas del cifrado y se rechazaban todos. Se vio
243
+ // al probar el caso en claro con un adjunto real. El defecto caía del lado estricto, que es el
244
+ // que corresponde si hay que equivocarse, pero era el branch equivocado igual.
245
+ const e = validarAdjuntos(env.attachments, { cifrado: !!hasEnc });
246
+ if (e) return fail(e);
165
247
  }
166
248
  if (!env.signature || env.signature.alg !== 'Ed25519' || !env.signature.kid || !env.signature.value) return fail('sobre sin firma');
167
249
  const bytes = Buffer.byteLength(JSON.stringify(env));
@@ -256,6 +338,13 @@ export function applyInboxPolicy(env, agentRecord, senderDomain) {
256
338
  const esLibro = String(agentRecord.address || '').startsWith('libro@');
257
339
  if (!esLibro && senderDomain?.policy?.outbound === 'sealed' && !env.encrypted) return permanent('el dominio emisor exige cifrado y el sobre viene en claro');
258
340
 
341
+ // Un sobre de una dirección a SÍ MISMA pasa su propia política (26-oct-2026). Es lo que sostiene
342
+ // `recordar`, la memoria entre sesiones: un sobre diferido al propio buzón. Antes rebotaba contra
343
+ // su propia lista blanca — `nyx5_remind` quedaba decorativo en todo buzón por lista — y en un buzón
344
+ // con estampilla se cobraba a sí mismo. Una lista, una prueba de trabajo o una estampilla defienden
345
+ // de OTROS; no abren nada, porque sólo quien tiene esa llave puede firmar como esa dirección.
346
+ if (env.from === agentRecord.address) return { ok: true };
347
+
259
348
  switch (inbox.policy) {
260
349
  case 'open':
261
350
  break;
@@ -8,6 +8,8 @@
8
8
  // No es una cuenta: no hay correo, ni contraseña, ni nada del humano. La única fricción
9
9
  // deliberada del sistema aparece después, en `mandate`, cuando alguien pone presupuesto.
10
10
 
11
+ import path from 'node:path';
12
+ import { directorioDeLlaves } from './identidad.js';
11
13
  import { Agent } from './agente.js';
12
14
  import { Resolver, parseAddress } from './resolver.js';
13
15
  import { Estafeta } from './estafeta.js';
@@ -27,12 +29,19 @@ export function nombreSugerido(raiz = 'agente') {
27
29
  return `${base}-${sufijo()}`;
28
30
  }
29
31
 
30
- export function bloqueMcp({ address, keyfile, comando = 'npx', args = ['-y', '@nyx5/nyx5'] }) {
32
+ // El bloque que un agente pega en su cliente. Desde el 4-nov-2026 NO lleva la ruta de la llave:
33
+ // el puente la deduce igual que el resto de los comandos (src/correo/identidad.js), y si no hay
34
+ // ninguna se une solo al arrancar. Eso lo vuelve CONSTANTE — las mismas tres líneas para todos y
35
+ // para siempre —, y por lo tanto algo que se puede publicar y que un agente puede escribirse a sí
36
+ // mismo sin haber corrido nada antes. Con la ruta dentro, cada agente necesitaba un bloque
37
+ // distinto, que es exactamente la fricción que este proyecto dice no tener.
38
+ // `keyfile` se sigue aceptando para un caso real: varias identidades en la misma máquina.
39
+ export function bloqueMcp({ address, keyfile = null, comando = 'npx', args = ['-y', '@nyx5/nyx5'] } = {}) {
31
40
  return {
32
41
  mcpServers: {
33
- nyx5: { command: comando, args: [...args, 'mcp', '--agent', keyfile], env: {} },
42
+ nyx5: { command: comando, args: [...args, 'mcp', ...(keyfile ? ['--agent', keyfile] : [])], env: {} },
34
43
  },
35
- _agent: address,
44
+ ...(address ? { _agent: address } : {}),
36
45
  };
37
46
  }
38
47
 
@@ -104,7 +113,8 @@ async function _despues(agente, card, { keyfile, house }) {
104
113
  address: agente.address, house, estafeta: agente.estafeta,
105
114
  keys: agente.keys, keyfile: archivo, card,
106
115
  balance, historial,
107
- mcp: bloqueMcp({ address: agente.address, keyfile: archivo }),
116
+ // Sin ruta si la llave quedó donde el puente la busca sola (ver bloqueMcp y identidad.js).
117
+ mcp: bloqueMcp({ address: agente.address, keyfile: archivo && path.dirname(archivo) === directorioDeLlaves() ? null : archivo }),
108
118
  first,
109
119
  _agente: agente,
110
120
  };
@@ -36,6 +36,8 @@ export const MEDIA = {
36
36
  op: 'application/nyx5.libro+json',
37
37
  cotizacion: 'application/nyx5.cotizacion+json',
38
38
  recibo: 'application/nyx5.recibo+json',
39
+ // NX-305: un comprador PIDE un servicio del catálogo; el vendedor contesta con la cotización.
40
+ pedido: 'application/nyx5.pedido+json',
39
41
  };
40
42
 
41
43
  const iso = () => new Date().toISOString();
@@ -121,8 +123,10 @@ export class Libro {
121
123
  // bond released | forfeited afirmación sostenida | derribada
122
124
  static TERMINALES = { spot: ['settled'], escrow: ['released', 'refunded'], bond: ['released', 'forfeited'], metered: [] };
123
125
 
124
- async historial(address) {
125
- const todos = await this.store.libroListContracts();
126
+ // `contratos`: la lista ya leída, para que un lote de historiales (la ruta en lote, el rastreo
127
+ // del índice) no vuelva a leer la tabla por cada dirección.
128
+ async historial(address, { contratos = null } = {}) {
129
+ const todos = contratos ?? await this.store.libroListContracts();
126
130
  const mios = todos.filter((c) => [c.seller, c.buyer].includes(address));
127
131
  const cuenta = () => ({ n: 0, tokens: 0 });
128
132
  const h = {