@muretai/agent-entry 1.0.0 → 1.1.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 +26 -0
- package/muretai-agent-entry.mjs +241 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -144,9 +144,35 @@ your site's identity**), `AGENT_ENTRY_PORT` (8788), `AGENT_ENTRY_BASE_URL`,
|
|
|
144
144
|
`AGENT_ENTRY_NAME`, `AGENT_ENTRY_ANON` (`1` also accepts unsigned inquiries, which create
|
|
145
145
|
no account).
|
|
146
146
|
|
|
147
|
+
## What `baseUrl` may be
|
|
148
|
+
|
|
147
149
|
`baseUrl` must be the URL visitors actually dial: it is what your signed card claims, and
|
|
148
150
|
a card naming a different origin proves nothing about yours.
|
|
149
151
|
|
|
152
|
+
Agent Entry does not copy it into the card — it canonicalises it, so the string it signs is
|
|
153
|
+
the one a visitor computes from the URL they dialled. Where the two could differ, **it
|
|
154
|
+
refuses to start**, naming the rule and the value to paste instead. That is deliberate: the
|
|
155
|
+
alternative is a card that fails on a stranger's machine, where the only diagnostic is
|
|
156
|
+
"signature verification failed" and nothing at all appears on yours.
|
|
157
|
+
|
|
158
|
+
Tidied up for you: surrounding spaces, the case of the scheme and host, a default port
|
|
159
|
+
(`:443`, `:80`), a trailing dot on the host, and any trailing slashes.
|
|
160
|
+
`https://studio.example/` and `https://Studio.Example:443` both publish as
|
|
161
|
+
`https://studio.example`.
|
|
162
|
+
|
|
163
|
+
Refused, with the fix in the message: a scheme other than `http`/`https`, a missing host,
|
|
164
|
+
`user@host`, a query string, a `#` fragment, non-ASCII characters, a stray tab or space, a
|
|
165
|
+
backslash, `.` or `..` in the path, a broken `%` escape, and a port outside 1–65535.
|
|
166
|
+
|
|
167
|
+
Two rules worth knowing before you pick a URL:
|
|
168
|
+
|
|
169
|
+
- **Paths are case-sensitive.** `https://studio.example/Alice` and `.../alice` are different
|
|
170
|
+
sites to a visitor. Choose one spelling and use it in every link, invite and QR code.
|
|
171
|
+
- **Write an international domain in its `xn--` form** — `https://xn--eckwd4c7c.example`,
|
|
172
|
+
not the Unicode spelling — and publish your links in that same form. JavaScript's URL
|
|
173
|
+
parser punycodes a host and Python's does not, so the two implementations would otherwise
|
|
174
|
+
sign different bytes for the same site.
|
|
175
|
+
|
|
150
176
|
## Pairs with WebMCP: the tab conversation becomes a customer
|
|
151
177
|
|
|
152
178
|
If your page already exposes [WebMCP](https://github.com/MiguelsPizza/WebMCP) tools, you have
|
package/muretai-agent-entry.mjs
CHANGED
|
@@ -870,6 +870,234 @@ function isThenable(v) {
|
|
|
870
870
|
return v !== null && typeof v === 'object' && typeof v.then === 'function';
|
|
871
871
|
}
|
|
872
872
|
|
|
873
|
+
// ---------------------------------------------------------------- baseUrl canonicalisation
|
|
874
|
+
|
|
875
|
+
/** RFC 3986 `pchar` plus '/' — the only characters an accepted path may carry. Everything
|
|
876
|
+
* outside this set is percent-encoded by `new URL()` and left verbatim by Python's
|
|
877
|
+
* urlsplit, which is precisely the divergence canonicalBaseUrl exists to prevent. */
|
|
878
|
+
const PATH_OK = new Set(
|
|
879
|
+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$&'()*+,;=:@/");
|
|
880
|
+
const HEX = new Set('0123456789abcdefABCDEF');
|
|
881
|
+
/** A host is lowercased before this is applied. '_' is not legal in a hostname but is real
|
|
882
|
+
* in the wild and both parsers keep it verbatim, so it is not a divergence. */
|
|
883
|
+
const HOST_OK = new Set('abcdefghijklmnopqrstuvwxyz0123456789.-_');
|
|
884
|
+
const DEFAULT_PORT = { http: 80, https: 443 };
|
|
885
|
+
|
|
886
|
+
/** Raise the one refusal shape, in the order an operator can act on at 2am: what they gave
|
|
887
|
+
* (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. `base_url is not publishable:` is the greppable
|
|
889
|
+
* stem the Python twin shares. */
|
|
890
|
+
function refuseBaseUrl(given, rule, why, fix) {
|
|
891
|
+
const lines = [`base_url is not publishable: ${rule}`, ` given: ${JSON.stringify(given)}`];
|
|
892
|
+
if (fix) lines.push(` use: ${JSON.stringify(fix)}`);
|
|
893
|
+
lines.push(` ${why}`);
|
|
894
|
+
throw new TypeError(lines.join('\n'));
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* canonicalBaseUrl(baseUrl) -> the exact string this entry may publish as its card `url`.
|
|
899
|
+
*
|
|
900
|
+
* The contract, and the only reason this function exists: **the returned value equals what
|
|
901
|
+
* a VISITOR computes** from the url they dialled (`Outbox.card_scope` in agent/outbox.py)
|
|
902
|
+
* when deciding whether this card is about this site. An entry that publishes anything else
|
|
903
|
+
* fails verification on a stranger's machine, with "signature verification failed" as the
|
|
904
|
+
* only diagnostic and nothing at all on the operator's screen.
|
|
905
|
+
*
|
|
906
|
+
* So every input on which the operator's spelling and the visitor's arithmetic could
|
|
907
|
+
* disagree — or on which this file and its Python twin (examples/agent_entry_reference.py)
|
|
908
|
+
* could disagree — is REFUSED here, loudly, at construction. The two are held byte-identical
|
|
909
|
+
* by an acceptance suite; a value they canonicalise differently is a signed-bytes split for
|
|
910
|
+
* identical operator input, which is the bug this whole function is about.
|
|
911
|
+
*
|
|
912
|
+
* It HAND-PARSES rather than using `new URL()`, which is not a candidate: it keeps a trailing
|
|
913
|
+
* DNS dot (`https://x.`) that the verifier drops, punycodes hosts, percent-encodes non-ASCII,
|
|
914
|
+
* spaces and `|^{}`, removes dot segments, and rewrites `\` to `/` — five spellings Python
|
|
915
|
+
* does not produce. `new URL()` runs at the END instead, as a tripwire: if a future Node
|
|
916
|
+
* changes the parser out from under the hand-parse, that is a loud startup failure here
|
|
917
|
+
* rather than silent byte drift discovered by a customer whose card stopped verifying.
|
|
918
|
+
*
|
|
919
|
+
* The gate (steps 1-7) runs before any folding, which is what makes the folds safe: it
|
|
920
|
+
* removes exactly the inputs the two languages disagree about, so every remaining fold is
|
|
921
|
+
* one they already agree on.
|
|
922
|
+
*/
|
|
923
|
+
export function canonicalBaseUrl(baseUrl, { warn = true } = {}) {
|
|
924
|
+
if (typeof baseUrl !== 'string' || !baseUrl.trim()) {
|
|
925
|
+
refuseBaseUrl(baseUrl, 'it is empty.', undefined,
|
|
926
|
+
'Set it to the URL visitors actually dial — it is what your signed card claims, and '
|
|
927
|
+
+ 'a card naming a different origin proves nothing about yours.');
|
|
928
|
+
}
|
|
929
|
+
const s = baseUrl.trim();
|
|
930
|
+
|
|
931
|
+
for (const ch of s) {
|
|
932
|
+
const c = ch.codePointAt(0);
|
|
933
|
+
if (c >= 0x21 && c <= 0x7e) continue;
|
|
934
|
+
if (c > 0x7e) {
|
|
935
|
+
const hostGuess = (s.split('://')[1] ?? s).split('/')[0];
|
|
936
|
+
let fix;
|
|
937
|
+
try { // best-effort A-label, for the message only
|
|
938
|
+
const asciiHost = new URL(`https://${hostGuess}`).hostname;
|
|
939
|
+
if (asciiHost && asciiHost !== hostGuess) fix = s.replace(hostGuess, asciiHost);
|
|
940
|
+
} catch { /* not a host we can suggest a spelling for */ }
|
|
941
|
+
refuseBaseUrl(s, 'it is not ASCII.', fix,
|
|
942
|
+
'Python and JavaScript disagree on how to spell this (Python keeps it as typed, '
|
|
943
|
+
+ 'JavaScript percent-encodes or punycodes it), so the two Agent Entry '
|
|
944
|
+
+ 'implementations would sign different bytes for the same site. Supply the ASCII '
|
|
945
|
+
+ 'form, and publish your links and QR codes in that same form.');
|
|
946
|
+
}
|
|
947
|
+
refuseBaseUrl(s, 'it contains whitespace or a control character.', undefined,
|
|
948
|
+
'Both URL parsers DELETE a tab or newline silently and mid-string, so the value you '
|
|
949
|
+
+ 'meant and the value that gets signed are not the same string and nothing tells you.');
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
if (s.includes('\\')) {
|
|
953
|
+
refuseBaseUrl(s, 'it contains a backslash.', s.replaceAll('\\', '/'),
|
|
954
|
+
"JavaScript reads '\\' as '/' and Python does not, so the two Agent Entry "
|
|
955
|
+
+ 'implementations would disagree about where the host ends.');
|
|
956
|
+
}
|
|
957
|
+
for (const [bad, label] of [['?', 'a query string'], ['#', 'a fragment']]) {
|
|
958
|
+
if (s.includes(bad)) {
|
|
959
|
+
refuseBaseUrl(s, `it carries ${label}.`, s.split(bad)[0] || undefined,
|
|
960
|
+
'This string is signed into your Agent Card, and every visitor compares it to the '
|
|
961
|
+
+ 'URL they dialled — which never carries one.');
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const sep = s.indexOf('://');
|
|
966
|
+
const scheme = sep < 0 ? '' : s.slice(0, sep).toLowerCase();
|
|
967
|
+
if (sep < 0 || !(scheme in DEFAULT_PORT)) {
|
|
968
|
+
refuseBaseUrl(s, 'it is not an http(s) URL.',
|
|
969
|
+
sep < 0 && !s.startsWith('/') ? `https://${s}` : undefined,
|
|
970
|
+
'An Agent Card names an origin a visitor can dial over HTTP.');
|
|
971
|
+
}
|
|
972
|
+
const rest = s.slice(sep + 3);
|
|
973
|
+
const cut = rest.indexOf('/');
|
|
974
|
+
const authority = cut < 0 ? rest : rest.slice(0, cut);
|
|
975
|
+
let path = cut < 0 ? '' : rest.slice(cut);
|
|
976
|
+
|
|
977
|
+
if (authority.includes('@')) {
|
|
978
|
+
refuseBaseUrl(s, "it carries userinfo (a '@' before the host).",
|
|
979
|
+
`${scheme}://${authority.slice(authority.lastIndexOf('@') + 1)}${path}`,
|
|
980
|
+
"The part before '@' is not the host: a visitor dials the part AFTER it, so a card "
|
|
981
|
+
+ 'built from this string would name a different site than the one being served.');
|
|
982
|
+
}
|
|
983
|
+
if (authority.includes('%')) {
|
|
984
|
+
refuseBaseUrl(s, 'the host contains a percent-escape.', undefined,
|
|
985
|
+
'Python lowercases it into the host and JavaScript refuses the URL outright, so the '
|
|
986
|
+
+ 'two Agent Entry implementations cannot agree.');
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
let host;
|
|
990
|
+
let portS;
|
|
991
|
+
if (authority.startsWith('[')) { // IPv6 literal — brackets are part of it
|
|
992
|
+
const close = authority.indexOf(']');
|
|
993
|
+
if (close < 0) {
|
|
994
|
+
refuseBaseUrl(s, "the IPv6 host is missing its closing ']'.", undefined,
|
|
995
|
+
'An address literal must be bracketed, e.g. http://[::1]:9000');
|
|
996
|
+
}
|
|
997
|
+
host = authority.slice(0, close + 1).toLowerCase();
|
|
998
|
+
const tail = authority.slice(close + 1);
|
|
999
|
+
if (tail && !tail.startsWith(':')) {
|
|
1000
|
+
refuseBaseUrl(s, 'there is text after the IPv6 address.', undefined,
|
|
1001
|
+
"Only ':<port>' may follow a bracketed address.");
|
|
1002
|
+
}
|
|
1003
|
+
portS = tail ? tail.slice(1) : '';
|
|
1004
|
+
} else {
|
|
1005
|
+
const colon = authority.lastIndexOf(':');
|
|
1006
|
+
host = (colon < 0 ? authority : authority.slice(0, colon)).toLowerCase();
|
|
1007
|
+
portS = colon < 0 ? '' : authority.slice(colon + 1);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
let port = null;
|
|
1011
|
+
if (portS) {
|
|
1012
|
+
const n = Number(portS);
|
|
1013
|
+
if (!/^[0-9]+$/.test(portS) || !Number.isInteger(n) || n < 1 || n > 65535) {
|
|
1014
|
+
refuseBaseUrl(s, `the port ${JSON.stringify(portS)} is not a number in 1-65535.`,
|
|
1015
|
+
undefined, 'A visitor dials a real port; anything else cannot be reached.');
|
|
1016
|
+
}
|
|
1017
|
+
port = n;
|
|
1018
|
+
}
|
|
1019
|
+
host = host.replace(/\.+$/, ''); // trailing dot: DNS-equal, byte-different
|
|
1020
|
+
if (!host || (!host.startsWith('[') && [...host].some((c) => !HOST_OK.has(c)))) {
|
|
1021
|
+
refuseBaseUrl(s, 'the host is missing or contains characters that are not a hostname.',
|
|
1022
|
+
undefined, 'A card must name a host a visitor can resolve.');
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
path = path.replace(/\/+$/, ''); // ALL of them: card_scope uses rstrip too
|
|
1026
|
+
if (path) {
|
|
1027
|
+
for (let i = 0; i < path.length;) {
|
|
1028
|
+
const ch = path[i];
|
|
1029
|
+
if (ch === '%') {
|
|
1030
|
+
if (path.length - i < 3 || !HEX.has(path[i + 1]) || !HEX.has(path[i + 2])) {
|
|
1031
|
+
refuseBaseUrl(s, 'the path has a malformed percent-escape.', undefined,
|
|
1032
|
+
"'%' must be followed by exactly two hex digits, or the two URL parsers "
|
|
1033
|
+
+ 'disagree about what the path is.');
|
|
1034
|
+
}
|
|
1035
|
+
i += 3;
|
|
1036
|
+
continue;
|
|
1037
|
+
}
|
|
1038
|
+
if (!PATH_OK.has(ch)) {
|
|
1039
|
+
refuseBaseUrl(s, `the path contains ${JSON.stringify(ch)}, which is not allowed `
|
|
1040
|
+
+ 'unencoded.', undefined,
|
|
1041
|
+
'JavaScript percent-encodes this character and Python leaves it verbatim, so the '
|
|
1042
|
+
+ 'two Agent Entry implementations would sign different bytes. Percent-encode it '
|
|
1043
|
+
+ 'yourself.');
|
|
1044
|
+
}
|
|
1045
|
+
i += 1;
|
|
1046
|
+
}
|
|
1047
|
+
const parts = path.split('/');
|
|
1048
|
+
if (parts.some((seg) => seg === '.' || seg === '..')) {
|
|
1049
|
+
const segs = []; // RFC 3986 remove_dot_segments, for the fix
|
|
1050
|
+
for (const seg of parts) {
|
|
1051
|
+
if (seg === '.') continue;
|
|
1052
|
+
if (seg === '..') { if (segs.length > 1) segs.pop(); continue; }
|
|
1053
|
+
segs.push(seg);
|
|
1054
|
+
}
|
|
1055
|
+
refuseBaseUrl(s, "the path contains '.' or '..' segments.",
|
|
1056
|
+
`${scheme}://${authority}${segs.join('/').replace(/\/+$/, '')}`,
|
|
1057
|
+
'JavaScript collapses these segments and Python does not, so the two Agent Entry '
|
|
1058
|
+
+ 'implementations would sign different bytes.');
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
const originOut = `${scheme}://${host}${port !== null && port !== DEFAULT_PORT[scheme] ? `:${port}` : ''}`;
|
|
1063
|
+
const out = originOut + path;
|
|
1064
|
+
|
|
1065
|
+
// The tripwire. Not redundant with the hand-parse: it is what turns a future Node/WHATWG
|
|
1066
|
+
// change into a loud startup failure instead of silent byte drift. If this ever fires,
|
|
1067
|
+
// the hand-parse and the platform parser have diverged on an input the gate let through.
|
|
1068
|
+
const probe = new URL(out);
|
|
1069
|
+
if (probe.origin !== originOut || probe.pathname !== (path || '/')
|
|
1070
|
+
|| probe.search || probe.hash) {
|
|
1071
|
+
throw new TypeError(
|
|
1072
|
+
`base_url canonicalisation is broken (a bug in this file, not in your input): `
|
|
1073
|
+
+ `${JSON.stringify(baseUrl)} -> ${JSON.stringify(out)}, which this runtime's URL `
|
|
1074
|
+
+ `parser reads as ${JSON.stringify(probe.origin + probe.pathname)}. `
|
|
1075
|
+
+ `Please report this at https://github.com/muretai/agent-entry/issues`);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
if (warn) {
|
|
1079
|
+
if (/[A-Z]/.test(path)) {
|
|
1080
|
+
process.stderr.write(`warning: base_url path ${JSON.stringify(path)} contains `
|
|
1081
|
+
+ `uppercase letters. Paths are case-SENSITIVE and are not folded by the visitor's `
|
|
1082
|
+
+ `check, so a visitor who dials ${JSON.stringify(path.toLowerCase())} will fail to `
|
|
1083
|
+
+ `verify your card. Make sure every link, invite and QR code you publish spells it `
|
|
1084
|
+
+ `exactly ${JSON.stringify(path)}.\n`);
|
|
1085
|
+
}
|
|
1086
|
+
if (path.includes('%')) {
|
|
1087
|
+
process.stderr.write(`warning: base_url path ${JSON.stringify(path)} contains a `
|
|
1088
|
+
+ `percent-escape. It is compared verbatim, case included, so a visitor who dials `
|
|
1089
|
+
+ `another spelling of the same path will fail to verify your card.\n`);
|
|
1090
|
+
}
|
|
1091
|
+
if (scheme === 'http' && !['localhost', '127.0.0.1', '[::1]'].includes(host)) {
|
|
1092
|
+
process.stderr.write(`warning: base_url ${JSON.stringify(out)} is plain HTTP on a `
|
|
1093
|
+
+ `public host. A visiting agent refuses a plain-http open door (the message text `
|
|
1094
|
+
+ `would cross the wire in the clear), so this entry will be skipped by every `
|
|
1095
|
+
+ `well-behaved visitor. Use https.\n`);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
return out;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
873
1101
|
/**
|
|
874
1102
|
* createAgentEntry(opts) -> { did, card, ledger, handleRequest, handleRequestAsync, listen }
|
|
875
1103
|
*
|
|
@@ -904,13 +1132,17 @@ export function createAgentEntry({
|
|
|
904
1132
|
} = {}) {
|
|
905
1133
|
if (!seedHex) throw new TypeError('createAgentEntry: seedHex is required');
|
|
906
1134
|
if (!baseUrl) throw new TypeError('createAgentEntry: baseUrl is required (it is signed into the card)');
|
|
1135
|
+
// The ONE string this entry publishes as its card url. Canonicalised, not echoed: it must
|
|
1136
|
+
// equal what a visitor's Outbox.card_scope computes for the url they dialled, or the card
|
|
1137
|
+
// fails verification on THEIR machine with nothing on ours.
|
|
1138
|
+
const canonUrl = canonicalBaseUrl(baseUrl);
|
|
907
1139
|
const did = didFromSeedHex(seedHex);
|
|
908
1140
|
|
|
909
1141
|
const card = {
|
|
910
1142
|
protocolVersion: PROTOCOL_VERSION,
|
|
911
1143
|
name,
|
|
912
1144
|
description,
|
|
913
|
-
url:
|
|
1145
|
+
url: canonUrl,
|
|
914
1146
|
did,
|
|
915
1147
|
version,
|
|
916
1148
|
capabilities: { streaming: false, pushNotifications: false },
|
|
@@ -1010,6 +1242,13 @@ export function createAgentEntry({
|
|
|
1010
1242
|
return { ok: false, reason: 'device binding does not name the sender' };
|
|
1011
1243
|
}
|
|
1012
1244
|
const { ts, validUntil, rootDid } = binding;
|
|
1245
|
+
// `Number.isSafeInteger` is the SAME predicate the Python twin now applies after
|
|
1246
|
+
// normalising an integer-valued float: both accept `1` and `1.0`, both refuse a true
|
|
1247
|
+
// fraction, and both refuse a magnitude that cannot round-trip (Python because such a
|
|
1248
|
+
// float is not integer-valued once it loses precision, JS at the safe-integer bound).
|
|
1249
|
+
// They disagreed before: Python refused `1.0` outright while this accepted it, so the
|
|
1250
|
+
// same POST created a customer here and 401'd there
|
|
1251
|
+
// (ISSUE(agent-entry-binding-float-ts-divergence)).
|
|
1013
1252
|
if (!Number.isSafeInteger(ts) || !Number.isSafeInteger(validUntil)) {
|
|
1014
1253
|
return { ok: false, reason: 'device binding timestamps must be integers' };
|
|
1015
1254
|
}
|
|
@@ -1252,7 +1491,7 @@ export function createAgentEntry({
|
|
|
1252
1491
|
if (pathname === '/') {
|
|
1253
1492
|
const body = Buffer.from(
|
|
1254
1493
|
`${name}\n\nThis origin is agent-reachable (Muretai agent entry).\n`
|
|
1255
|
-
+ `DID: ${did}\nCard: ${
|
|
1494
|
+
+ `DID: ${did}\nCard: ${canonUrl}${AGENT_CARD_PATH}\n`
|
|
1256
1495
|
+ 'POST a signed A2A message/send request to / for a signed reply.\n', 'utf8');
|
|
1257
1496
|
return { status: 200,
|
|
1258
1497
|
headers: { 'Content-Type': 'text/plain; charset=utf-8',
|
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.1.0",
|
|
4
|
+
"description": "Your website can recognise AI agents, hold accounts for them, and answer them \u2014 one dependency-free file.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "muretai-agent-entry.mjs",
|
|
7
7
|
"exports": {
|