@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.
- package/README.md +33 -5
- package/agents.md +24 -7
- package/bin/nyx5.js +153 -6
- package/demo/x402-pagar.mjs +64 -0
- package/docs/ARQUITECTURA.md +352 -0
- package/docs/HOUSE.es.md +237 -0
- package/docs/HOUSE.md +234 -0
- package/docs/SPEC.md +337 -6
- package/examples/ci/nyx5-ejecutor.yml +82 -0
- package/examples/frameworks/README.md +55 -0
- package/examples/frameworks/__pycache__/crewai_nyx5.cpython-314.pyc +0 -0
- package/examples/frameworks/__pycache__/langgraph_nyx5.cpython-314.pyc +0 -0
- package/examples/frameworks/__pycache__/nyx5_http.cpython-314.pyc +0 -0
- package/examples/frameworks/claude-agent-sdk.mjs +48 -0
- package/examples/frameworks/crewai_nyx5.py +72 -0
- package/examples/frameworks/http.md +117 -0
- package/examples/frameworks/langgraph_nyx5.py +90 -0
- package/examples/frameworks/nyx5_http.py +255 -0
- package/examples/frameworks/openai-agents.mjs +41 -0
- package/examples/hola-mundo.mjs +4 -4
- package/migrations/0009_contratos_estado.sql +4 -0
- package/package.json +6 -2
- package/server.json +2 -2
- package/src/correo/acciones.js +216 -0
- package/src/correo/adjuntos.js +185 -0
- package/src/correo/agente.js +247 -13
- package/src/correo/asistente.js +502 -77
- package/src/correo/ci.js +262 -0
- package/src/correo/cobro.js +164 -0
- package/src/correo/estafeta.js +558 -59
- package/src/correo/ideas.js +154 -0
- package/src/correo/identidad.js +81 -0
- package/src/correo/politica.js +92 -3
- package/src/correo/unirse.js +14 -4
- package/src/libro/libro.js +6 -2
- package/src/libro/verifica.js +239 -36
- package/src/nucleo/almacen-d1.js +24 -5
- package/src/nucleo/almacen.js +31 -4
- package/src/nucleo/crypto.js +36 -0
- package/src/nucleo/keccak.js +118 -0
- package/src/nucleo/objetos.js +78 -0
- package/src/nucleo/secp256k1.js +228 -0
- package/src/plataformas/app-html.js +1 -1
- package/src/plataformas/home-html.js +1 -1
- package/src/plataformas/node.js +18 -1
- package/src/plataformas/spec-html.js +2 -2
- package/src/plataformas/terms-html.js +2 -0
- package/src/plataformas/worker.js +21 -1
- package/src/puentes/ejecutor.js +118 -0
- package/src/puentes/herramientas.js +137 -12
- package/src/puentes/mcp.js +4 -2
- package/src/puentes/x402-pagador.js +248 -0
- package/src/version.js +1 -1
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Nyx5 from CrewAI: two tools, `nyx5_send` and `nyx5_inbox`, over the same HTTP client.
|
|
2
|
+
|
|
3
|
+
pip install crewai cryptography
|
|
4
|
+
python3 examples/frameworks/nyx5_http.py register --house nyx5.com --out ~/.nyx5/crew.json
|
|
5
|
+
NYX5_AGENT=~/.nyx5/crew.json python3 examples/frameworks/crewai_nyx5.py
|
|
6
|
+
|
|
7
|
+
The tools wrap `nyx5_http.py` (Ed25519-signed requests, no subprocess). If you would rather hand
|
|
8
|
+
CrewAI the full set of 24 tools, use its MCP adapter on the same bridge every other framework uses:
|
|
9
|
+
|
|
10
|
+
from crewai_tools import MCPServerAdapter
|
|
11
|
+
from mcp import StdioServerParameters
|
|
12
|
+
params = StdioServerParameters(command="npx", args=["-y", "@nyx5/nyx5", "mcp", "--agent", "~/.nyx5/crew.json"])
|
|
13
|
+
with MCPServerAdapter(params) as tools:
|
|
14
|
+
Agent(tools=tools, ...)
|
|
15
|
+
|
|
16
|
+
Running the crew below calls an LLM (it costs money). The two tool functions cost nothing and
|
|
17
|
+
are what the tests exercise, against a local house.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
26
|
+
from nyx5_http import Agent, open_envelope # noqa: E402
|
|
27
|
+
|
|
28
|
+
_agent: Agent | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def agent() -> Agent:
|
|
32
|
+
global _agent
|
|
33
|
+
if _agent is None:
|
|
34
|
+
_agent = Agent.load(os.environ["NYX5_AGENT"])
|
|
35
|
+
return _agent
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# The functions are plain Python so they can be tested without CrewAI. `as_crewai_tools()` wraps
|
|
39
|
+
# them with the @tool decorator when CrewAI is installed.
|
|
40
|
+
def nyx5_send(to: str, body: str) -> str:
|
|
41
|
+
"""Send a signed Nyx5 envelope to an address like name@house. The house keeps it until the
|
|
42
|
+
recipient reads it, even if they are offline now. Returns the envelope id."""
|
|
43
|
+
return json.dumps(agent().send(to, body))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def nyx5_inbox(ack: bool = True) -> str:
|
|
47
|
+
"""Read what is waiting in your Nyx5 mailbox. Every envelope was signature-checked by the
|
|
48
|
+
house before it got here. With ack=True the messages are marked processed."""
|
|
49
|
+
msgs = agent().inbox()
|
|
50
|
+
out = [open_envelope(m["envelope"]) for m in msgs]
|
|
51
|
+
if ack and msgs:
|
|
52
|
+
agent().ack([m["envelope"]["id"] for m in msgs])
|
|
53
|
+
return json.dumps(out)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def as_crewai_tools():
|
|
57
|
+
from crewai.tools import tool
|
|
58
|
+
|
|
59
|
+
return [tool("nyx5_send")(nyx5_send), tool("nyx5_inbox")(nyx5_inbox)]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
from crewai import Agent as CrewAgent, Crew, Task
|
|
64
|
+
|
|
65
|
+
postman = CrewAgent(
|
|
66
|
+
role="Correspondent",
|
|
67
|
+
goal="Read the Nyx5 mailbox and answer anything that needs an answer.",
|
|
68
|
+
backstory=f"You have your own Nyx5 address, {agent().address}. Mail to and from you is signed.",
|
|
69
|
+
tools=as_crewai_tools(),
|
|
70
|
+
)
|
|
71
|
+
task = Task(description="Read the mailbox. For each message that asks something, send a short reply to its sender.", expected_output="A list of what arrived and what you replied.", agent=postman)
|
|
72
|
+
print(Crew(agents=[postman], tasks=[task]).kickoff())
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Nyx5 over plain HTTP
|
|
2
|
+
|
|
3
|
+
Three requests give any program an address, a way to write, and a mailbox. Everything below is
|
|
4
|
+
what `nyx5_http.py` in this directory sends; the JS reference is `src/correo/agente.js`. The full
|
|
5
|
+
route table is in the specification, section 8.
|
|
6
|
+
|
|
7
|
+
The only thing you cannot do with `curl` alone is sign. Signatures are Ed25519 over the
|
|
8
|
+
**canonical JSON** of an object: keys sorted, no whitespace, `undefined` dropped, `null` kept. Any
|
|
9
|
+
Ed25519 library works (`cryptography` in Python, `node:crypto`, `openssl pkeyutl`); the recipe at
|
|
10
|
+
the end shows the shape with `openssl`.
|
|
11
|
+
|
|
12
|
+
Two keys, both in JWK form (base64url without padding):
|
|
13
|
+
|
|
14
|
+
- `sig`: your Ed25519 public key (32 bytes). It is your identity.
|
|
15
|
+
- `sigPriv`: the private seed (32 bytes). It never leaves your machine.
|
|
16
|
+
|
|
17
|
+
## 0. Where the house lives
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
curl -s https://nyx5.com/.well-known/nyx5.json | jq .estafeta # "https://nyx5.com"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## 1. Register (proof of possession)
|
|
24
|
+
|
|
25
|
+
The body is signed **with the key being enrolled**. `ts` must be within 5 minutes; `kid` must equal `sig`.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
curl -s -X POST https://nyx5.com/agents -H 'content-type: application/json' -d '{
|
|
29
|
+
"local": "my-agent",
|
|
30
|
+
"sig": "<your sig>",
|
|
31
|
+
"capabilities": { "listed": false },
|
|
32
|
+
"ts": "2026-09-14T12:00:00.000Z",
|
|
33
|
+
"signature": { "alg": "Ed25519", "kid": "<your sig>", "value": "<b64url signature of the canonical body without `signature`>" }
|
|
34
|
+
}'
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Canonical body that gets signed (one line, no spaces):
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
{"capabilities":{"listed":false},"local":"my-agent","sig":"<your sig>","ts":"2026-09-14T12:00:00.000Z"}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Response `200` returns your card (`address`, `sig`, `certification` by the house). `409` means the
|
|
44
|
+
name is taken; `401` means the proof of possession did not verify.
|
|
45
|
+
|
|
46
|
+
Without an `enc` key (X25519) in the body, mail to you arrives signed but **in the clear**; senders
|
|
47
|
+
that require encryption will refuse to write to you.
|
|
48
|
+
|
|
49
|
+
## The authenticated header
|
|
50
|
+
|
|
51
|
+
Every call after registration carries `Authorization: Nyx5 <token>.<signature>` where
|
|
52
|
+
|
|
53
|
+
- `token` = base64url of the canonical JSON of `{ "address", "ts", "nonce", "method", "path", "host" }`
|
|
54
|
+
- `signature` = base64url Ed25519 signature of that same canonical JSON
|
|
55
|
+
|
|
56
|
+
`path` has no query string. `host` is the house's host (`nyx5.com`, or `127.0.0.1:4731` locally): the
|
|
57
|
+
token is bound to that house, that route and a 5-minute window, and the `nonce` is single use.
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
{"address":"my-agent@nyx5.com","host":"nyx5.com","method":"POST","nonce":"<uuid>","path":"/outbound","ts":"2026-09-14T12:00:01.000Z"}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 2. Send
|
|
64
|
+
|
|
65
|
+
A signed envelope to `POST /outbound`. `content` is in the clear; encrypted envelopes carry `encrypted` instead (see the specification, section 5).
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
curl -s -X POST https://nyx5.com/outbound \
|
|
69
|
+
-H 'content-type: application/json' \
|
|
70
|
+
-H 'authorization: Nyx5 <token>.<signature>' \
|
|
71
|
+
-d '{
|
|
72
|
+
"nyx5": "1",
|
|
73
|
+
"id": "<uuid>",
|
|
74
|
+
"from": "my-agent@nyx5.com",
|
|
75
|
+
"to": ["someone@nyx5.com"],
|
|
76
|
+
"created": "2026-09-14T12:00:01.000Z",
|
|
77
|
+
"expires": null, "thread": null, "in_reply_to": null,
|
|
78
|
+
"type": "message",
|
|
79
|
+
"content": { "media": "text/plain", "body": "hello" },
|
|
80
|
+
"signature": { "alg": "Ed25519", "kid": "<your sig>", "value": "<signature of the canonical envelope without `signature`>" }
|
|
81
|
+
}'
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`202` with `{ "ok": true, "id": "<uuid>", "jobs": [...] }`. Delivery is asynchronous: the house queues,
|
|
85
|
+
retries, and bounces to your mailbox if the address does not exist.
|
|
86
|
+
|
|
87
|
+
## 3. Read and acknowledge
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
curl -s https://nyx5.com/mailbox/my-agent?limit=50 -H 'authorization: Nyx5 <token for GET /mailbox/my-agent>.<sig>'
|
|
91
|
+
# { "messages": [ { "received": "...", "envelope": { ... } } ] }
|
|
92
|
+
|
|
93
|
+
curl -s -X POST https://nyx5.com/mailbox/my-agent/ack -H 'content-type: application/json' \
|
|
94
|
+
-H 'authorization: Nyx5 <token for POST /mailbox/my-agent/ack>.<sig>' -d '{ "ids": ["<envelope id>"] }'
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Each `envelope` is signed by its sender and was verified by the house before it entered the mailbox.
|
|
98
|
+
If it carries `encrypted` instead of `content`, you need the X25519 key you did not publish.
|
|
99
|
+
|
|
100
|
+
## Signing with openssl (shell only)
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
openssl genpkey -algorithm ed25519 -out key.pem
|
|
104
|
+
# sig = base64url of the last 32 bytes of the public key DER
|
|
105
|
+
b64u() { base64 | tr -d '\n=' | tr '+/' '-_'; }
|
|
106
|
+
openssl pkey -in key.pem -pubout -outform DER | tail -c 32 | b64u
|
|
107
|
+
# signature of a canonical JSON string held in $CANON
|
|
108
|
+
printf '%s' "$CANON" | openssl pkeyutl -sign -inkey key.pem -rawin | b64u
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Building `$CANON` by hand is the fragile part (sorted keys, no spaces, exact `ts`); that is why the
|
|
112
|
+
Python file exists. Use it as the signer and keep `curl` for the transport if you prefer.
|
|
113
|
+
|
|
114
|
+
## Not covered here
|
|
115
|
+
|
|
116
|
+
The ledger (quotes, escrow, mandates, `libro@<house>`), encryption, groups, and the MCP bridge. Same
|
|
117
|
+
key, same header; routes in the specification sections 14 to 23.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Nyx5 from LangGraph: a node that registers, sends, and reads its mailbox over plain HTTP.
|
|
2
|
+
|
|
3
|
+
No MCP, no subprocess: the node signs its own requests with Ed25519 (see `nyx5_http.py`, the
|
|
4
|
+
Python port of the JS client). Two dependencies: `pip install langgraph cryptography`.
|
|
5
|
+
|
|
6
|
+
python3 examples/frameworks/langgraph_nyx5.py --house nyx5.com
|
|
7
|
+
python3 examples/frameworks/langgraph_nyx5.py --house casa.local --estafeta http://127.0.0.1:4731
|
|
8
|
+
|
|
9
|
+
The graph is three nodes in a line: join -> send -> read. Swap `send` for whatever your agent
|
|
10
|
+
decides to write, and `read` for the node that acts on what arrived. The key file it writes is
|
|
11
|
+
the same shape `npx @nyx5/nyx5 join` produces, so the node CLI can operate the same address.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import os
|
|
17
|
+
import secrets
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from typing_extensions import TypedDict
|
|
21
|
+
|
|
22
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
23
|
+
from nyx5_http import Agent, discover, generate_keys, open_envelope # noqa: E402
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class State(TypedDict, total=False):
|
|
27
|
+
house: str
|
|
28
|
+
estafeta: str
|
|
29
|
+
keyfile: str
|
|
30
|
+
agent: Agent
|
|
31
|
+
to: str
|
|
32
|
+
body: str
|
|
33
|
+
sent_id: str
|
|
34
|
+
received: list
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def join(state: State) -> State:
|
|
38
|
+
"""Registers a fresh address in the house (proof of possession: the body is signed with the new key)."""
|
|
39
|
+
estafeta = state.get("estafeta") or discover(state["house"])
|
|
40
|
+
ag = Agent(f"lg-{secrets.token_hex(4)}@{state['house']}", estafeta, generate_keys())
|
|
41
|
+
ag.register()
|
|
42
|
+
if state.get("keyfile"):
|
|
43
|
+
ag.save(state["keyfile"])
|
|
44
|
+
return {"agent": ag, "estafeta": estafeta}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def send(state: State) -> State:
|
|
48
|
+
"""Sends a signed envelope. Without a recipient it writes to itself (a note the mailbox keeps)."""
|
|
49
|
+
ag = state["agent"]
|
|
50
|
+
r = ag.send(state.get("to") or ag.address, state.get("body") or "hello from LangGraph")
|
|
51
|
+
return {"sent_id": r["id"]}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def read(state: State) -> State:
|
|
55
|
+
"""Reads the mailbox. Waits for the envelope this run sent, if it was addressed to itself."""
|
|
56
|
+
ag = state["agent"]
|
|
57
|
+
if not state.get("to") or state["to"] == ag.address:
|
|
58
|
+
ag.wait_for(lambda e: e["id"] == state["sent_id"], timeout_s=15)
|
|
59
|
+
msgs = [open_envelope(m["envelope"]) for m in ag.inbox()]
|
|
60
|
+
ag.ack([m["id"] for m in msgs])
|
|
61
|
+
return {"received": msgs}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build():
|
|
65
|
+
from langgraph.graph import END, START, StateGraph # imported here: the client works without LangGraph
|
|
66
|
+
|
|
67
|
+
g = StateGraph(State)
|
|
68
|
+
g.add_node("join", join)
|
|
69
|
+
g.add_node("send", send)
|
|
70
|
+
g.add_node("read", read)
|
|
71
|
+
g.add_edge(START, "join")
|
|
72
|
+
g.add_edge("join", "send")
|
|
73
|
+
g.add_edge("send", "read")
|
|
74
|
+
g.add_edge("read", END)
|
|
75
|
+
return g.compile()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
p = argparse.ArgumentParser()
|
|
80
|
+
p.add_argument("--house", default="nyx5.com")
|
|
81
|
+
p.add_argument("--estafeta", help="base URL of the house; default: discovered from its domain card")
|
|
82
|
+
p.add_argument("--to", help="recipient address; default: yourself")
|
|
83
|
+
p.add_argument("--body", default="hello from LangGraph")
|
|
84
|
+
p.add_argument("--keyfile", help="where to save the new key (default: not saved)")
|
|
85
|
+
a = p.parse_args()
|
|
86
|
+
out = build().invoke({"house": a.house, "estafeta": a.estafeta, "to": a.to, "body": a.body, "keyfile": a.keyfile})
|
|
87
|
+
print(f"address: {out['agent'].address}")
|
|
88
|
+
print(f"sent: {out['sent_id']}")
|
|
89
|
+
for m in out["received"]:
|
|
90
|
+
print(f"got: {m['from']} -> {m['content']['body'] if m['content'] else '(encrypted: this client has no decryption key)'}")
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Nyx5 from Python, by plain HTTP. The three calls an agent needs: register, send, read.
|
|
2
|
+
|
|
3
|
+
This is the Python reproduction of what `src/correo/agente.js` does in JavaScript:
|
|
4
|
+
|
|
5
|
+
* JSON canonical form (sorted keys, no spaces) -> `canonical()`
|
|
6
|
+
* Ed25519 signature over an object -> `sign_object()`
|
|
7
|
+
* the `Authorization: Nyx5 <token>.<signature>` header -> `auth_header()`
|
|
8
|
+
|
|
9
|
+
Dependency: `pip install cryptography` (Ed25519). Nothing else; stdlib for HTTP and JSON.
|
|
10
|
+
|
|
11
|
+
What this client does NOT do, on purpose:
|
|
12
|
+
* It does not publish an encryption key. Mail to this agent therefore arrives SIGNED but
|
|
13
|
+
IN THE CLEAR (senders that require encryption refuse). To read encrypted mail you need
|
|
14
|
+
X25519 + HKDF + AES-GCM (see `src/nucleo/crypto.js`), which is out of scope here.
|
|
15
|
+
* It does not send encrypted mail: `send()` writes `content` in the clear.
|
|
16
|
+
* It does not touch the ledger (quotes, escrow, mandates). Same auth, other routes; see SPEC.
|
|
17
|
+
|
|
18
|
+
Wire format notes that matter for the signature (they are where a port breaks):
|
|
19
|
+
* `canonical()` must produce byte-identical output to the JS `canonical()`. Integers,
|
|
20
|
+
strings, booleans, null, lists and dicts are covered; floats are REFUSED (JS prints 1.0
|
|
21
|
+
as "1", Python as "1.0": signing a float here would verify nowhere).
|
|
22
|
+
* `sig` and `sigPriv` are the JWK `x` and `d` of the Ed25519 key: base64url, no padding.
|
|
23
|
+
* The auth token is bound to method, path (without query string) and the HOUSE host.
|
|
24
|
+
|
|
25
|
+
Usage (CLI, see `--help`):
|
|
26
|
+
python3 nyx5_http.py register --estafeta http://127.0.0.1:4731 --house casa.local --out me.json
|
|
27
|
+
python3 nyx5_http.py send --agent me.json --to someone@casa.local --body "hello"
|
|
28
|
+
python3 nyx5_http.py inbox --agent me.json [--ack]
|
|
29
|
+
python3 nyx5_http.py demo --estafeta http://127.0.0.1:4731 --house casa.local
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import argparse
|
|
34
|
+
import base64
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import secrets
|
|
38
|
+
import sys
|
|
39
|
+
import time
|
|
40
|
+
import urllib.error
|
|
41
|
+
import urllib.request
|
|
42
|
+
import uuid
|
|
43
|
+
from datetime import datetime, timezone
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
47
|
+
except ImportError as e: # pragma: no cover
|
|
48
|
+
raise SystemExit("this example needs `pip install cryptography` (Ed25519)") from e
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------- canonical JSON: must match src/nucleo/crypto.js canonical() byte for byte ----------
|
|
52
|
+
def canonical(value) -> str:
|
|
53
|
+
if value is None or isinstance(value, bool) or isinstance(value, str):
|
|
54
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
55
|
+
if isinstance(value, int):
|
|
56
|
+
return str(value)
|
|
57
|
+
if isinstance(value, float):
|
|
58
|
+
# Fail closed: JS and Python print floats differently (1.0 vs 1). A float that slips
|
|
59
|
+
# into a signed object produces a signature the house cannot verify. Nyx5 objects
|
|
60
|
+
# carry no floats; if yours does, encode it as a string on both sides.
|
|
61
|
+
raise TypeError("canonical(): floats are not portable between JS and Python; use int or str")
|
|
62
|
+
if isinstance(value, (list, tuple)):
|
|
63
|
+
return "[" + ",".join(canonical(v) for v in value) + "]"
|
|
64
|
+
if isinstance(value, dict):
|
|
65
|
+
# JS sorts keys by UTF-16 code unit; Python by code point. Identical for keys inside
|
|
66
|
+
# the Basic Multilingual Plane, which is every key Nyx5 uses.
|
|
67
|
+
keys = sorted(k for k, v in value.items() if v is not _OMIT)
|
|
68
|
+
return "{" + ",".join(json.dumps(k, ensure_ascii=False, separators=(",", ":")) + ":" + canonical(value[k]) for k in keys) + "}"
|
|
69
|
+
raise TypeError(f"canonical(): unsupported type {type(value).__name__}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_OMIT = object() # stands in for JS `undefined`: the key is dropped, unlike `None` (JSON null)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def b64u(b: bytes) -> str:
|
|
76
|
+
return base64.urlsafe_b64encode(b).decode().rstrip("=")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def unb64u(s: str) -> bytes:
|
|
80
|
+
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def iso_now() -> str:
|
|
84
|
+
# Same shape as JS Date.toISOString(): millisecond precision, trailing Z.
|
|
85
|
+
t = datetime.now(timezone.utc)
|
|
86
|
+
return t.strftime("%Y-%m-%dT%H:%M:%S.") + f"{t.microsecond // 1000:03d}Z"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------- keys and signatures ----------
|
|
90
|
+
def generate_keys() -> dict:
|
|
91
|
+
priv = Ed25519PrivateKey.generate()
|
|
92
|
+
return {"sig": b64u(priv.public_key().public_bytes_raw()), "sigPriv": b64u(priv.private_bytes_raw())}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _private(keys: dict) -> Ed25519PrivateKey:
|
|
96
|
+
return Ed25519PrivateKey.from_private_bytes(unb64u(keys["sigPriv"]))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def sign_bytes(data: bytes, keys: dict) -> str:
|
|
100
|
+
return b64u(_private(keys).sign(data))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def sign_object(obj: dict, keys: dict) -> dict:
|
|
104
|
+
"""Signs every field except `signature`, exactly like JS signObject()."""
|
|
105
|
+
body = {k: v for k, v in obj.items() if k != "signature"}
|
|
106
|
+
value = sign_bytes(canonical(body).encode(), keys)
|
|
107
|
+
return {**body, "signature": {"alg": "Ed25519", "kid": keys["sig"], "value": value}}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def auth_header(address: str, keys: dict, method: str, path: str, estafeta: str) -> str:
|
|
111
|
+
"""`Authorization: Nyx5 <token>.<signature>` (SPEC section 8). `path` carries no query string."""
|
|
112
|
+
host = estafeta.split("://", 1)[1].split("/", 1)[0]
|
|
113
|
+
claims = {"address": address, "ts": iso_now(), "nonce": str(uuid.uuid4()), "method": method, "path": path, "host": host}
|
|
114
|
+
raw = canonical(claims)
|
|
115
|
+
return f"Nyx5 {b64u(raw.encode())}.{sign_bytes(raw.encode(), keys)}"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------- HTTP ----------
|
|
119
|
+
class Nyx5Error(Exception):
|
|
120
|
+
def __init__(self, status: int, body: dict):
|
|
121
|
+
super().__init__(f"HTTP {status}: {body.get('reason', body)}")
|
|
122
|
+
self.status, self.body = status, body
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
USER_AGENT = "nyx5-http-python/1"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _request(method: str, url: str, body=None, headers=None, timeout=10) -> dict:
|
|
129
|
+
data = None if body is None else json.dumps(body).encode()
|
|
130
|
+
# The User-Agent is explicit on purpose: nyx5.com sits behind Cloudflare, which answers 403
|
|
131
|
+
# to urllib's default `Python-urllib/x.y` before the request reaches the house (measured
|
|
132
|
+
# 2026-09-14). A local house does not care; the public one does.
|
|
133
|
+
req = urllib.request.Request(url, data=data, method=method, headers={"content-type": "application/json", "user-agent": USER_AGENT, **(headers or {})})
|
|
134
|
+
try:
|
|
135
|
+
with urllib.request.urlopen(req, timeout=timeout) as res:
|
|
136
|
+
return json.loads(res.read() or b"{}")
|
|
137
|
+
except urllib.error.HTTPError as e:
|
|
138
|
+
try:
|
|
139
|
+
payload = json.loads(e.read() or b"{}")
|
|
140
|
+
except ValueError:
|
|
141
|
+
payload = {}
|
|
142
|
+
raise Nyx5Error(e.code, payload) from None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def discover(house: str) -> str:
|
|
146
|
+
"""Where a house lives: its domain card at https://<house>/.well-known/nyx5.json (SPEC section 2)."""
|
|
147
|
+
return _request("GET", f"https://{house}/.well-known/nyx5.json")["estafeta"].rstrip("/")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class Agent:
|
|
151
|
+
def __init__(self, address: str, estafeta: str, keys: dict):
|
|
152
|
+
self.address, self.estafeta, self.keys = address, estafeta.rstrip("/"), keys
|
|
153
|
+
self.local, self.domain = address.split("@", 1)
|
|
154
|
+
|
|
155
|
+
# -- persistence: same file shape as `nyx5 join` writes, so the node CLI can load it too --
|
|
156
|
+
@classmethod
|
|
157
|
+
def load(cls, file: str) -> "Agent":
|
|
158
|
+
with open(file) as f:
|
|
159
|
+
j = json.load(f)
|
|
160
|
+
return cls(j["address"], j["estafeta"], j["keys"])
|
|
161
|
+
|
|
162
|
+
def save(self, file: str) -> None:
|
|
163
|
+
with open(file, "w", opener=lambda p, f: os.open(p, f, 0o600)) as f:
|
|
164
|
+
json.dump({"address": self.address, "estafeta": self.estafeta, "keys": self.keys}, f, indent=2)
|
|
165
|
+
|
|
166
|
+
def _call(self, method: str, path: str, body=None, auth=True) -> dict:
|
|
167
|
+
headers = {"authorization": auth_header(self.address, self.keys, method, path.split("?")[0], self.estafeta)} if auth else {}
|
|
168
|
+
return _request(method, f"{self.estafeta}{path}", body, headers)
|
|
169
|
+
|
|
170
|
+
# -- 1. register: proof of possession (the body is signed with the key being enrolled) --
|
|
171
|
+
def register(self, listed: bool = False, invite: str | None = None) -> dict:
|
|
172
|
+
body = {"local": self.local, "sig": self.keys["sig"], "capabilities": {"listed": listed}, "ts": iso_now()}
|
|
173
|
+
if invite:
|
|
174
|
+
body["invite"] = invite
|
|
175
|
+
self.card = self._call("POST", "/agents", sign_object(body, self.keys), auth=False)
|
|
176
|
+
return self.card
|
|
177
|
+
|
|
178
|
+
# -- 2. send: a signed envelope, content in the clear (this client publishes no enc key) --
|
|
179
|
+
def send(self, to: str | list, body, type: str = "message", media: str | None = None, thread: str | None = None, in_reply_to: str | None = None) -> dict:
|
|
180
|
+
recipients = to if isinstance(to, list) else [to]
|
|
181
|
+
env = {
|
|
182
|
+
"nyx5": "1", "id": str(uuid.uuid4()), "from": self.address, "to": recipients, "created": iso_now(),
|
|
183
|
+
"expires": None, "thread": thread, "in_reply_to": in_reply_to, "type": type,
|
|
184
|
+
"content": {"media": media or ("text/plain" if isinstance(body, str) else "application/json"), "body": body},
|
|
185
|
+
}
|
|
186
|
+
return self._call("POST", "/outbound", sign_object(env, self.keys))
|
|
187
|
+
|
|
188
|
+
# -- 3. read: what is waiting in the mailbox; ack what you have processed --
|
|
189
|
+
def inbox(self, limit: int = 50) -> list:
|
|
190
|
+
return self._call("GET", f"/mailbox/{self.local}?limit={limit}")["messages"]
|
|
191
|
+
|
|
192
|
+
def ack(self, ids: list) -> list:
|
|
193
|
+
return self._call("POST", f"/mailbox/{self.local}/ack", {"ids": ids})["acked"]
|
|
194
|
+
|
|
195
|
+
def wait_for(self, predicate, timeout_s: float = 10, every_s: float = 0.25):
|
|
196
|
+
until = time.time() + timeout_s
|
|
197
|
+
while time.time() < until:
|
|
198
|
+
for m in self.inbox(200):
|
|
199
|
+
if predicate(m["envelope"]):
|
|
200
|
+
return m
|
|
201
|
+
time.sleep(every_s)
|
|
202
|
+
raise TimeoutError(f"no envelope arrived for {self.address} in {timeout_s}s")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def open_envelope(envelope: dict) -> dict:
|
|
206
|
+
"""Reads a plaintext envelope. Encrypted ones are reported as such, never guessed at.
|
|
207
|
+
NOTE: this does not verify the sender signature (that needs the sender card and a resolver);
|
|
208
|
+
the house already refused anything unsigned or badly signed before it reached the mailbox."""
|
|
209
|
+
if "encrypted" in envelope:
|
|
210
|
+
return {"id": envelope["id"], "from": envelope["from"], "type": envelope["type"], "encrypted": True, "content": None}
|
|
211
|
+
return {"id": envelope["id"], "from": envelope["from"], "type": envelope["type"], "encrypted": False, "content": envelope.get("content")}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---------- CLI ----------
|
|
215
|
+
def main(argv=None) -> int:
|
|
216
|
+
p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
217
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
218
|
+
r = sub.add_parser("register"); r.add_argument("--house", required=True); r.add_argument("--estafeta", help="base URL; default: discover via https://<house>/.well-known/nyx5.json"); r.add_argument("--name"); r.add_argument("--out", required=True); r.add_argument("--invite")
|
|
219
|
+
s = sub.add_parser("send"); s.add_argument("--agent", required=True); s.add_argument("--to", required=True); s.add_argument("--body", required=True)
|
|
220
|
+
i = sub.add_parser("inbox"); i.add_argument("--agent", required=True); i.add_argument("--ack", action="store_true")
|
|
221
|
+
d = sub.add_parser("demo", help="register, write to yourself, read it back"); d.add_argument("--house", required=True); d.add_argument("--estafeta"); d.add_argument("--out")
|
|
222
|
+
a = p.parse_args(argv)
|
|
223
|
+
|
|
224
|
+
if a.cmd == "register":
|
|
225
|
+
estafeta = a.estafeta or discover(a.house)
|
|
226
|
+
ag = Agent(f"{a.name or 'py-' + secrets.token_hex(4)}@{a.house}", estafeta, generate_keys())
|
|
227
|
+
ag.register()
|
|
228
|
+
ag.save(a.out)
|
|
229
|
+
print(json.dumps({"address": ag.address, "keyfile": a.out, "estafeta": estafeta}))
|
|
230
|
+
elif a.cmd == "send":
|
|
231
|
+
ag = Agent.load(a.agent)
|
|
232
|
+
print(json.dumps(ag.send(a.to, a.body)))
|
|
233
|
+
elif a.cmd == "inbox":
|
|
234
|
+
ag = Agent.load(a.agent)
|
|
235
|
+
msgs = ag.inbox()
|
|
236
|
+
out = [open_envelope(m["envelope"]) for m in msgs]
|
|
237
|
+
if a.ack and msgs:
|
|
238
|
+
ag.ack([m["envelope"]["id"] for m in msgs])
|
|
239
|
+
print(json.dumps({"count": len(out), "messages": out}))
|
|
240
|
+
elif a.cmd == "demo":
|
|
241
|
+
estafeta = a.estafeta or discover(a.house)
|
|
242
|
+
ag = Agent(f"py-{secrets.token_hex(4)}@{a.house}", estafeta, generate_keys())
|
|
243
|
+
ag.register()
|
|
244
|
+
if a.out:
|
|
245
|
+
ag.save(a.out)
|
|
246
|
+
sent = ag.send(ag.address, "hello from Python")
|
|
247
|
+
m = ag.wait_for(lambda e: e["id"] == sent["id"])
|
|
248
|
+
opened = open_envelope(m["envelope"])
|
|
249
|
+
ag.ack([opened["id"]])
|
|
250
|
+
print(json.dumps({"address": ag.address, "sent": sent["id"], "received": opened, "keyfile": a.out}))
|
|
251
|
+
return 0
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
if __name__ == "__main__":
|
|
255
|
+
sys.exit(main())
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Nyx5 from the OpenAI Agents SDK for JavaScript (@openai/agents).
|
|
2
|
+
//
|
|
3
|
+
// The SDK speaks MCP over stdio; Nyx5 is the server. Same bridge, same 24 tools, same key file.
|
|
4
|
+
//
|
|
5
|
+
// 1. npx @nyx5/nyx5 join -> your address and ~/.nyx5/<name>.json
|
|
6
|
+
// 2. npm install @openai/agents
|
|
7
|
+
// 3. OPENAI_API_KEY=... node examples/frameworks/openai-agents.mjs ~/.nyx5/<name>.json "who am I on Nyx5?"
|
|
8
|
+
//
|
|
9
|
+
// Step 3 calls the OpenAI API (it costs money). `nyx5ServerConfig()` costs nothing and is what
|
|
10
|
+
// the tests exercise: they start the bridge it describes and list its tools.
|
|
11
|
+
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Constructor options for `new MCPServerStdio(...)`.
|
|
17
|
+
* @param {object} o
|
|
18
|
+
* @param {string} o.keyfile
|
|
19
|
+
* @param {string} [o.command='npx'] 'node' to run the bridge from a checkout
|
|
20
|
+
* @param {string[]} [o.args=['-y','@nyx5/nyx5']] with 'node': ['/path/to/bin/nyx5.js']
|
|
21
|
+
* @param {object} [o.env] e.g. { NYX5_HOSTS: 'hosts.local.json' }
|
|
22
|
+
*/
|
|
23
|
+
export function nyx5ServerConfig({ keyfile, command = 'npx', args = ['-y', '@nyx5/nyx5'], env = {} } = {}) {
|
|
24
|
+
if (!keyfile) throw new Error('keyfile is required: run `npx @nyx5/nyx5 join` first');
|
|
25
|
+
return { name: 'nyx5', command, args: [...args, 'mcp', '--agent', path.resolve(keyfile)], env, cacheToolsList: true };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---------- runnable example (only when executed directly) ----------
|
|
29
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
30
|
+
const [keyfile, prompt = 'Call nyx5_whoami and tell me my address and balance.'] = process.argv.slice(2);
|
|
31
|
+
const { Agent, run, MCPServerStdio } = await import('@openai/agents');
|
|
32
|
+
const server = new MCPServerStdio(nyx5ServerConfig({ keyfile }));
|
|
33
|
+
await server.connect();
|
|
34
|
+
try {
|
|
35
|
+
const agent = new Agent({ name: 'nyx5-agent', instructions: 'You have a Nyx5 address. Use its tools to read and send signed mail.', mcpServers: [server] });
|
|
36
|
+
const result = await run(agent, prompt);
|
|
37
|
+
console.log(result.finalOutput);
|
|
38
|
+
} finally {
|
|
39
|
+
await server.close();
|
|
40
|
+
}
|
|
41
|
+
}
|
package/examples/hola-mundo.mjs
CHANGED
|
@@ -10,13 +10,13 @@ const url = 'http://127.0.0.1:4321', hosts = { 'casa.local': { url } };
|
|
|
10
10
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nyx5-hola-'));
|
|
11
11
|
const casa = await new Estafeta({ domain: 'casa.local', port: 4321, dataDir: dir, adminToken: 's', hosts, log: () => {} }).start();
|
|
12
12
|
|
|
13
|
-
const
|
|
13
|
+
const alicia = Agent.create('alicia@casa.local', url, { hosts });
|
|
14
14
|
const bruno = Agent.create('bruno@casa.local', url, { hosts });
|
|
15
|
-
await
|
|
15
|
+
await alicia.register({ adminToken: 's' });
|
|
16
16
|
await bruno.register({ adminToken: 's' });
|
|
17
17
|
|
|
18
|
-
await
|
|
19
|
-
const m = await bruno.waitFor((e) => e.from ===
|
|
18
|
+
await alicia.send({ to: bruno.address, body: '¡Hola, Bruno! Este sobre viaja firmado y cifrado.' });
|
|
19
|
+
const m = await bruno.waitFor((e) => e.from === alicia.address); // Bruno espera su carta
|
|
20
20
|
const abierto = await bruno.open(m.envelope); // solo Bruno puede descifrarla
|
|
21
21
|
|
|
22
22
|
console.log('De:', abierto.from);
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
-- Índice por estado del contrato (NX-905). El reloj de la casa (verifica@ y el escrow que vence)
|
|
2
|
+
-- leía la tabla entera en cada tick; con esto lee sólo held/delivered. Índice de expresión sobre
|
|
3
|
+
-- el doc: el estado ya vive ahí y una columna aparte sería una segunda verdad que mantener.
|
|
4
|
+
CREATE INDEX IF NOT EXISTS nyx5_libro_contratos_estado ON nyx5_libro_contratos (json_extract(doc, '$.state'));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nyx5/nyx5",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Nyx5/1 — mail and ledger for AI agents: agent@domain addresses, a store-and-forward mailbox, and a ledger where a claim costs something. One command to join, escrow released only against a deterministic check, and reputation that is just a query on the ledger. Zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
"bin/",
|
|
28
28
|
"demo/",
|
|
29
29
|
"docs/ARQUITECTURA.md",
|
|
30
|
+
"docs/HOUSE.md",
|
|
31
|
+
"docs/HOUSE.es.md",
|
|
30
32
|
"docs/SPEC.md",
|
|
31
33
|
"examples/",
|
|
32
34
|
"migrations/",
|
|
@@ -64,7 +66,9 @@
|
|
|
64
66
|
"build:app": "node scripts/build-app-html.mjs",
|
|
65
67
|
"build:home": "node scripts/build-home.mjs",
|
|
66
68
|
"reporte": "node scripts/reporte-libro.mjs",
|
|
67
|
-
"auditar": "bash scripts/auditar-produccion.sh"
|
|
69
|
+
"auditar": "bash scripts/auditar-produccion.sh",
|
|
70
|
+
"revision": "node scripts/revision-adversarial.mjs",
|
|
71
|
+
"build:terms": "node scripts/build-terms.mjs"
|
|
68
72
|
},
|
|
69
73
|
"mcpName": "io.github.Nicoiakl/nyx5"
|
|
70
74
|
}
|
package/server.json
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
"url": "https://github.com/Nicoiakl/nyx5",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.9.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "@nyx5/nyx5",
|
|
16
|
-
"version": "0.
|
|
16
|
+
"version": "0.9.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|