@muretai/agent-entry 1.1.0 → 1.2.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 +151 -6
- package/examples/server.mjs +56 -15
- package/muretai-agent-entry.mjs +513 -33
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -8,6 +8,9 @@ person replaces their phone, your site still knows it is them.
|
|
|
8
8
|
|
|
9
9
|
One file. Zero dependencies. Node 20+.
|
|
10
10
|
|
|
11
|
+
Questions are welcome — mention [@muretaiai](https://x.com/muretaiai) on X, or
|
|
12
|
+
[open an issue](https://github.com/muretai/agent-entry/issues).
|
|
13
|
+
|
|
11
14
|
```js
|
|
12
15
|
import { createAgentEntry } from '@muretai/agent-entry';
|
|
13
16
|
|
|
@@ -162,7 +165,15 @@ Tidied up for you: surrounding spaces, the case of the scheme and host, a defaul
|
|
|
162
165
|
|
|
163
166
|
Refused, with the fix in the message: a scheme other than `http`/`https`, a missing host,
|
|
164
167
|
`user@host`, a query string, a `#` fragment, non-ASCII characters, a stray tab or space, a
|
|
165
|
-
backslash, `.` or `..` in the path
|
|
168
|
+
backslash, `.` or `..` in the path **including their `%2e` spellings**, a broken `%` escape,
|
|
169
|
+
and a port outside 1–65535.
|
|
170
|
+
|
|
171
|
+
> **Upgrading from 1.1.x?** The `%2e` rule is new. A `baseUrl` like
|
|
172
|
+
> `https://shop.example/a/%2e%2e/support` used to start on the Python reference and now
|
|
173
|
+
> refuses on both — because a browser's URL parser removes those segments and Python's does
|
|
174
|
+
> not, so the address you publish and the address a visitor computes were already two
|
|
175
|
+
> different things. The refusal names the string to paste instead. **Check your `baseUrl`
|
|
176
|
+
> before you deploy:** this turns a running entry into one that will not boot.
|
|
166
177
|
|
|
167
178
|
Two rules worth knowing before you pick a URL:
|
|
168
179
|
|
|
@@ -173,6 +184,87 @@ Two rules worth knowing before you pick a URL:
|
|
|
173
184
|
parser punycodes a host and Python's does not, so the two implementations would otherwise
|
|
174
185
|
sign different bytes for the same site.
|
|
175
186
|
|
|
187
|
+
## One host, many agents
|
|
188
|
+
|
|
189
|
+
A domain can hold a **fleet** — a front desk, support, sales — each its own agent, its own
|
|
190
|
+
key, its own address, each contactable directly. Give each one a `baseUrl` that carries its
|
|
191
|
+
path:
|
|
192
|
+
|
|
193
|
+
```js
|
|
194
|
+
createAgentEntry({ seedHex: SUPPORT_SEED, name: 'Support',
|
|
195
|
+
baseUrl: 'https://studio.example/support', responder });
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Every route then hangs off that path — `GET /support/.well-known/agent-card.json`, the
|
|
199
|
+
signed envelope beside it, and `POST /support` — and **the bare host is a 404 for that
|
|
200
|
+
entry**. On a shared host the bare host belongs to your site or to a neighbour, and an entry
|
|
201
|
+
that answered there would be answering for someone else.
|
|
202
|
+
|
|
203
|
+
The mount is **derived from `baseUrl`**, never configured beside it, so the address the
|
|
204
|
+
router answers on and the address the signed card claims are the same string by
|
|
205
|
+
construction. Two settings would let you spell them differently, and that produces the worst
|
|
206
|
+
error message this system has: every visitor fails with *"cannot prove that … owns …"* and
|
|
207
|
+
nothing says why.
|
|
208
|
+
|
|
209
|
+
A visitor handed `https://studio.example/support` reaches support and **only** support. If
|
|
210
|
+
sales re-served support's genuine, correctly-signed envelope at `/sales`, the visitor
|
|
211
|
+
refuses it — the signature is real, but the signed address says `/support` and the visitor
|
|
212
|
+
dialled `/sales`. That is what lets two agents share a hostname safely.
|
|
213
|
+
|
|
214
|
+
Routing a fleet with nginx — pass the prefix **through** (no trailing slash on `proxy_pass`)
|
|
215
|
+
so each entry sees the path its card claims:
|
|
216
|
+
|
|
217
|
+
```nginx
|
|
218
|
+
location /support/ { proxy_pass http://127.0.0.1:8788; }
|
|
219
|
+
location = /support { proxy_pass http://127.0.0.1:8788; }
|
|
220
|
+
location /sales/ { proxy_pass http://127.0.0.1:8789; }
|
|
221
|
+
location = /sales { proxy_pass http://127.0.0.1:8789; }
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
If your proxy **strips** the prefix instead (`proxy_pass http://127.0.0.1:8788/` — note the
|
|
225
|
+
trailing slash), pass `basePath: ''`. That is the one override, and it may only be `''` or
|
|
226
|
+
exactly the path `baseUrl` already names; anything else refuses at startup, because a third
|
|
227
|
+
spelling of your address is the thing this design exists to prevent.
|
|
228
|
+
|
|
229
|
+
Inside one Express app, use `req.originalUrl` — never `req.url`, which a mounted router has
|
|
230
|
+
already stripped:
|
|
231
|
+
|
|
232
|
+
```js
|
|
233
|
+
const fwd = (entry) => async (req, res) => {
|
|
234
|
+
const r = await entry.handleRequestAsync(req.method, req.originalUrl, req.headers, req.body);
|
|
235
|
+
res.status(r.status).set(r.headers).send(r.body);
|
|
236
|
+
};
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Which domains this entry speaks for
|
|
240
|
+
|
|
241
|
+
An entry can name the domains it belongs to:
|
|
242
|
+
|
|
243
|
+
```js
|
|
244
|
+
createAgentEntry({ seedHex, baseUrl: 'https://studio.example',
|
|
245
|
+
domains: ['studio.example'], responder });
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
This is **one half of a two-sided proof**, and it is worth being clear about what each half
|
|
249
|
+
does. Your card says "I speak for studio.example". The domain says, in a
|
|
250
|
+
`/.well-known/did-configuration.json` it serves, "this DID speaks for me". A verifier accepts
|
|
251
|
+
the binding only when **both** halves agree — so neither a domain that lists a DID it does
|
|
252
|
+
not own, nor an agent that claims a domain it has never touched, proves anything alone. And
|
|
253
|
+
either side can withdraw: the domain owner deletes one line from a file they already control,
|
|
254
|
+
and that agent — and only that agent — stops verifying.
|
|
255
|
+
|
|
256
|
+
That is why a domain may name many agents. Revoking one is a one-line edit, not a migration.
|
|
257
|
+
|
|
258
|
+
Names are checked at startup: a bare host, at least two labels, ASCII only, an optional
|
|
259
|
+
`:port`, at most five of them. Anything else — a scheme, a path, a stray space, an empty
|
|
260
|
+
entry from a trailing comma — **refuses to start**. So does naming more than five, rather
|
|
261
|
+
than quietly publishing the first five: a claim that is usable and is not what you said is
|
|
262
|
+
worse than a refusal you can read.
|
|
263
|
+
|
|
264
|
+
Naming no domain is the default and publishes exactly what 1.1.x did.
|
|
265
|
+
|
|
266
|
+
Set it from the environment with `AGENT_ENTRY_DOMAINS=studio.example,support.studio.example`.
|
|
267
|
+
|
|
176
268
|
## Pairs with WebMCP: the tab conversation becomes a customer
|
|
177
269
|
|
|
178
270
|
If your page already exposes [WebMCP](https://github.com/MiguelsPizza/WebMCP) tools, you have
|
|
@@ -231,18 +323,62 @@ the source:
|
|
|
231
323
|
seconds; if your site needs that speed, put the check in the backend your `responder`
|
|
232
324
|
calls.
|
|
233
325
|
|
|
326
|
+
What the entry now handles for you at the HTTP layer, so you do not have to:
|
|
327
|
+
|
|
328
|
+
- **A stranger always gets an HTTP response.** Never a silently closed socket, whatever they
|
|
329
|
+
send. A request that stalls gets `408`; past a connection ceiling a new one gets `503`.
|
|
330
|
+
- **Slow-drip connections cannot pile up.** Headers, body and idle keep-alives each have a
|
|
331
|
+
wall-clock bound. A socket timeout alone does not stop this: a caller sending one byte per
|
|
332
|
+
interval resets it forever, and the read only ends when it has everything it asked for.
|
|
333
|
+
- **Ambiguous framing is refused, not guessed.** A repeated `Content-Length`, a
|
|
334
|
+
`Content-Length` alongside `Transfer-Encoding`, or a length that is not plain digits is a
|
|
335
|
+
`400`. Those are the shapes that make a proxy and an origin disagree about where one
|
|
336
|
+
request ends and the next begins. Chunked bodies on their own are accepted and decoded,
|
|
337
|
+
bounded by the same limits, because a reverse proxy may legitimately re-frame a request.
|
|
338
|
+
- **The body must be real UTF-8.** Invalid bytes are refused rather than silently replaced,
|
|
339
|
+
so the two implementations cannot disagree about what you were sent.
|
|
340
|
+
- **The request target must be in origin form.** `POST /` — not
|
|
341
|
+
`POST https://elsewhere.example/`. This is a deliberate departure from RFC 9112 §3.2.2,
|
|
342
|
+
which says a server must accept the absolute form: this endpoint answers exactly the
|
|
343
|
+
address its card names, and the refusal says so.
|
|
344
|
+
|
|
234
345
|
## Two implementations, pinned to each other
|
|
235
346
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
drives this module against `testdata/wire_vectors.json
|
|
239
|
-
|
|
240
|
-
|
|
347
|
+
This module is not alone. A Python reference implements the same contract, and the two are
|
|
348
|
+
held to **identical verdicts** by an acceptance suite: it runs the same attack battery
|
|
349
|
+
against both, drives this module against `testdata/wire_vectors.json` byte for byte, posts
|
|
350
|
+
identical bytes to each over real sockets — down to the HTTP framing — and requires the same
|
|
351
|
+
status, the same account outcome and the same signed reply from both. If you write a third
|
|
352
|
+
implementation, that suite is the gate.
|
|
241
353
|
|
|
242
354
|
The bytes are the contract: every signed payload must match Python's canonical JSON
|
|
243
355
|
exactly, or a signature is unverifiable and the only diagnostic anyone gets is
|
|
244
356
|
"signature verification failed".
|
|
245
357
|
|
|
358
|
+
**What ships here, and what does not.** This repo is the **site side**: the door a website runs.
|
|
359
|
+
It is one file, it depends on nothing, and it carries everything it needs including its own
|
|
360
|
+
Ed25519.
|
|
361
|
+
|
|
362
|
+
The other implementation is a Python one, and it lives with Muretai core, where it is the
|
|
363
|
+
executable specification the acceptance suite drives. It is not published here on purpose. A
|
|
364
|
+
door needs a signer, a card, a binding verifier and a domain-name check; core's copy reaches
|
|
365
|
+
for a URL guard, a JWS minter and a release module that a door never touches, and shipping
|
|
366
|
+
those here would put the *visiting-agent* and *node* sides of the network into an artifact that
|
|
367
|
+
is only ever the site side.
|
|
368
|
+
|
|
369
|
+
The visiting side needs nothing from this package either: an agent already has a runtime — a
|
|
370
|
+
Muretai node, or whatever framework it runs on — and that is what knocks on your door.
|
|
371
|
+
|
|
372
|
+
So the two implementations share no code at all, by design. **What holds them to identical
|
|
373
|
+
verdicts is the acceptance suite, not a shared library** — which is the honest arrangement,
|
|
374
|
+
because a shared library would only ever have covered the parts they happen to share. The
|
|
375
|
+
suite posts identical bytes to both, down to the HTTP framing, and requires the same status,
|
|
376
|
+
the same account outcome and the same signed reply.
|
|
377
|
+
|
|
378
|
+
`testdata/wire_vectors.json` is the part of that gate you can run here: it pins the canonical
|
|
379
|
+
JSON, the signing payloads, the card envelopes and the did:key round-trips this module must
|
|
380
|
+
reproduce byte for byte.
|
|
381
|
+
|
|
246
382
|
## What this is part of
|
|
247
383
|
|
|
248
384
|
[Muretai](https://muretai.com) is a network where AI agents that belong to *different
|
|
@@ -253,4 +389,13 @@ that has to stay awake.
|
|
|
253
389
|
You do not need the rest of the network to use this file. It is useful on its own the
|
|
254
390
|
moment an agent knocks.
|
|
255
391
|
|
|
392
|
+
## Questions
|
|
393
|
+
|
|
394
|
+
Ask — there is no wrong question about this, and the answers usually improve the docs.
|
|
395
|
+
|
|
396
|
+
- **X:** [@muretaiai](https://x.com/muretaiai) — mention us, we read them
|
|
397
|
+
- **Issues:** [github.com/muretai/agent-entry/issues](https://github.com/muretai/agent-entry/issues)
|
|
398
|
+
- **Security:** please report privately first, at
|
|
399
|
+
[muretai.com/.well-known/security.txt](https://muretai.com/.well-known/security.txt)
|
|
400
|
+
|
|
256
401
|
MIT.
|
package/examples/server.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* **This is a usage SAMPLE, not part of core Muretai.** It adds nothing to the protocol:
|
|
6
6
|
* it only wires the public primitive `createAgentEntry()` from
|
|
7
|
-
* `muretai-agent-entry.mjs` to a demo booking desk. Copy it, gut the responder,
|
|
7
|
+
* `web/agent-entry/muretai-agent-entry.mjs` to a demo booking desk. Copy it, gut the responder,
|
|
8
8
|
* point it at your backend — core stays byte-unchanged.
|
|
9
9
|
*
|
|
10
10
|
* Run it:
|
|
@@ -20,18 +20,43 @@
|
|
|
20
20
|
* into the Agent Card and a visitor requires the card to name the
|
|
21
21
|
* origin it dialled — behind a proxy or a tunnel, set this to the
|
|
22
22
|
* public URL or every verification fails. Default http://127.0.0.1:<port>.
|
|
23
|
+
* It MAY include a path (https://example.com/support): the entry then
|
|
24
|
+
* answers THERE and 404s the bare host, so one hostname can hold
|
|
25
|
+
* several agents — a front desk, support, sales — each its own key.
|
|
26
|
+
* AGENT_ENTRY_DOMAINS comma-separated bare domains this entry speaks for, e.g.
|
|
27
|
+
* "example.com". Half a proof: the domain must ALSO serve a
|
|
28
|
+
* credential naming this DID at /.well-known/did-configuration.json,
|
|
29
|
+
* or a verifier correctly reports the agent withdrew its side.
|
|
30
|
+
* At most 5, and every segment must be a bare domain — a doubled or
|
|
31
|
+
* trailing comma REFUSES to start rather than quietly publishing
|
|
32
|
+
* one name fewer than you wrote.
|
|
23
33
|
* AGENT_ENTRY_NAME public display name on the card
|
|
24
34
|
* AGENT_ENTRY_HOST bind address (default 127.0.0.1 — set 0.0.0.0 only behind TLS)
|
|
25
35
|
* AGENT_ENTRY_ANON "1" also accepts UNSIGNED walk-in inquiries (they mint no account)
|
|
26
36
|
*/
|
|
27
37
|
|
|
28
|
-
import { createAgentEntry, newSeedHex, didFromSeedHex, AGENT_CARD_PATH }
|
|
38
|
+
import { createAgentEntry, newSeedHex, didFromSeedHex, trimOuter, AGENT_CARD_PATH }
|
|
29
39
|
from '../muretai-agent-entry.mjs';
|
|
30
40
|
|
|
31
41
|
const port = Number(process.env.AGENT_ENTRY_PORT || 8788);
|
|
32
42
|
const host = process.env.AGENT_ENTRY_HOST || '127.0.0.1';
|
|
33
43
|
const baseUrl = process.env.AGENT_ENTRY_BASE_URL || `http://127.0.0.1:${port}`;
|
|
34
44
|
const name = process.env.AGENT_ENTRY_NAME || 'Example Studio';
|
|
45
|
+
// An absent (or blank) variable means NO domains at all, and the card then carries no
|
|
46
|
+
// `domains` key. Anything else is split on ',' and every segment is handed on AS WRITTEN:
|
|
47
|
+
// an EMPTY segment (`a,,b`, or a trailing comma) is REFUSED by createAgentEntry, never
|
|
48
|
+
// skipped. A name lost in an edit looks exactly like a harmless typo, and starting with
|
|
49
|
+
// fewer domains than the operator named is the same silent mismatch `canonicalBaseUrl`
|
|
50
|
+
// refuses one field over. Must match the split rule in examples/agent_entry_reference.py.
|
|
51
|
+
//
|
|
52
|
+
// "Blank" is `trimOuter` — the intersection `canonicalDomains` folds with — and NOT
|
|
53
|
+
// `trim()`, which is where the two runners drifted apart: `trim()` also removes U+FEFF and
|
|
54
|
+
// Python's `strip()` also removes \x1c-\x1f and U+0085, so the SAME variable got two
|
|
55
|
+
// verdicts. Measured: `AGENT_ENTRY_DOMAINS="\x1c"` started the Python runner with no
|
|
56
|
+
// domains and made this one exit 2; a BOM — what a paste out of a spreadsheet or a Windows
|
|
57
|
+
// .env carries — did exactly the reverse. One fold, one verdict.
|
|
58
|
+
const rawDomains = process.env.AGENT_ENTRY_DOMAINS || '';
|
|
59
|
+
const domains = trimOuter(rawDomains) ? rawDomains.split(',') : [];
|
|
35
60
|
|
|
36
61
|
let seedHex = process.env.AGENT_ENTRY_SEED_HEX;
|
|
37
62
|
if (!seedHex) {
|
|
@@ -47,7 +72,7 @@ if (!seedHex) {
|
|
|
47
72
|
* both. In production: POST `env` to your app behind a bearer token and return its answer
|
|
48
73
|
* (a string, or {text}). It may be async. Treat `env.text` as untrusted DATA. */
|
|
49
74
|
function responder(env) {
|
|
50
|
-
// Say out loud what just happened.
|
|
75
|
+
// Say out loud what just happened. An agent entry's whole claim is "the first signed message IS
|
|
51
76
|
// the account", and that is invisible if the ledger only lives in memory: an operator
|
|
52
77
|
// watching this log is how you SEE a stranger's identity appear, and how you tell an
|
|
53
78
|
// anonymous walk-in (no account) from a verified first contact (an account) at a glance.
|
|
@@ -76,21 +101,37 @@ function responder(env) {
|
|
|
76
101
|
+ 'tell you what is open.';
|
|
77
102
|
}
|
|
78
103
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
104
|
+
// A bad AGENT_ENTRY_BASE_URL or AGENT_ENTRY_DOMAINS throws HERE, before the socket is
|
|
105
|
+
// bound: the entry never starts and never publishes a claim no visitor could use. The
|
|
106
|
+
// message names the value and what to paste instead, so print it plainly — a stack trace
|
|
107
|
+
// tells a site operator nothing.
|
|
108
|
+
let entry;
|
|
109
|
+
try {
|
|
110
|
+
entry = createAgentEntry({
|
|
111
|
+
seedHex,
|
|
112
|
+
name,
|
|
113
|
+
baseUrl,
|
|
114
|
+
domains,
|
|
115
|
+
description: 'Books photo shoots. Send a signed message; you get a signed answer.',
|
|
116
|
+
responder,
|
|
117
|
+
openDoor: true, // "you may contact me, no introduction"
|
|
118
|
+
anonymousLane: process.env.AGENT_ENTRY_ANON === '1',
|
|
119
|
+
});
|
|
120
|
+
} catch (err) {
|
|
121
|
+
console.error(err && err.message ? err.message : String(err));
|
|
122
|
+
process.exit(2);
|
|
123
|
+
}
|
|
88
124
|
|
|
89
125
|
const server = entry.listen(port, host, () => {
|
|
90
|
-
console.log(`${name} is agent-reachable on ${
|
|
126
|
+
console.log(`${name} is agent-reachable on ${entry.card.url}`);
|
|
91
127
|
console.log(` DID: ${entry.did}`);
|
|
92
|
-
console.log(` Card: ${
|
|
93
|
-
|
|
128
|
+
console.log(` Card: ${entry.card.url}${AGENT_CARD_PATH}`);
|
|
129
|
+
if (entry.card.domains) {
|
|
130
|
+
console.log(` Speaking for: ${entry.card.domains.join(', ')} — each domain must serve a `
|
|
131
|
+
+ 'credential naming this DID at /.well-known/did-configuration.json');
|
|
132
|
+
}
|
|
133
|
+
console.log(` Listening on ${host}:${port} — POST a signed message/send to `
|
|
134
|
+
+ `${entry.mount || '/'}`);
|
|
94
135
|
});
|
|
95
136
|
|
|
96
137
|
// A port collision is the first thing anyone running this twice hits (a previous run that was
|
package/muretai-agent-entry.mjs
CHANGED
|
@@ -54,6 +54,12 @@ export const CARD_SIG_REFRESH_S = 3600;
|
|
|
54
54
|
* wrote. Must match `ANON_RATE_PER_MIN` in examples/agent_entry_reference.py: one contract,
|
|
55
55
|
* two implementations, one bound. */
|
|
56
56
|
export const ANON_RATE_PER_MIN = 30;
|
|
57
|
+
/** How many domains one card may advertise (agent/domainstore.MAX_CARD_DOMAINS, and the
|
|
58
|
+
* same ceiling shared/protocol.build_agent_card applies to a node's card). Every name
|
|
59
|
+
* listed is an outbound HTTPS fetch this entry asks strangers to make, so the cap bounds
|
|
60
|
+
* the work an entry can push onto its visitors — not how many domains a site may own.
|
|
61
|
+
* Must match `MAX_CARD_DOMAINS` in examples/agent_entry_reference.py. */
|
|
62
|
+
export const MAX_CARD_DOMAINS = 5;
|
|
57
63
|
|
|
58
64
|
export const AGENT_CARD_PATH = '/.well-known/agent-card.json';
|
|
59
65
|
export const AGENT_CARD_PATH_LEGACY = '/.well-known/agent.json';
|
|
@@ -796,6 +802,20 @@ class RateBound {
|
|
|
796
802
|
}
|
|
797
803
|
}
|
|
798
804
|
|
|
805
|
+
/**
|
|
806
|
+
* An unpaired UTF-16 surrogate — a string with no UTF-8 encoding at all.
|
|
807
|
+
*
|
|
808
|
+
* `"\ud800"` is legal JSON and both parsers accept it, but Python's `.encode("utf-8")`
|
|
809
|
+
* RAISES on it while a JavaScript Buffer quietly substitutes U+FFFD. Measured: a one-shot
|
|
810
|
+
* POST carrying `"text": "\ud800"` from a stranger with no key killed the Python reference
|
|
811
|
+
* entry's request with no HTTP response, and was answered -32001 here — same bytes, two
|
|
812
|
+
* verdicts, one of them a dead socket. It is not text; the shape gate refuses it on both.
|
|
813
|
+
*
|
|
814
|
+
* Written without the `u` flag on purpose: in a Unicode-mode pattern these ranges are not
|
|
815
|
+
* matchable as isolated code units, which is exactly what has to be matched here.
|
|
816
|
+
*/
|
|
817
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
818
|
+
|
|
799
819
|
/**
|
|
800
820
|
* The STRICT type check on the fields that end up inside the signed payload. Returns a
|
|
801
821
|
* reason string, or null when the shape is acceptable.
|
|
@@ -809,6 +829,16 @@ class RateBound {
|
|
|
809
829
|
* JavaScript renders it `1` and Python renders it `1.0`, so exactly one of them can verify
|
|
810
830
|
* the signature — and we would then ECHO it into our own signed reply.
|
|
811
831
|
*
|
|
832
|
+
* Also here, and for the same reason, are the ENVELOPE fields — `metadata` and the
|
|
833
|
+
* `from`/`to`/`sig` inside it. Step 4 below can only ask "is it there", and a wrongly-typed
|
|
834
|
+
* one answered that question WRONG on both twins in opposite directions: `metadata: "x"`
|
|
835
|
+
* read as an EMPTY envelope here (so -32001, or — with the anonymous lane on — a SIGNED
|
|
836
|
+
* ANONYMOUS REPLY, a malformed envelope silently downgraded to a walk-in) while the Python
|
|
837
|
+
* reference answered -32600; and `metadata.to = 1` survived Python's presence test and
|
|
838
|
+
* reached `to_did[:24]`, a TypeError that closed the socket with no HTTP response at all.
|
|
839
|
+
* A partial or malformed envelope is never an absent one — that rule is already written
|
|
840
|
+
* down for a stripped `sig` in docs/AGENT_ENTRY.md, and it holds for the type too.
|
|
841
|
+
*
|
|
812
842
|
* -32600 (Invalid Request) for all of them: a wrongly-typed field is a malformed request,
|
|
813
843
|
* not a failed signature. `examples/agent_entry_reference.py::_wire_shape_error` answers the
|
|
814
844
|
* same code for the same input, case for case.
|
|
@@ -825,14 +855,49 @@ function wireShapeError(msg) {
|
|
|
825
855
|
if ('text' in part && typeof part.text !== 'string') {
|
|
826
856
|
return 'a text part\'s `text` must be a string';
|
|
827
857
|
}
|
|
858
|
+
if (typeof part.text === 'string' && LONE_SURROGATE.test(part.text)) {
|
|
859
|
+
return 'a text part\'s `text` is not encodable UTF-8 (a lone surrogate)';
|
|
860
|
+
}
|
|
828
861
|
}
|
|
829
862
|
if (typeof msg.messageId !== 'string' || !msg.messageId) {
|
|
830
863
|
return 'messageId must be a non-empty string';
|
|
831
864
|
}
|
|
865
|
+
if (LONE_SURROGATE.test(msg.messageId)) {
|
|
866
|
+
return 'messageId is not encodable UTF-8 (a lone surrogate)';
|
|
867
|
+
}
|
|
832
868
|
if (msg.contextId !== undefined && msg.contextId !== null
|
|
833
869
|
&& typeof msg.contextId !== 'string') {
|
|
834
870
|
return 'contextId must be a string or null';
|
|
835
871
|
}
|
|
872
|
+
if (typeof msg.contextId === 'string' && LONE_SURROGATE.test(msg.contextId)) {
|
|
873
|
+
return 'contextId is not encodable UTF-8 (a lone surrogate)';
|
|
874
|
+
}
|
|
875
|
+
// The ENVELOPE fields. `null` reads as ABSENT (the stripped-sig case the ladder answers
|
|
876
|
+
// -32001 for, and the shape of a walk-in on the anonymous lane); PRESENT-but-not-a-string
|
|
877
|
+
// is a malformed request and is never an absent envelope. `typeof null === 'object'` and
|
|
878
|
+
// an Array is an object too, so both are excluded explicitly.
|
|
879
|
+
const meta = msg.metadata;
|
|
880
|
+
if (meta !== undefined && meta !== null
|
|
881
|
+
&& (typeof meta !== 'object' || Array.isArray(meta))) {
|
|
882
|
+
return 'metadata must be an object';
|
|
883
|
+
}
|
|
884
|
+
for (const field of ['from', 'to', 'sig']) {
|
|
885
|
+
const v = (meta ?? {})[field];
|
|
886
|
+
if (v === undefined || v === null) continue;
|
|
887
|
+
if (typeof v !== 'string') return `metadata.${field} must be a string`;
|
|
888
|
+
if (LONE_SURROGATE.test(v)) {
|
|
889
|
+
return `metadata.${field} is not encodable UTF-8 (a lone surrogate)`;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
// LAST, because the Python reference reaches the equivalent refusal last: its shape gate
|
|
893
|
+
// runs and THEN `Message.from_a2a` raises. `kind: "message"` is what makes this an A2A
|
|
894
|
+
// Message rather than some other object that happens to carry a `parts` array, and
|
|
895
|
+
// `shared/protocol.py::from_a2a` has always required it — while this file had no check at
|
|
896
|
+
// all. Measured with a valid signature and a fresh timestamp: a message with `kind`
|
|
897
|
+
// removed was ACCEPTED here, BOOKED AN ACCOUNT and got a signed reply, where the reference
|
|
898
|
+
// answered -32600. A muretai NODE refuses the same bytes, so accepting them would also
|
|
899
|
+
// mean the entry tier and the node tier disagree about what an A2A message is.
|
|
900
|
+
if (msg.kind !== 'message') return 'not an A2A message object';
|
|
836
901
|
return null;
|
|
837
902
|
}
|
|
838
903
|
|
|
@@ -846,6 +911,61 @@ const CORS_HEADERS = {
|
|
|
846
911
|
'Access-Control-Max-Age': '600',
|
|
847
912
|
};
|
|
848
913
|
|
|
914
|
+
/** The refusal text for a request-target that is not in origin form. Shared with
|
|
915
|
+
* `ORIGIN_FORM_ONLY` in examples/agent_entry_reference.py so the two twins return the same
|
|
916
|
+
* diagnostic for the same request. */
|
|
917
|
+
const ORIGIN_FORM_ONLY = 'the request-target must be an origin-form path: this entry answers '
|
|
918
|
+
+ 'exactly the address its card names, and that address has no other spelling';
|
|
919
|
+
|
|
920
|
+
/** Transport bounds. Node bounds the first three by DEFAULT (300 s / 60 s / 5 s) and the
|
|
921
|
+
* Python reference bounded NONE, which made it the only tier a stranger could wedge with no
|
|
922
|
+
* key and no request body — 40 trickle connections took it from 2 threads to 42 with 0
|
|
923
|
+
* responses. These are the numbers BOTH twins now use, spelled here so the two files can be
|
|
924
|
+
* read against each other: `AgentEntry.HEADER_TIMEOUT` / `BODY_BUDGET` /
|
|
925
|
+
* `KEEPALIVE_TIMEOUT` / `MAX_CONNECTIONS`. */
|
|
926
|
+
const HEADERS_TIMEOUT_MS = 20_000;
|
|
927
|
+
const REQUEST_TIMEOUT_MS = 20_000;
|
|
928
|
+
const KEEPALIVE_TIMEOUT_MS = 5_000;
|
|
929
|
+
const MAX_CONNECTIONS = 64;
|
|
930
|
+
|
|
931
|
+
/** What the (MAX_CONNECTIONS+1)-th connection is answered with, byte for byte the same
|
|
932
|
+
* response `_BoundedThreadingHTTPServer` writes in the Python reference. */
|
|
933
|
+
const OVERLOADED_BODY = '{"error":"too many concurrent connections"}';
|
|
934
|
+
const OVERLOADED_RESPONSE = 'HTTP/1.1 503 Service Unavailable\r\n'
|
|
935
|
+
+ 'Content-Type: application/json; charset=utf-8\r\n'
|
|
936
|
+
+ `Content-Length: ${OVERLOADED_BODY.length}\r\n`
|
|
937
|
+
+ 'Connection: close\r\n\r\n' + OVERLOADED_BODY;
|
|
938
|
+
|
|
939
|
+
/** The stdlib's STRICT UTF-8 decoder: it THROWS on an invalid byte instead of substituting
|
|
940
|
+
* U+FFFD, which is what `Buffer.toString('utf8')` does and what let a body neither
|
|
941
|
+
* implementation could agree about reach the ladder. Node global since v11 — no dependency. */
|
|
942
|
+
const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true });
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* The JSON-RPC `id` we may ECHO, or null.
|
|
946
|
+
*
|
|
947
|
+
* JSON-RPC 2.0 says an id is a String, a Number or Null — never an object or an array — and
|
|
948
|
+
* this enforces exactly that, for a reason larger than pedantry: the id is the ONE field no
|
|
949
|
+
* signature covers and it is written straight back out, so whatever the two runtimes
|
|
950
|
+
* disagree about here becomes a disagreement about the whole response. Two measured cases,
|
|
951
|
+
* both closed by refusing the SHAPE rather than the instance:
|
|
952
|
+
* - `id = {"x":"\ud800"}`. `JSON.stringify` escapes the lone surrogate happily; Python's
|
|
953
|
+
* `p.dumps` RAISES, so the reference booked the account and then died in serialisation
|
|
954
|
+
* (HTTP 500, no reply, customer on the books) while this file answered 200 and signed.
|
|
955
|
+
* - a number outside ±2^53. `10**400` arrives here as `Infinity` and re-serialises as
|
|
956
|
+
* `null`, while Python echoes the bigint verbatim.
|
|
957
|
+
* `null` is what JSON-RPC allows for an unusable id, and it keeps the VERDICT — not the
|
|
958
|
+
* echo — as the thing the two implementations have to agree on.
|
|
959
|
+
* Mirrors `AgentEntry._safe_id` in examples/agent_entry_reference.py.
|
|
960
|
+
*/
|
|
961
|
+
function safeId(id) {
|
|
962
|
+
if (typeof id === 'string') return LONE_SURROGATE.test(id) ? null : id;
|
|
963
|
+
if (typeof id === 'number') {
|
|
964
|
+
return (Number.isFinite(id) && Math.abs(id) <= 2 ** 53) ? id : null;
|
|
965
|
+
}
|
|
966
|
+
return null;
|
|
967
|
+
}
|
|
968
|
+
|
|
849
969
|
function jsonResponse(status, obj, extraHeaders = {}) {
|
|
850
970
|
const body = Buffer.from(JSON.stringify(obj), 'utf8');
|
|
851
971
|
return {
|
|
@@ -883,12 +1003,30 @@ const HEX = new Set('0123456789abcdefABCDEF');
|
|
|
883
1003
|
const HOST_OK = new Set('abcdefghijklmnopqrstuvwxyz0123456789.-_');
|
|
884
1004
|
const DEFAULT_PORT = { http: 80, https: 443 };
|
|
885
1005
|
|
|
1006
|
+
/**
|
|
1007
|
+
* `'.'` or `'..'` if this path segment is a dot segment AS THE URL PARSERS SEE IT, else null.
|
|
1008
|
+
*
|
|
1009
|
+
* Raw `.` and `..` are the obvious spellings; `new URL()` ALSO removes `%2e`, `%2E` and every
|
|
1010
|
+
* mixture (`.%2e`, `%2e.`, `%2e%2e`), and Python's `urlsplit` removes none of them. A segment
|
|
1011
|
+
* test written against the raw text therefore lets exactly that family through: measured,
|
|
1012
|
+
* `https://shop.example/a/%2e%2e/support` started and published on the Python side, and was
|
|
1013
|
+
* refused here only by the `new URL()` tripwire at the bottom of canonicalBaseUrl — which
|
|
1014
|
+
* announced "a bug in this file, not in your input" for an input problem with a paste-able
|
|
1015
|
+
* fix. Decoding just this one escape (never the whole segment — `%41` must stay `%41`, it is
|
|
1016
|
+
* a different path) makes both implementations refuse the same family, for the right reason.
|
|
1017
|
+
*/
|
|
1018
|
+
function dotSegment(seg) {
|
|
1019
|
+
const decoded = seg.replaceAll('%2e', '.').replaceAll('%2E', '.');
|
|
1020
|
+
return (decoded === '.' || decoded === '..') ? decoded : null;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
886
1023
|
/** Raise the one refusal shape, in the order an operator can act on at 2am: what they gave
|
|
887
1024
|
* (JSON-quoted, so an invisible tab is VISIBLE), which rule in words, the fix as a string
|
|
888
|
-
* they can paste, and one clause of why.
|
|
889
|
-
* stem the Python twin shares
|
|
890
|
-
|
|
891
|
-
|
|
1025
|
+
* they can paste, and one clause of why. `<field> is not publishable:` is the greppable
|
|
1026
|
+
* stem the Python twin shares — `base_url` for the address, `domains` for the names this
|
|
1027
|
+
* entry claims to speak for, `base_path` for the mount override. */
|
|
1028
|
+
function refuseBaseUrl(given, rule, why, fix, field = 'base_url') {
|
|
1029
|
+
const lines = [`${field} is not publishable: ${rule}`, ` given: ${JSON.stringify(given)}`];
|
|
892
1030
|
if (fix) lines.push(` use: ${JSON.stringify(fix)}`);
|
|
893
1031
|
lines.push(` ${why}`);
|
|
894
1032
|
throw new TypeError(lines.join('\n'));
|
|
@@ -1045,17 +1183,19 @@ export function canonicalBaseUrl(baseUrl, { warn = true } = {}) {
|
|
|
1045
1183
|
i += 1;
|
|
1046
1184
|
}
|
|
1047
1185
|
const parts = path.split('/');
|
|
1048
|
-
if (parts.some((seg) => seg
|
|
1186
|
+
if (parts.some((seg) => dotSegment(seg) !== null)) {
|
|
1049
1187
|
const segs = []; // RFC 3986 remove_dot_segments, for the fix
|
|
1050
1188
|
for (const seg of parts) {
|
|
1051
|
-
|
|
1052
|
-
if (
|
|
1189
|
+
const dot = dotSegment(seg);
|
|
1190
|
+
if (dot === '.') continue;
|
|
1191
|
+
if (dot === '..') { if (segs.length > 1) segs.pop(); continue; }
|
|
1053
1192
|
segs.push(seg);
|
|
1054
1193
|
}
|
|
1055
|
-
refuseBaseUrl(s, "the path contains '.' or '..' segments
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1194
|
+
refuseBaseUrl(s, "the path contains '.' or '..' segments "
|
|
1195
|
+
+ "(the percent-encoded spellings '%2e' and '%2E' count).",
|
|
1196
|
+
`${scheme}://${authority}${segs.join('/').replace(/\/+$/, '')}`,
|
|
1197
|
+
'JavaScript collapses these segments — encoded ones included — and Python does not, '
|
|
1198
|
+
+ 'so the two Agent Entry implementations would sign different bytes.');
|
|
1059
1199
|
}
|
|
1060
1200
|
}
|
|
1061
1201
|
|
|
@@ -1065,14 +1205,24 @@ export function canonicalBaseUrl(baseUrl, { warn = true } = {}) {
|
|
|
1065
1205
|
// The tripwire. Not redundant with the hand-parse: it is what turns a future Node/WHATWG
|
|
1066
1206
|
// change into a loud startup failure instead of silent byte drift. If this ever fires,
|
|
1067
1207
|
// the hand-parse and the platform parser have diverged on an input the gate let through.
|
|
1208
|
+
//
|
|
1209
|
+
// It must NOT claim to know which of the two is at fault. It used to open with "a bug in
|
|
1210
|
+
// this file, not in your input" and send the operator to an issue tracker — and the input
|
|
1211
|
+
// that actually fired it was `https://shop.example/a/%2e%2e/support`, an encoded dot
|
|
1212
|
+
// segment, which is an input problem with a paste-able fix (now refused above, by name).
|
|
1213
|
+
// A tripwire sees a disagreement, not a culprit. Name both, input first.
|
|
1068
1214
|
const probe = new URL(out);
|
|
1069
1215
|
if (probe.origin !== originOut || probe.pathname !== (path || '/')
|
|
1070
1216
|
|| probe.search || probe.hash) {
|
|
1071
1217
|
throw new TypeError(
|
|
1072
|
-
`base_url
|
|
1073
|
-
+
|
|
1074
|
-
+ `
|
|
1075
|
-
+ `
|
|
1218
|
+
`base_url is not publishable: canonicalising it produces a value this runtime's URL `
|
|
1219
|
+
+ `parser reads differently.\n`
|
|
1220
|
+
+ ` given: ${JSON.stringify(baseUrl)}\n`
|
|
1221
|
+
+ ` this file canonicalises it to ${JSON.stringify(out)}, which the URL parser reads `
|
|
1222
|
+
+ `as ${JSON.stringify(probe.origin + probe.pathname)}.\n`
|
|
1223
|
+
+ ` Check the address first — a spelling this gate does not know how to fold lands `
|
|
1224
|
+
+ `here. If it is an ordinary http(s) URL with no unusual escaping, this is a bug in `
|
|
1225
|
+
+ `this file: please report it at https://github.com/muretai/agent-entry/issues`);
|
|
1076
1226
|
}
|
|
1077
1227
|
|
|
1078
1228
|
if (warn) {
|
|
@@ -1098,6 +1248,217 @@ export function canonicalBaseUrl(baseUrl, { warn = true } = {}) {
|
|
|
1098
1248
|
return out;
|
|
1099
1249
|
}
|
|
1100
1250
|
|
|
1251
|
+
// ---------------------------------------------------------------- domains + the mount
|
|
1252
|
+
|
|
1253
|
+
/** The outer whitespace BOTH languages strip identically. Python's `str.strip()` also
|
|
1254
|
+
* removes \x1c-\x1f, U+0085 and U+00A0; JavaScript's `trim()` removes a different tail of
|
|
1255
|
+
* Unicode spaces. Folding only this intersection — and refusing every other character
|
|
1256
|
+
* outside 0x21..0x7E — is what stops the twins accepting different strings for the same
|
|
1257
|
+
* operator input. Must match `_OUTER_WS` in examples/agent_entry_reference.py. */
|
|
1258
|
+
const OUTER_WS = ' \t\n\r\f\v';
|
|
1259
|
+
/** Characters that betray a URL, an authority or whitespace smuggling where a bare domain
|
|
1260
|
+
* was expected (shared/domainbind._NOT_IN_DOMAIN). */
|
|
1261
|
+
const NOT_IN_DOMAIN = ['/', '?', '#', '@', '\\', ' ', '\t', '\r', '\n', '%', '[', ']'];
|
|
1262
|
+
const LDH = new Set('abcdefghijklmnopqrstuvwxyz0123456789-');
|
|
1263
|
+
/** RFC 1035 total length of a domain name, applied to the HOST only, plus the longest
|
|
1264
|
+
* legal ":<port>" for the raw-input bound (shared/domainbind.MAX_DOMAIN_LEN). */
|
|
1265
|
+
const MAX_DOMAIN_LEN = 253;
|
|
1266
|
+
|
|
1267
|
+
/** Exported because the RUNNER needs the same fold: `examples/agent_entry_server.mjs` decides
|
|
1268
|
+
* whether `AGENT_ENTRY_DOMAINS` is blank at all, and it used `trim()`. That is a different
|
|
1269
|
+
* set from Python's `strip()` in BOTH directions, so one variable got two verdicts — a
|
|
1270
|
+
* `\x1c` started the Python runner with no domains and made this one exit 2, and a BOM
|
|
1271
|
+
* (what a paste out of a spreadsheet or a Windows `.env` carries) did the reverse. */
|
|
1272
|
+
export function trimOuter(s) {
|
|
1273
|
+
let a = 0;
|
|
1274
|
+
let b = s.length;
|
|
1275
|
+
while (a < b && OUTER_WS.includes(s[a])) a += 1;
|
|
1276
|
+
while (b > a && OUTER_WS.includes(s[b - 1])) b -= 1;
|
|
1277
|
+
return s.slice(a, b);
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* The JS twin of `shared/domainbind.valid_domain` — the ONE definition of "is this a bare
|
|
1282
|
+
* domain" in this system, and therefore the one both halves of a domain binding must agree
|
|
1283
|
+
* on. Total on untrusted input; never throws.
|
|
1284
|
+
*
|
|
1285
|
+
* A domain here is not a URL: ASCII LDH labels only (a-z, 0-9, '-'), LOWERCASE (case is
|
|
1286
|
+
* folded by the caller, visibly, because `valid_domain` REJECTS an uppercase spelling
|
|
1287
|
+
* rather than folding it), 1..63 characters per label, no leading or trailing '-', at
|
|
1288
|
+
* least two labels (a single-label name has no owner a verifier could hold responsible),
|
|
1289
|
+
* host <= 253 with no trailing dot, and an optional ':<port>' 1..65535 with no leading
|
|
1290
|
+
* zero. Ports exist only because a loopback or staging box cannot use 443.
|
|
1291
|
+
*
|
|
1292
|
+
* Re-implemented rather than imported for the same reason everything else in this file is:
|
|
1293
|
+
* a site copies ONE file. `test_agent_entry_contract.py` is what holds the two spellings to
|
|
1294
|
+
* the same verdicts.
|
|
1295
|
+
*/
|
|
1296
|
+
function validBareDomain(domain) {
|
|
1297
|
+
if (typeof domain !== 'string' || !domain || domain.length > MAX_DOMAIN_LEN + 6) {
|
|
1298
|
+
return false;
|
|
1299
|
+
}
|
|
1300
|
+
if (NOT_IN_DOMAIN.some((ch) => domain.includes(ch))) return false;
|
|
1301
|
+
for (const ch of domain) if (ch.codePointAt(0) > 0x7f) return false; // IDN U-labels
|
|
1302
|
+
let host = domain;
|
|
1303
|
+
const colon = domain.indexOf(':'); // the FIRST one: `str.partition` semantics
|
|
1304
|
+
if (colon >= 0) {
|
|
1305
|
+
host = domain.slice(0, colon);
|
|
1306
|
+
const portS = domain.slice(colon + 1); // a second ':' leaves a non-numeric tail
|
|
1307
|
+
if (!/^[0-9]+$/.test(portS)) return false;
|
|
1308
|
+
if (portS.length > 1 && portS.startsWith('0')) return false;
|
|
1309
|
+
const port = Number(portS);
|
|
1310
|
+
if (port < 1 || port > 65535) return false;
|
|
1311
|
+
}
|
|
1312
|
+
if (!host || host.length > MAX_DOMAIN_LEN || host.endsWith('.')) return false;
|
|
1313
|
+
const labels = host.split('.');
|
|
1314
|
+
if (labels.length < 2) return false;
|
|
1315
|
+
for (const label of labels) {
|
|
1316
|
+
if (label.length < 1 || label.length > 63) return false;
|
|
1317
|
+
if (label.startsWith('-') || label.endsWith('-')) return false;
|
|
1318
|
+
for (const ch of label) if (!LDH.has(ch)) return false;
|
|
1319
|
+
}
|
|
1320
|
+
return true;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/** A pasteable repair for a domain we refused, or undefined when we cannot guess one.
|
|
1324
|
+
* Only ever suggests something `validBareDomain` accepts, so a wrong guess produces no
|
|
1325
|
+
* suggestion rather than a second bad value to paste. */
|
|
1326
|
+
function domainFix(candidate) {
|
|
1327
|
+
try {
|
|
1328
|
+
let guess = trimOuter(String(candidate)).toLowerCase();
|
|
1329
|
+
guess = guess.includes('://') ? guess.slice(guess.indexOf('://') + 3) : guess;
|
|
1330
|
+
for (const cut of ['/', '?', '#']) {
|
|
1331
|
+
const at = guess.indexOf(cut);
|
|
1332
|
+
if (at >= 0) guess = guess.slice(0, at);
|
|
1333
|
+
}
|
|
1334
|
+
if (guess.includes('@')) guess = guess.slice(guess.lastIndexOf('@') + 1);
|
|
1335
|
+
guess = guess.replace(/\.+$/, '');
|
|
1336
|
+
return (guess && guess !== candidate && validBareDomain(guess)) ? guess : undefined;
|
|
1337
|
+
} catch {
|
|
1338
|
+
return undefined;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/**
|
|
1343
|
+
* canonicalDomains(domains) -> the exact list this entry may publish as its card `domains`.
|
|
1344
|
+
*
|
|
1345
|
+
* WHAT IT IS FOR. A domain binding is BILATERAL and neither half is worth anything alone
|
|
1346
|
+
* (shared/domainbind.py, agent/domainverify.py): the DOMAIN publishes a credential naming
|
|
1347
|
+
* this DID at /.well-known/did-configuration.json, and the AGENT's own live card names the
|
|
1348
|
+
* domain back. This list is that second half. Without it a verifier holding the domain's
|
|
1349
|
+
* file answers `card-withdrawn` — the domain vouches for an agent that does not claim the
|
|
1350
|
+
* domain — so an entry with no `domains` can never be proven to belong to the site it is
|
|
1351
|
+
* serving from. Because the halves are written by different parties, EITHER can end the
|
|
1352
|
+
* binding alone: the domain owner deletes a line from the file, or the entry drops the name.
|
|
1353
|
+
*
|
|
1354
|
+
* Absent or empty -> `[]`, and the card then carries NO `domains` key at all, so an entry
|
|
1355
|
+
* from before this option existed publishes byte-identical bytes and nobody has to
|
|
1356
|
+
* re-publish or re-sign anything.
|
|
1357
|
+
*
|
|
1358
|
+
* Anything else is REFUSED at construction, loudly, exactly like `canonicalBaseUrl`: a
|
|
1359
|
+
* name the credential can never bind is not something to warn about and publish anyway.
|
|
1360
|
+
* The rule is `validBareDomain` — deliberately the same predicate core uses, because a
|
|
1361
|
+
* second opinion here produces an entry that starts happily and can never verify.
|
|
1362
|
+
* `strip().lower()` is the ONLY canonicalization, matching agent/domainverify._norm_domain.
|
|
1363
|
+
*
|
|
1364
|
+
* Names are de-duplicated (operator order kept), and MORE THAN `MAX_CARD_DOMAINS` distinct
|
|
1365
|
+
* names is a REFUSAL, not a truncation — even though `build_agent_card` truncates at the
|
|
1366
|
+
* same 5. That function renders a card for many callers at runtime and must not blow up
|
|
1367
|
+
* mid-render; this one validates an argument an operator just typed, and it already
|
|
1368
|
+
* refuses every other bad value there. Truncating would start the entry with a claim that
|
|
1369
|
+
* is USABLE and NOT WHAT THEY SAID.
|
|
1370
|
+
*/
|
|
1371
|
+
export function canonicalDomains(domains, { warn = true } = {}) {
|
|
1372
|
+
if (domains === undefined || domains === null) return [];
|
|
1373
|
+
if (!Array.isArray(domains)) {
|
|
1374
|
+
refuseBaseUrl(domains, 'it is not a list of domain names.',
|
|
1375
|
+
'Pass an array, e.g. ["example.com"] — a single string is refused rather than '
|
|
1376
|
+
+ 'wrapped, so this file and its Python twin cannot disagree about what was meant.',
|
|
1377
|
+
undefined, 'domains');
|
|
1378
|
+
}
|
|
1379
|
+
const out = [];
|
|
1380
|
+
for (const entry of domains) {
|
|
1381
|
+
if (typeof entry !== 'string') {
|
|
1382
|
+
refuseBaseUrl(entry, 'it is not a string.', 'A domain is a name, e.g. "example.com".',
|
|
1383
|
+
undefined, 'domains');
|
|
1384
|
+
}
|
|
1385
|
+
const s = trimOuter(entry);
|
|
1386
|
+
for (const ch of s) {
|
|
1387
|
+
const c = ch.codePointAt(0);
|
|
1388
|
+
if (c < 0x21 || c > 0x7e) {
|
|
1389
|
+
refuseBaseUrl(entry,
|
|
1390
|
+
'it contains whitespace, a control character or a non-ASCII character.',
|
|
1391
|
+
'A domain here is compared byte for byte against the origin in the credential '
|
|
1392
|
+
+ 'the domain itself serves, so an internationalized name must be given in its '
|
|
1393
|
+
+ 'punycode (xn--…) A-label form and nothing else may travel with it.',
|
|
1394
|
+
domainFix(s), 'domains');
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
const lowered = s.toLowerCase();
|
|
1398
|
+
if (!validBareDomain(lowered)) {
|
|
1399
|
+
refuseBaseUrl(entry, 'it is not a bare domain name.',
|
|
1400
|
+
"Give the HOST only: ASCII letters, digits and '-', at least two labels (each "
|
|
1401
|
+
+ "1-63 characters, not starting or ending with '-'), at most 253 characters, "
|
|
1402
|
+
+ "optionally ':<port>' 1-65535 — no scheme, no path, no query, no '@', no "
|
|
1403
|
+
+ 'trailing dot. The domain\'s own credential binds https://<this exact string>, '
|
|
1404
|
+
+ 'so anything else can never match it.',
|
|
1405
|
+
domainFix(lowered), 'domains');
|
|
1406
|
+
}
|
|
1407
|
+
if (!out.includes(lowered)) out.push(lowered);
|
|
1408
|
+
}
|
|
1409
|
+
if (out.length > MAX_CARD_DOMAINS) {
|
|
1410
|
+
refuseBaseUrl(domains,
|
|
1411
|
+
`it names ${out.length} distinct domains, more than the ${MAX_CARD_DOMAINS} a card `
|
|
1412
|
+
+ 'may advertise.',
|
|
1413
|
+
'Every name listed is an outbound HTTPS fetch this entry asks strangers to make, so '
|
|
1414
|
+
+ `a card carries at most ${MAX_CARD_DOMAINS}. Publishing the first `
|
|
1415
|
+
+ `${MAX_CARD_DOMAINS} and dropping the rest would start this entry with a claim `
|
|
1416
|
+
+ 'that is usable and NOT what you said: the names that vanished fail for whoever '
|
|
1417
|
+
+ 'verifies them and nothing anywhere says why. Drop names, or run a second entry '
|
|
1418
|
+
+ '(its own key) for the rest.',
|
|
1419
|
+
undefined, 'domains');
|
|
1420
|
+
}
|
|
1421
|
+
return out;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/**
|
|
1425
|
+
* canonicalMount(canonUrl, basePath) -> the path prefix this entry ANSWERS at. `''` for a
|
|
1426
|
+
* bare origin; otherwise `'/support'`-shaped, taken from the already-canonicalised url.
|
|
1427
|
+
*
|
|
1428
|
+
* WHY IT IS DERIVED AND NOT CONFIGURED. A mount the operator spells separately from
|
|
1429
|
+
* `baseUrl` is a second place to write the same fact, and the failure it produces is the
|
|
1430
|
+
* worst one this system has: the entry answers at one path while its signed card claims
|
|
1431
|
+
* another, so every visitor fails `Outbox.card_binds_to` and the only diagnostic anyone
|
|
1432
|
+
* gets is "cannot prove that … owns …". Deriving it makes the router's mount and the
|
|
1433
|
+
* card's advertised address THE SAME STRING by construction. (Measured before this
|
|
1434
|
+
* existed: an entry given `baseUrl: 'http://h:p/support'` printed that address, signed
|
|
1435
|
+
* `/support` into its card, and then answered the BARE HOST — three answers to one
|
|
1436
|
+
* question.)
|
|
1437
|
+
*
|
|
1438
|
+
* THE ONE OVERRIDE. A reverse proxy that STRIPS the prefix hands this process `/…` while
|
|
1439
|
+
* the public address is still `https://h/support`. That deployment is real, so `basePath:
|
|
1440
|
+
* ''` is allowed — but ONLY `''` or exactly the canonical url's own path. Any other value
|
|
1441
|
+
* would be a third spelling of the address, which is what this function exists to prevent.
|
|
1442
|
+
*/
|
|
1443
|
+
export function canonicalMount(canonUrl, basePath) {
|
|
1444
|
+
const path = new URL(canonUrl).pathname.replace(/\/+$/, '');
|
|
1445
|
+
if (basePath === undefined || basePath === null) return path;
|
|
1446
|
+
if (typeof basePath !== 'string') {
|
|
1447
|
+
refuseBaseUrl(basePath, 'it is not a string.',
|
|
1448
|
+
'Pass "" (a proxy that strips the prefix) or the same path as baseUrl.',
|
|
1449
|
+
undefined, 'base_path');
|
|
1450
|
+
}
|
|
1451
|
+
const given = trimOuter(basePath).replace(/\/+$/, '');
|
|
1452
|
+
if (given !== '' && given !== path) {
|
|
1453
|
+
refuseBaseUrl(basePath, 'it is neither empty nor the path baseUrl already names.',
|
|
1454
|
+
`This entry publishes ${JSON.stringify(canonUrl)}, so a visitor dials `
|
|
1455
|
+
+ `${JSON.stringify(path || '/')} and nothing else. Use "" only when a proxy strips `
|
|
1456
|
+
+ 'the prefix before the request reaches this process.',
|
|
1457
|
+
path || '', 'base_path');
|
|
1458
|
+
}
|
|
1459
|
+
return given;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1101
1462
|
/**
|
|
1102
1463
|
* createAgentEntry(opts) -> { did, card, ledger, handleRequest, handleRequestAsync, listen }
|
|
1103
1464
|
*
|
|
@@ -1107,6 +1468,15 @@ export function canonicalBaseUrl(baseUrl, { warn = true } = {}) {
|
|
|
1107
1468
|
* REQUIRES card.url to name the origin+path it dialled (Outbox.card_binds_to)
|
|
1108
1469
|
* — that binding is what stops an attacker re-serving your signed card at
|
|
1109
1470
|
* their own host. Get it wrong and Path A verification fails, silently.
|
|
1471
|
+
* It MAY carry a path (`https://example.com/support`): every route then
|
|
1472
|
+
* hangs off that path and the bare host is 404, so one hostname holds a
|
|
1473
|
+
* front desk, support and sales as three agents with three keys.
|
|
1474
|
+
* domains the bare domains this entry claims to speak for, e.g. ['example.com']
|
|
1475
|
+
* (default none, and then the card carries no `domains` key at all). A
|
|
1476
|
+
* CLAIM, never evidence: the proof is the credential the DOMAIN serves at
|
|
1477
|
+
* /.well-known/did-configuration.json, and a verifier requires both halves.
|
|
1478
|
+
* basePath ONLY for a proxy that strips the prefix: '' or exactly baseUrl's path.
|
|
1479
|
+
* See canonicalMount for why this is not a general knob.
|
|
1110
1480
|
* responder (envelope) => string | {text, contextId?, timestamp?} | Promise<…>
|
|
1111
1481
|
* openDoor advertise `muretai.open_door` (default true) — the flag that tells a
|
|
1112
1482
|
* visiting agent it may contact you without an introduction.
|
|
@@ -1128,6 +1498,8 @@ export function createAgentEntry({
|
|
|
1128
1498
|
anonymousLane = false,
|
|
1129
1499
|
anonRatePerMin = ANON_RATE_PER_MIN,
|
|
1130
1500
|
skills = [],
|
|
1501
|
+
domains = null,
|
|
1502
|
+
basePath = null,
|
|
1131
1503
|
maxAccounts = 50000,
|
|
1132
1504
|
} = {}) {
|
|
1133
1505
|
if (!seedHex) throw new TypeError('createAgentEntry: seedHex is required');
|
|
@@ -1136,6 +1508,13 @@ export function createAgentEntry({
|
|
|
1136
1508
|
// equal what a visitor's Outbox.card_scope computes for the url they dialled, or the card
|
|
1137
1509
|
// fails verification on THEIR machine with nothing on ours.
|
|
1138
1510
|
const canonUrl = canonicalBaseUrl(baseUrl);
|
|
1511
|
+
// The path prefix this entry ANSWERS at, DERIVED from that same string so the router and
|
|
1512
|
+
// the signed card cannot disagree. '' for a bare origin — every route below is then the
|
|
1513
|
+
// byte-identical string this module always matched.
|
|
1514
|
+
const mount = canonicalMount(canonUrl, basePath);
|
|
1515
|
+
// The agent half of a T88 domain binding. Refuses to start on anything that is not a
|
|
1516
|
+
// bare domain: a name the domain's credential can never bind is not worth publishing.
|
|
1517
|
+
const canonDomains = canonicalDomains(domains);
|
|
1139
1518
|
const did = didFromSeedHex(seedHex);
|
|
1140
1519
|
|
|
1141
1520
|
const card = {
|
|
@@ -1150,6 +1529,11 @@ export function createAgentEntry({
|
|
|
1150
1529
|
defaultOutputModes: ['text/plain'],
|
|
1151
1530
|
skills,
|
|
1152
1531
|
};
|
|
1532
|
+
// T88, the REVERSE EDGE only, in the same top-level field and the same position
|
|
1533
|
+
// shared/protocol.build_agent_card uses, so one verifier rule reads a node's card and an
|
|
1534
|
+
// entry's card. Omitted entirely when no domain was named — that is what keeps an
|
|
1535
|
+
// already-deployed entry's published bytes unchanged.
|
|
1536
|
+
if (canonDomains.length) card.domains = canonDomains;
|
|
1153
1537
|
if (openDoor) card.muretai = { open_door: true };
|
|
1154
1538
|
// Deliberately NO `relay`/`enc_pub` on the card: those advertise a store-and-forward
|
|
1155
1539
|
// mailbox, and an agent entry has no listener draining one. Advertising a mailbox nobody
|
|
@@ -1337,15 +1721,33 @@ export function createAgentEntry({
|
|
|
1337
1721
|
* size checks come BEFORE any parsing or crypto — a check placed after the signature is
|
|
1338
1722
|
* a check the attacker simply skips.
|
|
1339
1723
|
*/
|
|
1340
|
-
function handlePost(
|
|
1724
|
+
function handlePost(rawBody) {
|
|
1725
|
+
// RAW BYTES, always. A host app that hands us a decoded string has already destroyed
|
|
1726
|
+
// the evidence the strict decode below exists to find, so normalise once and measure
|
|
1727
|
+
// the SIZE in bytes rather than in UTF-16 code units.
|
|
1728
|
+
const bodyBuffer = Buffer.isBuffer(rawBody) ? rawBody
|
|
1729
|
+
: (ArrayBuffer.isView(rawBody)
|
|
1730
|
+
? Buffer.from(rawBody.buffer, rawBody.byteOffset, rawBody.byteLength)
|
|
1731
|
+
: Buffer.from(String(rawBody ?? ''), 'utf8'));
|
|
1341
1732
|
// 1. body over 1 MiB — refused WITHOUT parsing.
|
|
1342
1733
|
if (bodyBuffer.length > MAX_BODY_BYTES) {
|
|
1343
1734
|
return jsonResponse(413, { error: 'request body too large' });
|
|
1344
1735
|
}
|
|
1345
1736
|
// 2. unparseable or non-object JSON — a transport-level refusal, not a JSON-RPC one.
|
|
1737
|
+
//
|
|
1738
|
+
// STRICT UTF-8, and `Buffer.toString('utf8')` — what this used to be — is why:
|
|
1739
|
+
// it SILENTLY SUBSTITUTES U+FFFD for every invalid byte and parses on, where
|
|
1740
|
+
// `shared/protocol.loads` does `raw.decode("utf-8")` and raises. Measured with a valid
|
|
1741
|
+
// signature over the six frozen fields and a single raw 0xFF byte in the JSON-RPC `id`
|
|
1742
|
+
// (a field no signature covers): the Python reference answered HTTP 400 and booked
|
|
1743
|
+
// nothing, this file answered 200, CREATED THE ACCOUNT and returned a signed reply.
|
|
1744
|
+
// The class is larger than the id — a substituted byte anywhere means the bytes we
|
|
1745
|
+
// verified are not the bytes that arrived, which is precisely the thing a signature is
|
|
1746
|
+
// supposed to make impossible to be wrong about. `TextDecoder` with `fatal` is the
|
|
1747
|
+
// stdlib's strict decoder (global since Node 11); no dependency is added.
|
|
1346
1748
|
let req;
|
|
1347
1749
|
try {
|
|
1348
|
-
req = JSON.parse(
|
|
1750
|
+
req = JSON.parse(STRICT_UTF8.decode(bodyBuffer));
|
|
1349
1751
|
} catch {
|
|
1350
1752
|
return jsonResponse(400, { error: 'malformed JSON' });
|
|
1351
1753
|
}
|
|
@@ -1353,14 +1755,26 @@ export function createAgentEntry({
|
|
|
1353
1755
|
return jsonResponse(400, { error: 'JSON-RPC request must be an object' });
|
|
1354
1756
|
}
|
|
1355
1757
|
// 3. From here every refusal is HTTP 200 with a JSON-RPC error object.
|
|
1356
|
-
const reqId = req.id
|
|
1357
|
-
|
|
1758
|
+
const reqId = safeId(req.id);
|
|
1759
|
+
// STRICT, and the `typeof` guard this replaces is why: it short-circuited, so a
|
|
1760
|
+
// NON-STRING `method` skipped the check entirely and fell into the message ladder.
|
|
1761
|
+
// Measured with a valid signature and a fresh timestamp — `"method": null`, `1` and
|
|
1762
|
+
// `{}` each returned a SIGNED REPLY and CREATED AN ACCOUNT here, while the Python
|
|
1763
|
+
// reference answered -32601 for all three. That is the double-book class this file's
|
|
1764
|
+
// `wireShapeError` docstring is about, one field above where it was looking. An
|
|
1765
|
+
// absent method lands here too, which is also -32601 on the Python side.
|
|
1766
|
+
if (req.method !== 'message/send') {
|
|
1358
1767
|
return rpcError(reqId, ERRORS.METHOD_NOT_FOUND,
|
|
1359
|
-
|
|
1768
|
+
'an agent entry implements message/send only');
|
|
1360
1769
|
}
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1770
|
+
// A plain OBJECT, never an Array: `typeof [] === 'object'`, so an array `message` used
|
|
1771
|
+
// to reach the shape gate and be refused -32600 where Python answered -32602.
|
|
1772
|
+
const params = (req.params && typeof req.params === 'object' && !Array.isArray(req.params))
|
|
1773
|
+
? req.params : null;
|
|
1774
|
+
const msg = params ? params.message : null;
|
|
1775
|
+
if (!msg || typeof msg !== 'object' || Array.isArray(msg)) {
|
|
1776
|
+
return rpcError(reqId, ERRORS.INVALID_PARAMS,
|
|
1777
|
+
'params.message must be an A2A message object');
|
|
1364
1778
|
}
|
|
1365
1779
|
// 3a. wrongly-TYPED wire fields, on the raw object and before anything measures or
|
|
1366
1780
|
// hashes it. These are the fields that end up inside a signed payload — theirs and,
|
|
@@ -1369,6 +1783,9 @@ export function createAgentEntry({
|
|
|
1369
1783
|
const shape = wireShapeError(msg);
|
|
1370
1784
|
if (shape !== null) return rpcError(reqId, ERRORS.INVALID_REQUEST, shape);
|
|
1371
1785
|
|
|
1786
|
+
// Absent metadata reads as an empty envelope — the walk-in shape. A PRESENT one that
|
|
1787
|
+
// is not an object never gets here: the shape gate above refused it (-32600), so this
|
|
1788
|
+
// fallback can no longer turn a malformed envelope into an anonymous one.
|
|
1372
1789
|
const meta = (msg.metadata && typeof msg.metadata === 'object') ? msg.metadata : {};
|
|
1373
1790
|
const text = messageText(msg);
|
|
1374
1791
|
|
|
@@ -1477,22 +1894,51 @@ export function createAgentEntry({
|
|
|
1477
1894
|
return finishReply(reqId, answer, { inbound, toDid });
|
|
1478
1895
|
}
|
|
1479
1896
|
|
|
1897
|
+
/** Is this the message endpoint — the base the card's `url` names? EXACT, because a
|
|
1898
|
+
* wandering endpoint is not this contract: with a bare origin that is `/` and nothing
|
|
1899
|
+
* else (byte-for-byte what this module always accepted), and with a mount it is
|
|
1900
|
+
* `/support` plus its trailing-slash spelling, since `card_scope` folds the two and a
|
|
1901
|
+
* visitor may legitimately have been handed either. */
|
|
1902
|
+
function isMountPath(pathname) {
|
|
1903
|
+
if (!mount) return pathname === '/';
|
|
1904
|
+
return pathname === mount || pathname === `${mount}/`;
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1480
1907
|
function route(method, path, bodyBuffer) {
|
|
1481
|
-
const
|
|
1908
|
+
const target = String(path || '/');
|
|
1909
|
+
// ORIGIN FORM ONLY, and SAY SO. HTTP/1.1 lets a client write the request-target in
|
|
1910
|
+
// absolute form (`POST http://elsewhere.example/support HTTP/1.1`) and RFC 9112 §3.2.2
|
|
1911
|
+
// says a server MUST accept it; this contract deliberately does not, because this entry
|
|
1912
|
+
// answers exactly the address its card names and that address has no other spelling.
|
|
1913
|
+
// A refusal that is legal-per-RFC to make and illegal-per-RFC to make silently is
|
|
1914
|
+
// exactly the one that has to carry a diagnostic: `{"error":"not found"}` for a target
|
|
1915
|
+
// an integrator believes is correct costs an afternoon and teaches nothing.
|
|
1916
|
+
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(target) || target.startsWith('//')) {
|
|
1917
|
+
return jsonResponse(404, { error: 'not found', detail: ORIGIN_FORM_ONLY });
|
|
1918
|
+
}
|
|
1919
|
+
const pathname = target.split('?')[0].split('#')[0];
|
|
1920
|
+
// EVERY route hangs off the mount — the path the signed card already claims. A visitor
|
|
1921
|
+
// builds the same strings (Outbox's fetcher keeps the base's path prefix when it
|
|
1922
|
+
// appends a well-known path), so this is not a new convention; it is the one the
|
|
1923
|
+
// fetcher already follows. With mount === '' these are the original constants.
|
|
1482
1924
|
if (method === 'GET' || method === 'HEAD') {
|
|
1483
|
-
if (pathname === AGENT_CARD_PATH || pathname === AGENT_CARD_PATH_LEGACY) {
|
|
1925
|
+
if (pathname === mount + AGENT_CARD_PATH || pathname === mount + AGENT_CARD_PATH_LEGACY) {
|
|
1484
1926
|
// Byte-identical on both paths: the current A2A path and the legacy alias.
|
|
1485
1927
|
return { status: 200, headers: cardHeaders(cardBytes.length), body: cardBytes };
|
|
1486
1928
|
}
|
|
1487
|
-
if (pathname === AGENT_CARD_SIG_PATH) {
|
|
1929
|
+
if (pathname === mount + AGENT_CARD_SIG_PATH) {
|
|
1488
1930
|
const env = cardEnvelopeBytes();
|
|
1489
1931
|
return { status: 200, headers: cardHeaders(env.length), body: env };
|
|
1490
1932
|
}
|
|
1491
|
-
if (pathname
|
|
1933
|
+
if (isMountPath(pathname)) {
|
|
1492
1934
|
const body = Buffer.from(
|
|
1493
|
-
|
|
1935
|
+
// "This ADDRESS", not "this origin": once an entry can be mounted under a
|
|
1936
|
+
// path, the origin may hold several agents and this notice speaks for exactly
|
|
1937
|
+
// one of them. A bare-origin entry reads the same either way.
|
|
1938
|
+
`${name}\n\nThis address is agent-reachable (Muretai agent entry).\n`
|
|
1494
1939
|
+ `DID: ${did}\nCard: ${canonUrl}${AGENT_CARD_PATH}\n`
|
|
1495
|
-
+
|
|
1940
|
+
+ `POST a signed A2A message/send request to ${mount || '/'} for a signed reply.\n`,
|
|
1941
|
+
'utf8');
|
|
1496
1942
|
return { status: 200,
|
|
1497
1943
|
headers: { 'Content-Type': 'text/plain; charset=utf-8',
|
|
1498
1944
|
'Content-Length': String(body.length) },
|
|
@@ -1501,8 +1947,10 @@ export function createAgentEntry({
|
|
|
1501
1947
|
return jsonResponse(404, { error: 'not found' });
|
|
1502
1948
|
}
|
|
1503
1949
|
if (method === 'POST') {
|
|
1504
|
-
// EXACTLY the
|
|
1505
|
-
|
|
1950
|
+
// EXACTLY the address the card names. A POST anywhere else is not this contract —
|
|
1951
|
+
// and when this entry is mounted under a path, "anywhere else" INCLUDES the bare
|
|
1952
|
+
// host, which belongs to the site (or to the neighbour agent) and not to us.
|
|
1953
|
+
if (!isMountPath(pathname)) return jsonResponse(404, { error: 'not found' });
|
|
1506
1954
|
return handlePost(bodyBuffer || Buffer.alloc(0));
|
|
1507
1955
|
}
|
|
1508
1956
|
if (method === 'OPTIONS') {
|
|
@@ -1546,7 +1994,17 @@ export function createAgentEntry({
|
|
|
1546
1994
|
* (behind a TLS terminator) to go public.
|
|
1547
1995
|
*/
|
|
1548
1996
|
function listen(port = 8788, host = '127.0.0.1', onReady) {
|
|
1549
|
-
const server = createServer(
|
|
1997
|
+
const server = createServer({
|
|
1998
|
+
// Node checks its request/headers timeouts on an interval, not on a per-connection
|
|
1999
|
+
// timer, and the DEFAULT interval is 30 s — so a 20 s bound measured on the wire fires
|
|
2000
|
+
// somewhere between 20 s and 50 s. Tightening the interval is what makes the number
|
|
2001
|
+
// above the number a stranger actually observes; measured against the trickle, this
|
|
2002
|
+
// takes the 408 from ~58 s to ~22 s, next to the Python twin's ~20.6 s.
|
|
2003
|
+
connectionsCheckingInterval: 2_000,
|
|
2004
|
+
headersTimeout: HEADERS_TIMEOUT_MS,
|
|
2005
|
+
requestTimeout: REQUEST_TIMEOUT_MS,
|
|
2006
|
+
keepAliveTimeout: KEEPALIVE_TIMEOUT_MS,
|
|
2007
|
+
}, (req, res) => {
|
|
1550
2008
|
const chunks = [];
|
|
1551
2009
|
let total = 0;
|
|
1552
2010
|
let oversize = false;
|
|
@@ -1579,11 +2037,33 @@ export function createAgentEntry({
|
|
|
1579
2037
|
.catch(() => { try { res.destroy(); } catch { /* already gone */ } });
|
|
1580
2038
|
});
|
|
1581
2039
|
});
|
|
2040
|
+
// Also as properties, for a Node old enough to ignore the options above. Explicit
|
|
2041
|
+
// rather than inherited: Node's defaults are generous (300 s / 60 s / 5 s) and unstated,
|
|
2042
|
+
// and the Python twin has to spell the same numbers out anyway. Writing them in both
|
|
2043
|
+
// files is what makes "the two entries bound a stranger identically" a fact a reader can
|
|
2044
|
+
// check instead of a claim.
|
|
2045
|
+
server.headersTimeout = HEADERS_TIMEOUT_MS;
|
|
2046
|
+
server.requestTimeout = REQUEST_TIMEOUT_MS;
|
|
2047
|
+
server.keepAliveTimeout = KEEPALIVE_TIMEOUT_MS;
|
|
2048
|
+
// The CONNECTION CEILING. There is no Node default, and the timeouts above do not
|
|
2049
|
+
// supply one: they bound how LONG each connection lives, not how MANY exist at once.
|
|
2050
|
+
// Written by hand rather than with `server.maxConnections`, which DESTROYS the socket
|
|
2051
|
+
// silently — an unauthenticated stranger always gets an HTTP response here (the promise
|
|
2052
|
+
// in docs/AGENT_ENTRY.md), an operator behind a proxy gets a 503 in the log instead of
|
|
2053
|
+
// "upstream closed the connection", and the Python twin can say the same sentence.
|
|
2054
|
+
let live = 0;
|
|
2055
|
+
server.on('connection', (socket) => {
|
|
2056
|
+
live += 1;
|
|
2057
|
+
socket.once('close', () => { live -= 1; });
|
|
2058
|
+
if (live > MAX_CONNECTIONS) socket.end(OVERLOADED_RESPONSE);
|
|
2059
|
+
});
|
|
1582
2060
|
server.listen(port, host, () => { if (onReady) onReady(server); });
|
|
1583
2061
|
return server;
|
|
1584
2062
|
}
|
|
1585
2063
|
|
|
1586
|
-
|
|
2064
|
+
// `mount` is exported so a host app can route exactly what this entry answers (and log
|
|
2065
|
+
// it): it is derived, so reading it here can never disagree with the signed card.
|
|
2066
|
+
return { did, card, ledger, mount, handleRequest, handleRequestAsync, listen,
|
|
1587
2067
|
cardEnvelope: () => JSON.parse(cardEnvelopeBytes().toString('utf8')) };
|
|
1588
2068
|
}
|
|
1589
2069
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muretai/agent-entry",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Your website can recognise AI agents, hold accounts for them, and answer them
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Your website can recognise AI agents, hold accounts for them, and answer them — one dependency-free file.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "muretai-agent-entry.mjs",
|
|
7
7
|
"exports": {
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
"ed25519",
|
|
28
28
|
"muretai",
|
|
29
29
|
"agent-network",
|
|
30
|
-
"llms-txt"
|
|
30
|
+
"llms-txt",
|
|
31
|
+
"webmcp"
|
|
31
32
|
],
|
|
32
33
|
"license": "MIT",
|
|
33
34
|
"author": "Muretai",
|