@muretai/agent-entry 1.0.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 +176 -5
- package/examples/server.mjs +56 -15
- package/muretai-agent-entry.mjs +739 -20
- package/package.json +3 -2
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
|
|
|
@@ -144,9 +147,124 @@ your site's identity**), `AGENT_ENTRY_PORT` (8788), `AGENT_ENTRY_BASE_URL`,
|
|
|
144
147
|
`AGENT_ENTRY_NAME`, `AGENT_ENTRY_ANON` (`1` also accepts unsigned inquiries, which create
|
|
145
148
|
no account).
|
|
146
149
|
|
|
150
|
+
## What `baseUrl` may be
|
|
151
|
+
|
|
147
152
|
`baseUrl` must be the URL visitors actually dial: it is what your signed card claims, and
|
|
148
153
|
a card naming a different origin proves nothing about yours.
|
|
149
154
|
|
|
155
|
+
Agent Entry does not copy it into the card — it canonicalises it, so the string it signs is
|
|
156
|
+
the one a visitor computes from the URL they dialled. Where the two could differ, **it
|
|
157
|
+
refuses to start**, naming the rule and the value to paste instead. That is deliberate: the
|
|
158
|
+
alternative is a card that fails on a stranger's machine, where the only diagnostic is
|
|
159
|
+
"signature verification failed" and nothing at all appears on yours.
|
|
160
|
+
|
|
161
|
+
Tidied up for you: surrounding spaces, the case of the scheme and host, a default port
|
|
162
|
+
(`:443`, `:80`), a trailing dot on the host, and any trailing slashes.
|
|
163
|
+
`https://studio.example/` and `https://Studio.Example:443` both publish as
|
|
164
|
+
`https://studio.example`.
|
|
165
|
+
|
|
166
|
+
Refused, with the fix in the message: a scheme other than `http`/`https`, a missing host,
|
|
167
|
+
`user@host`, a query string, a `#` fragment, non-ASCII characters, a stray tab or space, a
|
|
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.
|
|
177
|
+
|
|
178
|
+
Two rules worth knowing before you pick a URL:
|
|
179
|
+
|
|
180
|
+
- **Paths are case-sensitive.** `https://studio.example/Alice` and `.../alice` are different
|
|
181
|
+
sites to a visitor. Choose one spelling and use it in every link, invite and QR code.
|
|
182
|
+
- **Write an international domain in its `xn--` form** — `https://xn--eckwd4c7c.example`,
|
|
183
|
+
not the Unicode spelling — and publish your links in that same form. JavaScript's URL
|
|
184
|
+
parser punycodes a host and Python's does not, so the two implementations would otherwise
|
|
185
|
+
sign different bytes for the same site.
|
|
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
|
+
|
|
150
268
|
## Pairs with WebMCP: the tab conversation becomes a customer
|
|
151
269
|
|
|
152
270
|
If your page already exposes [WebMCP](https://github.com/MiguelsPizza/WebMCP) tools, you have
|
|
@@ -205,18 +323,62 @@ the source:
|
|
|
205
323
|
seconds; if your site needs that speed, put the check in the backend your `responder`
|
|
206
324
|
calls.
|
|
207
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
|
+
|
|
208
345
|
## Two implementations, pinned to each other
|
|
209
346
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
drives this module against `testdata/wire_vectors.json
|
|
213
|
-
|
|
214
|
-
|
|
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.
|
|
215
353
|
|
|
216
354
|
The bytes are the contract: every signed payload must match Python's canonical JSON
|
|
217
355
|
exactly, or a signature is unverifiable and the only diagnostic anyone gets is
|
|
218
356
|
"signature verification failed".
|
|
219
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
|
+
|
|
220
382
|
## What this is part of
|
|
221
383
|
|
|
222
384
|
[Muretai](https://muretai.com) is a network where AI agents that belong to *different
|
|
@@ -227,4 +389,13 @@ that has to stay awake.
|
|
|
227
389
|
You do not need the rest of the network to use this file. It is useful on its own the
|
|
228
390
|
moment an agent knocks.
|
|
229
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
|
+
|
|
230
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 {
|
|
@@ -870,6 +990,475 @@ function isThenable(v) {
|
|
|
870
990
|
return v !== null && typeof v === 'object' && typeof v.then === 'function';
|
|
871
991
|
}
|
|
872
992
|
|
|
993
|
+
// ---------------------------------------------------------------- baseUrl canonicalisation
|
|
994
|
+
|
|
995
|
+
/** RFC 3986 `pchar` plus '/' — the only characters an accepted path may carry. Everything
|
|
996
|
+
* outside this set is percent-encoded by `new URL()` and left verbatim by Python's
|
|
997
|
+
* urlsplit, which is precisely the divergence canonicalBaseUrl exists to prevent. */
|
|
998
|
+
const PATH_OK = new Set(
|
|
999
|
+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$&'()*+,;=:@/");
|
|
1000
|
+
const HEX = new Set('0123456789abcdefABCDEF');
|
|
1001
|
+
/** A host is lowercased before this is applied. '_' is not legal in a hostname but is real
|
|
1002
|
+
* in the wild and both parsers keep it verbatim, so it is not a divergence. */
|
|
1003
|
+
const HOST_OK = new Set('abcdefghijklmnopqrstuvwxyz0123456789.-_');
|
|
1004
|
+
const DEFAULT_PORT = { http: 80, https: 443 };
|
|
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
|
+
|
|
1023
|
+
/** Raise the one refusal shape, in the order an operator can act on at 2am: what they gave
|
|
1024
|
+
* (JSON-quoted, so an invisible tab is VISIBLE), which rule in words, the fix as a string
|
|
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)}`];
|
|
1030
|
+
if (fix) lines.push(` use: ${JSON.stringify(fix)}`);
|
|
1031
|
+
lines.push(` ${why}`);
|
|
1032
|
+
throw new TypeError(lines.join('\n'));
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* canonicalBaseUrl(baseUrl) -> the exact string this entry may publish as its card `url`.
|
|
1037
|
+
*
|
|
1038
|
+
* The contract, and the only reason this function exists: **the returned value equals what
|
|
1039
|
+
* a VISITOR computes** from the url they dialled (`Outbox.card_scope` in agent/outbox.py)
|
|
1040
|
+
* when deciding whether this card is about this site. An entry that publishes anything else
|
|
1041
|
+
* fails verification on a stranger's machine, with "signature verification failed" as the
|
|
1042
|
+
* only diagnostic and nothing at all on the operator's screen.
|
|
1043
|
+
*
|
|
1044
|
+
* So every input on which the operator's spelling and the visitor's arithmetic could
|
|
1045
|
+
* disagree — or on which this file and its Python twin (examples/agent_entry_reference.py)
|
|
1046
|
+
* could disagree — is REFUSED here, loudly, at construction. The two are held byte-identical
|
|
1047
|
+
* by an acceptance suite; a value they canonicalise differently is a signed-bytes split for
|
|
1048
|
+
* identical operator input, which is the bug this whole function is about.
|
|
1049
|
+
*
|
|
1050
|
+
* It HAND-PARSES rather than using `new URL()`, which is not a candidate: it keeps a trailing
|
|
1051
|
+
* DNS dot (`https://x.`) that the verifier drops, punycodes hosts, percent-encodes non-ASCII,
|
|
1052
|
+
* spaces and `|^{}`, removes dot segments, and rewrites `\` to `/` — five spellings Python
|
|
1053
|
+
* does not produce. `new URL()` runs at the END instead, as a tripwire: if a future Node
|
|
1054
|
+
* changes the parser out from under the hand-parse, that is a loud startup failure here
|
|
1055
|
+
* rather than silent byte drift discovered by a customer whose card stopped verifying.
|
|
1056
|
+
*
|
|
1057
|
+
* The gate (steps 1-7) runs before any folding, which is what makes the folds safe: it
|
|
1058
|
+
* removes exactly the inputs the two languages disagree about, so every remaining fold is
|
|
1059
|
+
* one they already agree on.
|
|
1060
|
+
*/
|
|
1061
|
+
export function canonicalBaseUrl(baseUrl, { warn = true } = {}) {
|
|
1062
|
+
if (typeof baseUrl !== 'string' || !baseUrl.trim()) {
|
|
1063
|
+
refuseBaseUrl(baseUrl, 'it is empty.', undefined,
|
|
1064
|
+
'Set it to the URL visitors actually dial — it is what your signed card claims, and '
|
|
1065
|
+
+ 'a card naming a different origin proves nothing about yours.');
|
|
1066
|
+
}
|
|
1067
|
+
const s = baseUrl.trim();
|
|
1068
|
+
|
|
1069
|
+
for (const ch of s) {
|
|
1070
|
+
const c = ch.codePointAt(0);
|
|
1071
|
+
if (c >= 0x21 && c <= 0x7e) continue;
|
|
1072
|
+
if (c > 0x7e) {
|
|
1073
|
+
const hostGuess = (s.split('://')[1] ?? s).split('/')[0];
|
|
1074
|
+
let fix;
|
|
1075
|
+
try { // best-effort A-label, for the message only
|
|
1076
|
+
const asciiHost = new URL(`https://${hostGuess}`).hostname;
|
|
1077
|
+
if (asciiHost && asciiHost !== hostGuess) fix = s.replace(hostGuess, asciiHost);
|
|
1078
|
+
} catch { /* not a host we can suggest a spelling for */ }
|
|
1079
|
+
refuseBaseUrl(s, 'it is not ASCII.', fix,
|
|
1080
|
+
'Python and JavaScript disagree on how to spell this (Python keeps it as typed, '
|
|
1081
|
+
+ 'JavaScript percent-encodes or punycodes it), so the two Agent Entry '
|
|
1082
|
+
+ 'implementations would sign different bytes for the same site. Supply the ASCII '
|
|
1083
|
+
+ 'form, and publish your links and QR codes in that same form.');
|
|
1084
|
+
}
|
|
1085
|
+
refuseBaseUrl(s, 'it contains whitespace or a control character.', undefined,
|
|
1086
|
+
'Both URL parsers DELETE a tab or newline silently and mid-string, so the value you '
|
|
1087
|
+
+ 'meant and the value that gets signed are not the same string and nothing tells you.');
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
if (s.includes('\\')) {
|
|
1091
|
+
refuseBaseUrl(s, 'it contains a backslash.', s.replaceAll('\\', '/'),
|
|
1092
|
+
"JavaScript reads '\\' as '/' and Python does not, so the two Agent Entry "
|
|
1093
|
+
+ 'implementations would disagree about where the host ends.');
|
|
1094
|
+
}
|
|
1095
|
+
for (const [bad, label] of [['?', 'a query string'], ['#', 'a fragment']]) {
|
|
1096
|
+
if (s.includes(bad)) {
|
|
1097
|
+
refuseBaseUrl(s, `it carries ${label}.`, s.split(bad)[0] || undefined,
|
|
1098
|
+
'This string is signed into your Agent Card, and every visitor compares it to the '
|
|
1099
|
+
+ 'URL they dialled — which never carries one.');
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
const sep = s.indexOf('://');
|
|
1104
|
+
const scheme = sep < 0 ? '' : s.slice(0, sep).toLowerCase();
|
|
1105
|
+
if (sep < 0 || !(scheme in DEFAULT_PORT)) {
|
|
1106
|
+
refuseBaseUrl(s, 'it is not an http(s) URL.',
|
|
1107
|
+
sep < 0 && !s.startsWith('/') ? `https://${s}` : undefined,
|
|
1108
|
+
'An Agent Card names an origin a visitor can dial over HTTP.');
|
|
1109
|
+
}
|
|
1110
|
+
const rest = s.slice(sep + 3);
|
|
1111
|
+
const cut = rest.indexOf('/');
|
|
1112
|
+
const authority = cut < 0 ? rest : rest.slice(0, cut);
|
|
1113
|
+
let path = cut < 0 ? '' : rest.slice(cut);
|
|
1114
|
+
|
|
1115
|
+
if (authority.includes('@')) {
|
|
1116
|
+
refuseBaseUrl(s, "it carries userinfo (a '@' before the host).",
|
|
1117
|
+
`${scheme}://${authority.slice(authority.lastIndexOf('@') + 1)}${path}`,
|
|
1118
|
+
"The part before '@' is not the host: a visitor dials the part AFTER it, so a card "
|
|
1119
|
+
+ 'built from this string would name a different site than the one being served.');
|
|
1120
|
+
}
|
|
1121
|
+
if (authority.includes('%')) {
|
|
1122
|
+
refuseBaseUrl(s, 'the host contains a percent-escape.', undefined,
|
|
1123
|
+
'Python lowercases it into the host and JavaScript refuses the URL outright, so the '
|
|
1124
|
+
+ 'two Agent Entry implementations cannot agree.');
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
let host;
|
|
1128
|
+
let portS;
|
|
1129
|
+
if (authority.startsWith('[')) { // IPv6 literal — brackets are part of it
|
|
1130
|
+
const close = authority.indexOf(']');
|
|
1131
|
+
if (close < 0) {
|
|
1132
|
+
refuseBaseUrl(s, "the IPv6 host is missing its closing ']'.", undefined,
|
|
1133
|
+
'An address literal must be bracketed, e.g. http://[::1]:9000');
|
|
1134
|
+
}
|
|
1135
|
+
host = authority.slice(0, close + 1).toLowerCase();
|
|
1136
|
+
const tail = authority.slice(close + 1);
|
|
1137
|
+
if (tail && !tail.startsWith(':')) {
|
|
1138
|
+
refuseBaseUrl(s, 'there is text after the IPv6 address.', undefined,
|
|
1139
|
+
"Only ':<port>' may follow a bracketed address.");
|
|
1140
|
+
}
|
|
1141
|
+
portS = tail ? tail.slice(1) : '';
|
|
1142
|
+
} else {
|
|
1143
|
+
const colon = authority.lastIndexOf(':');
|
|
1144
|
+
host = (colon < 0 ? authority : authority.slice(0, colon)).toLowerCase();
|
|
1145
|
+
portS = colon < 0 ? '' : authority.slice(colon + 1);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
let port = null;
|
|
1149
|
+
if (portS) {
|
|
1150
|
+
const n = Number(portS);
|
|
1151
|
+
if (!/^[0-9]+$/.test(portS) || !Number.isInteger(n) || n < 1 || n > 65535) {
|
|
1152
|
+
refuseBaseUrl(s, `the port ${JSON.stringify(portS)} is not a number in 1-65535.`,
|
|
1153
|
+
undefined, 'A visitor dials a real port; anything else cannot be reached.');
|
|
1154
|
+
}
|
|
1155
|
+
port = n;
|
|
1156
|
+
}
|
|
1157
|
+
host = host.replace(/\.+$/, ''); // trailing dot: DNS-equal, byte-different
|
|
1158
|
+
if (!host || (!host.startsWith('[') && [...host].some((c) => !HOST_OK.has(c)))) {
|
|
1159
|
+
refuseBaseUrl(s, 'the host is missing or contains characters that are not a hostname.',
|
|
1160
|
+
undefined, 'A card must name a host a visitor can resolve.');
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
path = path.replace(/\/+$/, ''); // ALL of them: card_scope uses rstrip too
|
|
1164
|
+
if (path) {
|
|
1165
|
+
for (let i = 0; i < path.length;) {
|
|
1166
|
+
const ch = path[i];
|
|
1167
|
+
if (ch === '%') {
|
|
1168
|
+
if (path.length - i < 3 || !HEX.has(path[i + 1]) || !HEX.has(path[i + 2])) {
|
|
1169
|
+
refuseBaseUrl(s, 'the path has a malformed percent-escape.', undefined,
|
|
1170
|
+
"'%' must be followed by exactly two hex digits, or the two URL parsers "
|
|
1171
|
+
+ 'disagree about what the path is.');
|
|
1172
|
+
}
|
|
1173
|
+
i += 3;
|
|
1174
|
+
continue;
|
|
1175
|
+
}
|
|
1176
|
+
if (!PATH_OK.has(ch)) {
|
|
1177
|
+
refuseBaseUrl(s, `the path contains ${JSON.stringify(ch)}, which is not allowed `
|
|
1178
|
+
+ 'unencoded.', undefined,
|
|
1179
|
+
'JavaScript percent-encodes this character and Python leaves it verbatim, so the '
|
|
1180
|
+
+ 'two Agent Entry implementations would sign different bytes. Percent-encode it '
|
|
1181
|
+
+ 'yourself.');
|
|
1182
|
+
}
|
|
1183
|
+
i += 1;
|
|
1184
|
+
}
|
|
1185
|
+
const parts = path.split('/');
|
|
1186
|
+
if (parts.some((seg) => dotSegment(seg) !== null)) {
|
|
1187
|
+
const segs = []; // RFC 3986 remove_dot_segments, for the fix
|
|
1188
|
+
for (const seg of parts) {
|
|
1189
|
+
const dot = dotSegment(seg);
|
|
1190
|
+
if (dot === '.') continue;
|
|
1191
|
+
if (dot === '..') { if (segs.length > 1) segs.pop(); continue; }
|
|
1192
|
+
segs.push(seg);
|
|
1193
|
+
}
|
|
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.');
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
const originOut = `${scheme}://${host}${port !== null && port !== DEFAULT_PORT[scheme] ? `:${port}` : ''}`;
|
|
1203
|
+
const out = originOut + path;
|
|
1204
|
+
|
|
1205
|
+
// The tripwire. Not redundant with the hand-parse: it is what turns a future Node/WHATWG
|
|
1206
|
+
// change into a loud startup failure instead of silent byte drift. If this ever fires,
|
|
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.
|
|
1214
|
+
const probe = new URL(out);
|
|
1215
|
+
if (probe.origin !== originOut || probe.pathname !== (path || '/')
|
|
1216
|
+
|| probe.search || probe.hash) {
|
|
1217
|
+
throw new TypeError(
|
|
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`);
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
if (warn) {
|
|
1229
|
+
if (/[A-Z]/.test(path)) {
|
|
1230
|
+
process.stderr.write(`warning: base_url path ${JSON.stringify(path)} contains `
|
|
1231
|
+
+ `uppercase letters. Paths are case-SENSITIVE and are not folded by the visitor's `
|
|
1232
|
+
+ `check, so a visitor who dials ${JSON.stringify(path.toLowerCase())} will fail to `
|
|
1233
|
+
+ `verify your card. Make sure every link, invite and QR code you publish spells it `
|
|
1234
|
+
+ `exactly ${JSON.stringify(path)}.\n`);
|
|
1235
|
+
}
|
|
1236
|
+
if (path.includes('%')) {
|
|
1237
|
+
process.stderr.write(`warning: base_url path ${JSON.stringify(path)} contains a `
|
|
1238
|
+
+ `percent-escape. It is compared verbatim, case included, so a visitor who dials `
|
|
1239
|
+
+ `another spelling of the same path will fail to verify your card.\n`);
|
|
1240
|
+
}
|
|
1241
|
+
if (scheme === 'http' && !['localhost', '127.0.0.1', '[::1]'].includes(host)) {
|
|
1242
|
+
process.stderr.write(`warning: base_url ${JSON.stringify(out)} is plain HTTP on a `
|
|
1243
|
+
+ `public host. A visiting agent refuses a plain-http open door (the message text `
|
|
1244
|
+
+ `would cross the wire in the clear), so this entry will be skipped by every `
|
|
1245
|
+
+ `well-behaved visitor. Use https.\n`);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
return out;
|
|
1249
|
+
}
|
|
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
|
+
|
|
873
1462
|
/**
|
|
874
1463
|
* createAgentEntry(opts) -> { did, card, ledger, handleRequest, handleRequestAsync, listen }
|
|
875
1464
|
*
|
|
@@ -879,6 +1468,15 @@ function isThenable(v) {
|
|
|
879
1468
|
* REQUIRES card.url to name the origin+path it dialled (Outbox.card_binds_to)
|
|
880
1469
|
* — that binding is what stops an attacker re-serving your signed card at
|
|
881
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.
|
|
882
1480
|
* responder (envelope) => string | {text, contextId?, timestamp?} | Promise<…>
|
|
883
1481
|
* openDoor advertise `muretai.open_door` (default true) — the flag that tells a
|
|
884
1482
|
* visiting agent it may contact you without an introduction.
|
|
@@ -900,17 +1498,30 @@ export function createAgentEntry({
|
|
|
900
1498
|
anonymousLane = false,
|
|
901
1499
|
anonRatePerMin = ANON_RATE_PER_MIN,
|
|
902
1500
|
skills = [],
|
|
1501
|
+
domains = null,
|
|
1502
|
+
basePath = null,
|
|
903
1503
|
maxAccounts = 50000,
|
|
904
1504
|
} = {}) {
|
|
905
1505
|
if (!seedHex) throw new TypeError('createAgentEntry: seedHex is required');
|
|
906
1506
|
if (!baseUrl) throw new TypeError('createAgentEntry: baseUrl is required (it is signed into the card)');
|
|
1507
|
+
// The ONE string this entry publishes as its card url. Canonicalised, not echoed: it must
|
|
1508
|
+
// equal what a visitor's Outbox.card_scope computes for the url they dialled, or the card
|
|
1509
|
+
// fails verification on THEIR machine with nothing on ours.
|
|
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);
|
|
907
1518
|
const did = didFromSeedHex(seedHex);
|
|
908
1519
|
|
|
909
1520
|
const card = {
|
|
910
1521
|
protocolVersion: PROTOCOL_VERSION,
|
|
911
1522
|
name,
|
|
912
1523
|
description,
|
|
913
|
-
url:
|
|
1524
|
+
url: canonUrl,
|
|
914
1525
|
did,
|
|
915
1526
|
version,
|
|
916
1527
|
capabilities: { streaming: false, pushNotifications: false },
|
|
@@ -918,6 +1529,11 @@ export function createAgentEntry({
|
|
|
918
1529
|
defaultOutputModes: ['text/plain'],
|
|
919
1530
|
skills,
|
|
920
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;
|
|
921
1537
|
if (openDoor) card.muretai = { open_door: true };
|
|
922
1538
|
// Deliberately NO `relay`/`enc_pub` on the card: those advertise a store-and-forward
|
|
923
1539
|
// mailbox, and an agent entry has no listener draining one. Advertising a mailbox nobody
|
|
@@ -1010,6 +1626,13 @@ export function createAgentEntry({
|
|
|
1010
1626
|
return { ok: false, reason: 'device binding does not name the sender' };
|
|
1011
1627
|
}
|
|
1012
1628
|
const { ts, validUntil, rootDid } = binding;
|
|
1629
|
+
// `Number.isSafeInteger` is the SAME predicate the Python twin now applies after
|
|
1630
|
+
// normalising an integer-valued float: both accept `1` and `1.0`, both refuse a true
|
|
1631
|
+
// fraction, and both refuse a magnitude that cannot round-trip (Python because such a
|
|
1632
|
+
// float is not integer-valued once it loses precision, JS at the safe-integer bound).
|
|
1633
|
+
// They disagreed before: Python refused `1.0` outright while this accepted it, so the
|
|
1634
|
+
// same POST created a customer here and 401'd there
|
|
1635
|
+
// (ISSUE(agent-entry-binding-float-ts-divergence)).
|
|
1013
1636
|
if (!Number.isSafeInteger(ts) || !Number.isSafeInteger(validUntil)) {
|
|
1014
1637
|
return { ok: false, reason: 'device binding timestamps must be integers' };
|
|
1015
1638
|
}
|
|
@@ -1098,15 +1721,33 @@ export function createAgentEntry({
|
|
|
1098
1721
|
* size checks come BEFORE any parsing or crypto — a check placed after the signature is
|
|
1099
1722
|
* a check the attacker simply skips.
|
|
1100
1723
|
*/
|
|
1101
|
-
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'));
|
|
1102
1732
|
// 1. body over 1 MiB — refused WITHOUT parsing.
|
|
1103
1733
|
if (bodyBuffer.length > MAX_BODY_BYTES) {
|
|
1104
1734
|
return jsonResponse(413, { error: 'request body too large' });
|
|
1105
1735
|
}
|
|
1106
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.
|
|
1107
1748
|
let req;
|
|
1108
1749
|
try {
|
|
1109
|
-
req = JSON.parse(
|
|
1750
|
+
req = JSON.parse(STRICT_UTF8.decode(bodyBuffer));
|
|
1110
1751
|
} catch {
|
|
1111
1752
|
return jsonResponse(400, { error: 'malformed JSON' });
|
|
1112
1753
|
}
|
|
@@ -1114,14 +1755,26 @@ export function createAgentEntry({
|
|
|
1114
1755
|
return jsonResponse(400, { error: 'JSON-RPC request must be an object' });
|
|
1115
1756
|
}
|
|
1116
1757
|
// 3. From here every refusal is HTTP 200 with a JSON-RPC error object.
|
|
1117
|
-
const reqId = req.id
|
|
1118
|
-
|
|
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') {
|
|
1119
1767
|
return rpcError(reqId, ERRORS.METHOD_NOT_FOUND,
|
|
1120
|
-
|
|
1768
|
+
'an agent entry implements message/send only');
|
|
1121
1769
|
}
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
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');
|
|
1125
1778
|
}
|
|
1126
1779
|
// 3a. wrongly-TYPED wire fields, on the raw object and before anything measures or
|
|
1127
1780
|
// hashes it. These are the fields that end up inside a signed payload — theirs and,
|
|
@@ -1130,6 +1783,9 @@ export function createAgentEntry({
|
|
|
1130
1783
|
const shape = wireShapeError(msg);
|
|
1131
1784
|
if (shape !== null) return rpcError(reqId, ERRORS.INVALID_REQUEST, shape);
|
|
1132
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.
|
|
1133
1789
|
const meta = (msg.metadata && typeof msg.metadata === 'object') ? msg.metadata : {};
|
|
1134
1790
|
const text = messageText(msg);
|
|
1135
1791
|
|
|
@@ -1238,22 +1894,51 @@ export function createAgentEntry({
|
|
|
1238
1894
|
return finishReply(reqId, answer, { inbound, toDid });
|
|
1239
1895
|
}
|
|
1240
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
|
+
|
|
1241
1907
|
function route(method, path, bodyBuffer) {
|
|
1242
|
-
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.
|
|
1243
1924
|
if (method === 'GET' || method === 'HEAD') {
|
|
1244
|
-
if (pathname === AGENT_CARD_PATH || pathname === AGENT_CARD_PATH_LEGACY) {
|
|
1925
|
+
if (pathname === mount + AGENT_CARD_PATH || pathname === mount + AGENT_CARD_PATH_LEGACY) {
|
|
1245
1926
|
// Byte-identical on both paths: the current A2A path and the legacy alias.
|
|
1246
1927
|
return { status: 200, headers: cardHeaders(cardBytes.length), body: cardBytes };
|
|
1247
1928
|
}
|
|
1248
|
-
if (pathname === AGENT_CARD_SIG_PATH) {
|
|
1929
|
+
if (pathname === mount + AGENT_CARD_SIG_PATH) {
|
|
1249
1930
|
const env = cardEnvelopeBytes();
|
|
1250
1931
|
return { status: 200, headers: cardHeaders(env.length), body: env };
|
|
1251
1932
|
}
|
|
1252
|
-
if (pathname
|
|
1933
|
+
if (isMountPath(pathname)) {
|
|
1253
1934
|
const body = Buffer.from(
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
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`
|
|
1939
|
+
+ `DID: ${did}\nCard: ${canonUrl}${AGENT_CARD_PATH}\n`
|
|
1940
|
+
+ `POST a signed A2A message/send request to ${mount || '/'} for a signed reply.\n`,
|
|
1941
|
+
'utf8');
|
|
1257
1942
|
return { status: 200,
|
|
1258
1943
|
headers: { 'Content-Type': 'text/plain; charset=utf-8',
|
|
1259
1944
|
'Content-Length': String(body.length) },
|
|
@@ -1262,8 +1947,10 @@ export function createAgentEntry({
|
|
|
1262
1947
|
return jsonResponse(404, { error: 'not found' });
|
|
1263
1948
|
}
|
|
1264
1949
|
if (method === 'POST') {
|
|
1265
|
-
// EXACTLY the
|
|
1266
|
-
|
|
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' });
|
|
1267
1954
|
return handlePost(bodyBuffer || Buffer.alloc(0));
|
|
1268
1955
|
}
|
|
1269
1956
|
if (method === 'OPTIONS') {
|
|
@@ -1307,7 +1994,17 @@ export function createAgentEntry({
|
|
|
1307
1994
|
* (behind a TLS terminator) to go public.
|
|
1308
1995
|
*/
|
|
1309
1996
|
function listen(port = 8788, host = '127.0.0.1', onReady) {
|
|
1310
|
-
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) => {
|
|
1311
2008
|
const chunks = [];
|
|
1312
2009
|
let total = 0;
|
|
1313
2010
|
let oversize = false;
|
|
@@ -1340,11 +2037,33 @@ export function createAgentEntry({
|
|
|
1340
2037
|
.catch(() => { try { res.destroy(); } catch { /* already gone */ } });
|
|
1341
2038
|
});
|
|
1342
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
|
+
});
|
|
1343
2060
|
server.listen(port, host, () => { if (onReady) onReady(server); });
|
|
1344
2061
|
return server;
|
|
1345
2062
|
}
|
|
1346
2063
|
|
|
1347
|
-
|
|
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,
|
|
1348
2067
|
cardEnvelope: () => JSON.parse(cardEnvelopeBytes().toString('utf8')) };
|
|
1349
2068
|
}
|
|
1350
2069
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muretai/agent-entry",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
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",
|
|
@@ -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",
|